storage: persist message attachments and user AI settings

This commit is contained in:
2026-05-10 22:52:10 +03:00
parent 28f67aefc2
commit d666244863
14 changed files with 1147 additions and 45 deletions
+99
View File
@@ -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);
}
}
+57
View File
@@ -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);
}
}
+28 -4
View File
@@ -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<string, StoredMessage>();
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: <number>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<StoredMessage | null> {
@@ -38,7 +62,7 @@ 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;
}
+450
View File
@@ -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 users 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)};
}
+196
View File
@@ -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,
};
}
+22 -2
View File
@@ -1,6 +1,9 @@
import {User} from "typescript-telegram-bot-api";
import {userDao} from "../index";
import {StoredUser} from "../model/stored-user";
import {getLruMapValue, setLruMapValue} from "../util/lru-map";
const USER_CACHE_MAX_ENTRIES = 5_000;
export class UserStore {
private static map = new Map<number, StoredUser>();
@@ -10,6 +13,7 @@ export class UserStore {
}
static async put(u: User): Promise<StoredUser> {
const current = getLruMapValue(this.map, u.id);
const user: StoredUser = {
id: u.id,
isBot: u.is_bot,
@@ -17,19 +21,35 @@ 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<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> {
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;
}
+128 -3
View File
@@ -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<void> = 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<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}`));
}
}
+57 -14
View File
@@ -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<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> {
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,25 +63,33 @@ export async function readData(): Promise<void> {
}
export async function saveData(): Promise<void> {
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<void> {
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) {
+25 -3
View File
@@ -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<StoredMessage> {
private tag: string = "MessageDao";
override async getAll(): Promise<StoredMessage[]> {
await DatabaseManager.ready;
const then = Date.now();
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> {
await DatabaseManager.ready;
const then = Date.now();
const messages =
@@ -45,6 +48,7 @@ export class MessageDao extends Dao<StoredMessage> {
}
override async getByIds(params: { chatId: number, ids: number[] }): Promise<StoredMessage[]> {
await DatabaseManager.ready;
const then = Date.now();
const messages =
@@ -65,13 +69,16 @@ export class MessageDao extends Dao<StoredMessage> {
}
async insert(values: MessageInsert[]): Promise<true> {
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<StoredMessage> {
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<StoredMessage> {
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),
};
});
}
}
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
View File
@@ -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;
+40 -5
View File
@@ -12,6 +12,7 @@ export class UserDao extends Dao<StoredUser> {
private tag: string = "UserDao";
override async getAll(): Promise<StoredUser[]> {
await DatabaseManager.ready;
const then = Date.now();
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> {
await DatabaseManager.ready;
const then = Date.now();
const users =
@@ -43,6 +45,7 @@ export class UserDao extends Dao<StoredUser> {
}
override async getByIds(params: { ids: number[] }): Promise<StoredUser[]> {
await DatabaseManager.ready;
const then = Date.now();
const users =
@@ -60,7 +63,9 @@ export class UserDao extends Dao<StoredUser> {
}
override async insert(values: UserInsert[] | UserInsert): Promise<true> {
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<StoredUser> {
.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<StoredUser> {
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[] {
return users.map(u => {
return {
@@ -85,20 +112,28 @@ export class UserDao extends Dao<StoredUser> {
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: <number>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,
};
});
}
+11
View File
@@ -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;
};
+4
View File
@@ -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;
attachments?: StoredAttachment[] | null;
};
+8 -2
View File
@@ -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;
langCode?: string;
interfaceLanguage?: string;
aiProvider?: string;
aiResponseLanguage?: string;
aiContextSize?: number;
aiVoiceMode?: string;
}