storage: persist message attachments and user AI settings
This commit is contained in:
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
if (!msg.from) return;
|
||||||
|
|
||||||
|
await UserStore.put(msg.from);
|
||||||
|
const args = match?.[3]?.trim();
|
||||||
|
let settings = await ensureValidUserAiSettings(msg.from.id);
|
||||||
|
let screen: Parameters<typeof formatUserSettingsText>[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,14 @@ import {StoredMessage} from "../model/stored-message";
|
|||||||
import {Message} from "typescript-telegram-bot-api";
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
import {extractTextMessage, getPhotoMaxSize, isStoredMessage} from "../util/utils";
|
import {extractTextMessage, getPhotoMaxSize, isStoredMessage} from "../util/utils";
|
||||||
import {messageDao} from "../index";
|
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 {
|
export class MessageStore {
|
||||||
private static map = new Map<string, StoredMessage>();
|
private static map = new Map<string, StoredMessage>();
|
||||||
|
private static locks = new KeyedAsyncLock();
|
||||||
|
|
||||||
private static key(chatId: number, messageId: number) {
|
private static key(chatId: number, messageId: number) {
|
||||||
return `${chatId}:${messageId}`;
|
return `${chatId}:${messageId}`;
|
||||||
@@ -23,13 +28,32 @@ export class MessageStore {
|
|||||||
replyToMessageId: m.reply_to_message?.message_id,
|
replyToMessageId: m.reply_to_message?.message_id,
|
||||||
fromId: <number>m.from?.id,
|
fromId: <number>m.from?.id,
|
||||||
text: extractTextMessage(m),
|
text: extractTextMessage(m),
|
||||||
|
quoteText: m.quote?.text,
|
||||||
date: m.date ?? 0,
|
date: m.date ?? 0,
|
||||||
photoMaxSizeFilePath: maxSizePath ? [maxSizePath] : undefined,
|
photoMaxSizeFilePath: maxSizePath ? [maxSizePath] : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.map.set(this.key(msg.chatId, msg.id), msg);
|
const key = this.key(msg.chatId, msg.id);
|
||||||
await messageDao.insert(messageDao.mapStoredTo([msg]));
|
return this.locks.runExclusive(key, async () => {
|
||||||
return msg;
|
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<StoredMessage | null> {
|
static async get(chatId: number, messageId: number | undefined): Promise<StoredMessage | null> {
|
||||||
@@ -38,11 +62,11 @@ export class MessageStore {
|
|||||||
const message = await messageDao.getById({chatId: chatId, id: messageId});
|
const message = await messageDao.getById({chatId: chatId, id: messageId});
|
||||||
if (!message) return null;
|
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;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
static clear() {
|
static clear() {
|
||||||
this.map.clear();
|
this.map.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
|
||||||
|
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<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 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) {
|
||||||
|
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)};
|
||||||
|
}
|
||||||
@@ -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<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)),
|
||||||
|
].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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import {User} from "typescript-telegram-bot-api";
|
import {User} from "typescript-telegram-bot-api";
|
||||||
import {userDao} from "../index";
|
import {userDao} from "../index";
|
||||||
import {StoredUser} from "../model/stored-user";
|
import {StoredUser} from "../model/stored-user";
|
||||||
|
import {getLruMapValue, setLruMapValue} from "../util/lru-map";
|
||||||
|
|
||||||
|
const USER_CACHE_MAX_ENTRIES = 5_000;
|
||||||
|
|
||||||
export class UserStore {
|
export class UserStore {
|
||||||
private static map = new Map<number, StoredUser>();
|
private static map = new Map<number, StoredUser>();
|
||||||
@@ -10,6 +13,7 @@ export class UserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async put(u: User): Promise<StoredUser> {
|
static async put(u: User): Promise<StoredUser> {
|
||||||
|
const current = getLruMapValue(this.map, u.id);
|
||||||
const user: StoredUser = {
|
const user: StoredUser = {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
isBot: u.is_bot,
|
isBot: u.is_bot,
|
||||||
@@ -17,23 +21,39 @@ export class UserStore {
|
|||||||
lastName: u.last_name,
|
lastName: u.last_name,
|
||||||
userName: u.username,
|
userName: u.username,
|
||||||
isPremium: u.is_premium,
|
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]));
|
await userDao.insert(userDao.mapTo([u]));
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async updateSettings(
|
||||||
|
id: number,
|
||||||
|
settings: Partial<Pick<StoredUser, "interfaceLanguage" | "aiProvider" | "aiResponseLanguage" | "aiContextSize" | "aiVoiceMode">>
|
||||||
|
): 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> {
|
static async get(id: number): Promise<StoredUser | null> {
|
||||||
const user = await userDao.getById({id: id});
|
const user = await userDao.getById({id: id});
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
this.map.set(id, user);
|
setLruMapValue(this.map, id, user, USER_CACHE_MAX_ENTRIES);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
static clear() {
|
static clear() {
|
||||||
this.map.clear();
|
this.map.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-4
@@ -2,16 +2,141 @@ import "dotenv/config";
|
|||||||
import {drizzle, LibSQLDatabase} from "drizzle-orm/libsql";
|
import {drizzle, LibSQLDatabase} from "drizzle-orm/libsql";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {logError} from "../util/utils";
|
import {logError} from "../util/utils";
|
||||||
|
import {sql} from "drizzle-orm";
|
||||||
|
|
||||||
|
type TableInfoRow = {
|
||||||
|
name: string;
|
||||||
|
pk: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class DatabaseManager {
|
export class DatabaseManager {
|
||||||
|
|
||||||
static db: LibSQLDatabase;
|
static db: LibSQLDatabase;
|
||||||
|
static ready: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
static init() {
|
static init() {
|
||||||
try {
|
DatabaseManager.db = drizzle(Environment.DB_PATH);
|
||||||
DatabaseManager.db = drizzle(Environment.DB_PATH);
|
DatabaseManager.ready = DatabaseManager.ensureSchema().catch(e => {
|
||||||
} catch (e) {
|
|
||||||
logError(e);
|
logError(e);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getTableInfo(tableName: string): Promise<TableInfoRow[]> {
|
||||||
|
return DatabaseManager.db.all<TableInfoRow>(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<void> {
|
||||||
|
await DatabaseManager.ensureUsersTable();
|
||||||
|
await DatabaseManager.ensureMessagesTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async ensureUsersTable(): Promise<void> {
|
||||||
|
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'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static async createMessagesTable(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+58
-15
@@ -2,6 +2,8 @@ import * as fs from "fs";
|
|||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {logError} from "../util/utils";
|
import {logError} from "../util/utils";
|
||||||
import {Answers} from "../model/answers";
|
import {Answers} from "../model/answers";
|
||||||
|
import path from "node:path";
|
||||||
|
import {KeyedAsyncLock} from "../util/async-lock";
|
||||||
|
|
||||||
type DataJsonFile = {
|
type DataJsonFile = {
|
||||||
admins: number[]
|
admins: number[]
|
||||||
@@ -10,9 +12,42 @@ type DataJsonFile = {
|
|||||||
|
|
||||||
export let jsonFile: 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<T>(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<void> {
|
export async function readData(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
jsonFile = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/data.json`).toString());
|
jsonFile = readJsonFile("data.json", DEFAULT_DATA);
|
||||||
|
|
||||||
const admins = jsonFile.admins || [];
|
const admins = jsonFile.admins || [];
|
||||||
admins.unshift(Environment.CREATOR_ID);
|
admins.unshift(Environment.CREATOR_ID);
|
||||||
@@ -28,29 +63,37 @@ export async function readData(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveData(): Promise<void> {
|
export async function saveData(): Promise<void> {
|
||||||
const adminIds: number[] = [];
|
return dataFileLock.runExclusive("data.json", async () => {
|
||||||
Environment.ADMIN_IDS.forEach(id => adminIds.push(id));
|
ensureDataPath();
|
||||||
jsonFile.admins = adminIds;
|
jsonFile ??= structuredClone(DEFAULT_DATA);
|
||||||
|
|
||||||
const mutedList: number[] = [];
|
const adminIds: number[] = [];
|
||||||
Environment.MUTED_IDS.forEach(id => mutedList.push(id));
|
Environment.ADMIN_IDS.forEach(id => adminIds.push(id));
|
||||||
jsonFile.muted = mutedList;
|
jsonFile.admins = adminIds;
|
||||||
|
|
||||||
try {
|
const mutedList: number[] = [];
|
||||||
fs.writeFileSync(`${Environment.DATA_PATH}/data.json`, JSON.stringify(jsonFile));
|
Environment.MUTED_IDS.forEach(id => mutedList.push(id));
|
||||||
return readData();
|
jsonFile.muted = mutedList;
|
||||||
} catch (e) {
|
|
||||||
return Promise.reject(e);
|
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<void> {
|
export async function retrieveAnswers(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const json: Answers = JSON.parse(fs.readFileSync(`${Environment.DATA_PATH}/answers.json`).toString());
|
const json = readJsonFile("answers.json", DEFAULT_ANSWERS);
|
||||||
Environment.setAnswers(json);
|
Environment.setAnswers(json);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(e);
|
logError(e);
|
||||||
return Promise.reject(e);
|
return Promise.reject(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-4
@@ -5,12 +5,14 @@ import {and, eq} from "drizzle-orm";
|
|||||||
import {inArray} from "drizzle-orm/sql/expressions/conditions";
|
import {inArray} from "drizzle-orm/sql/expressions/conditions";
|
||||||
import {Dao} from "../base/dao";
|
import {Dao} from "../base/dao";
|
||||||
import {buildExcludedSet} from "../util/utils";
|
import {buildExcludedSet} from "../util/utils";
|
||||||
|
import {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
|
||||||
export class MessageDao extends Dao<StoredMessage> {
|
export class MessageDao extends Dao<StoredMessage> {
|
||||||
|
|
||||||
private tag: string = "MessageDao";
|
private tag: string = "MessageDao";
|
||||||
|
|
||||||
override async getAll(): Promise<StoredMessage[]> {
|
override async getAll(): Promise<StoredMessage[]> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
|
|
||||||
const messages = await DatabaseManager.db.select().from(messagesTable);
|
const messages = await DatabaseManager.db.select().from(messagesTable);
|
||||||
@@ -23,6 +25,7 @@ export class MessageDao extends Dao<StoredMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async getById(params: { chatId: number, id: number }): Promise<StoredMessage | null> {
|
override async getById(params: { chatId: number, id: number }): Promise<StoredMessage | null> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
|
|
||||||
const messages =
|
const messages =
|
||||||
@@ -45,6 +48,7 @@ export class MessageDao extends Dao<StoredMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async getByIds(params: { chatId: number, ids: number[] }): Promise<StoredMessage[]> {
|
override async getByIds(params: { chatId: number, ids: number[] }): Promise<StoredMessage[]> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
|
|
||||||
const messages =
|
const messages =
|
||||||
@@ -65,13 +69,16 @@ export class MessageDao extends Dao<StoredMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async insert(values: MessageInsert[]): Promise<true> {
|
async insert(values: MessageInsert[]): Promise<true> {
|
||||||
|
if (!values.length) return true;
|
||||||
|
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
const r = await DatabaseManager.db
|
const r = await DatabaseManager.db
|
||||||
.insert(messagesTable)
|
.insert(messagesTable)
|
||||||
.values(values)
|
.values(values)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: messagesTable.id,
|
target: [messagesTable.chatId, messagesTable.id],
|
||||||
set: buildExcludedSet(messagesTable, ["id"])
|
set: buildExcludedSet(messagesTable, ["chatId", "id"])
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -88,8 +95,10 @@ export class MessageDao extends Dao<StoredMessage> {
|
|||||||
replyToMessageId: msg.replyToMessageId,
|
replyToMessageId: msg.replyToMessageId,
|
||||||
fromId: msg.fromId,
|
fromId: msg.fromId,
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
|
quoteText: msg.quoteText,
|
||||||
date: msg.date,
|
date: msg.date,
|
||||||
photoMaxSizeFilePath: msg.photoMaxSizeFilePath?.join(";"),
|
photoMaxSizeFilePath: msg.photoMaxSizeFilePath?.join(";"),
|
||||||
|
attachments: msg.attachments?.length ? JSON.stringify(msg.attachments) : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,9 +111,22 @@ export class MessageDao extends Dao<StoredMessage> {
|
|||||||
replyToMessageId: m.replyToMessageId || undefined,
|
replyToMessageId: m.replyToMessageId || undefined,
|
||||||
fromId: m.fromId,
|
fromId: m.fromId,
|
||||||
text: m.text,
|
text: m.text,
|
||||||
|
quoteText: m.quoteText,
|
||||||
date: m.date,
|
date: m.date,
|
||||||
photoMaxSizeFilePath: m.photoMaxSizeFilePath?.split(";")
|
photoMaxSizeFilePath: m.photoMaxSizeFilePath?.split(";"),
|
||||||
|
attachments: parseAttachments(m.attachments),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-4
@@ -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", {
|
export const messagesTable = sqliteTable("messages", {
|
||||||
id: int().primaryKey().unique().notNull(),
|
id: int().notNull(),
|
||||||
chatId: int().notNull(),
|
chatId: int().notNull(),
|
||||||
replyToMessageId: int(),
|
replyToMessageId: int(),
|
||||||
fromId: int().notNull(),
|
fromId: int().notNull(),
|
||||||
text: text(),
|
text: text(),
|
||||||
|
quoteText: text(),
|
||||||
date: int().notNull(),
|
date: int().notNull(),
|
||||||
photoMaxSizeFilePath: text(),
|
photoMaxSizeFilePath: text(),
|
||||||
});
|
attachments: text(),
|
||||||
|
}, table => [
|
||||||
|
primaryKey({columns: [table.chatId, table.id]}),
|
||||||
|
]);
|
||||||
|
|
||||||
export type MessageInsert = typeof messagesTable.$inferInsert;
|
export type MessageInsert = typeof messagesTable.$inferInsert;
|
||||||
|
|
||||||
export const usersTable = sqliteTable("users", {
|
export const usersTable = sqliteTable("users", {
|
||||||
id: int().primaryKey().unique().notNull(),
|
id: int().primaryKey().notNull(),
|
||||||
isBot: int().notNull(),
|
isBot: int().notNull(),
|
||||||
firstName: text().notNull(),
|
firstName: text().notNull(),
|
||||||
lastName: text(),
|
lastName: text(),
|
||||||
userName: text(),
|
userName: text(),
|
||||||
isPremium: int(),
|
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;
|
export type UserInsert = typeof usersTable.$inferInsert;
|
||||||
|
|||||||
+41
-6
@@ -12,6 +12,7 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
private tag: string = "UserDao";
|
private tag: string = "UserDao";
|
||||||
|
|
||||||
override async getAll(): Promise<StoredUser[]> {
|
override async getAll(): Promise<StoredUser[]> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
|
|
||||||
const users = await DatabaseManager.db.select().from(usersTable);
|
const users = await DatabaseManager.db.select().from(usersTable);
|
||||||
@@ -24,6 +25,7 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async getById(params: { id: number }): Promise<StoredUser | null> {
|
override async getById(params: { id: number }): Promise<StoredUser | null> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
|
|
||||||
const users =
|
const users =
|
||||||
@@ -43,6 +45,7 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async getByIds(params: { ids: number[] }): Promise<StoredUser[]> {
|
override async getByIds(params: { ids: number[] }): Promise<StoredUser[]> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
|
|
||||||
const users =
|
const users =
|
||||||
@@ -60,7 +63,9 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async insert(values: UserInsert[] | UserInsert): Promise<true> {
|
override async insert(values: UserInsert[] | UserInsert): Promise<true> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
const rows = Array.isArray(values) ? values : [values];
|
const rows = Array.isArray(values) ? values : [values];
|
||||||
|
if (!rows.length) return true;
|
||||||
|
|
||||||
const then = Date.now();
|
const then = Date.now();
|
||||||
const r = await DatabaseManager.db
|
const r = await DatabaseManager.db
|
||||||
@@ -68,7 +73,7 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
.values(rows)
|
.values(rows)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: usersTable.id,
|
target: usersTable.id,
|
||||||
set: buildExcludedSet(usersTable, ["id"])
|
set: buildExcludedSet(usersTable, ["id", "interfaceLanguage", "aiProvider", "aiResponseLanguage", "aiContextSize", "aiVoiceMode"])
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -77,6 +82,28 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSettings(
|
||||||
|
id: number,
|
||||||
|
settings: Partial<Pick<StoredUser, "interfaceLanguage" | "aiProvider" | "aiResponseLanguage" | "aiContextSize" | "aiVoiceMode">>
|
||||||
|
): Promise<true> {
|
||||||
|
await DatabaseManager.ready;
|
||||||
|
|
||||||
|
const update: Partial<UserInsert> = {};
|
||||||
|
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[] {
|
mapTo(users: User[]): UserInsert[] {
|
||||||
return users.map(u => {
|
return users.map(u => {
|
||||||
return {
|
return {
|
||||||
@@ -85,21 +112,29 @@ export class UserDao extends Dao<StoredUser> {
|
|||||||
firstName: u.first_name,
|
firstName: u.first_name,
|
||||||
lastName: u.last_name,
|
lastName: u.last_name,
|
||||||
userName: u.username,
|
userName: u.username,
|
||||||
isPremium: boolToInt(u.is_premium)
|
isPremium: boolToInt(u.is_premium),
|
||||||
|
langCode: u.language_code
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
mapFrom(users: UserInsert[]): StoredUser[] {
|
mapFrom(users: UserInsert[]): StoredUser[] {
|
||||||
|
// @ts-ignore
|
||||||
return users.map(u => {
|
return users.map(u => {
|
||||||
return {
|
return {
|
||||||
id: <number>u.id,
|
id: <number>u.id,
|
||||||
isBot: u.isBot === 1,
|
isBot: u.isBot === 1,
|
||||||
firstName: u.firstName,
|
firstName: u.firstName,
|
||||||
lastName: u.lastName,
|
lastName: u.lastName === null ? undefined : u.lastName,
|
||||||
userName: u.userName,
|
userName: u.userName === null ? undefined : u.userName,
|
||||||
isPremium: u.isPremium === 1
|
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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import {StoredAttachment} from "./stored-attachment";
|
||||||
|
|
||||||
export type StoredMessage = {
|
export type StoredMessage = {
|
||||||
chatId: number;
|
chatId: number;
|
||||||
id: number;
|
id: number;
|
||||||
replyToMessageId?: number;
|
replyToMessageId?: number;
|
||||||
fromId: number;
|
fromId: number;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
quoteText?: string | null;
|
||||||
date: number;
|
date: number;
|
||||||
photoMaxSizeFilePath?: string[] | null;
|
photoMaxSizeFilePath?: string[] | null;
|
||||||
};
|
attachments?: StoredAttachment[] | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ export type StoredUser = {
|
|||||||
id: number;
|
id: number;
|
||||||
isBot: boolean;
|
isBot: boolean;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName?: string | null;
|
lastName?: string;
|
||||||
userName?: string | null;
|
userName?: string;
|
||||||
isPremium?: boolean;
|
isPremium?: boolean;
|
||||||
}
|
langCode?: string;
|
||||||
|
interfaceLanguage?: string;
|
||||||
|
aiProvider?: string;
|
||||||
|
aiResponseLanguage?: string;
|
||||||
|
aiContextSize?: number;
|
||||||
|
aiVoiceMode?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user