diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b11be4b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +TELEGRAM_BOT_KEY=your_bot_token_here +TELEGRAM_API_BASE_URL=https://api.telegram.org diff --git a/.gitignore b/.gitignore index 6ab7e13..45ec194 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /assets/selfies/* /assets/archive/selfies/* /assets/journal_entry_images/* +/assets/custom_404/* /bin *.db +.env jotbot \ No newline at end of file diff --git a/README.md b/README.md index 0d361a8..6abd8ed 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,25 @@ Jotbot is a telegram bot that can help you to record your thoughts and emotions in directly in the telegram app. +## Configuration + +Jotbot uses environment variables for configuration. Copy `.env.example` to +`.env` and fill in your values: + +```bash +cp .env.example .env +``` + +### Environment Variables + +| Variable | Description | Default | +| ----------------------- | --------------------------------------- | -------------------------- | +| `TELEGRAM_BOT_KEY` | Your Telegram bot token from @BotFather | (required) | +| `TELEGRAM_API_BASE_URL` | Custom Telegram Bot API URL | `https://api.telegram.org` | + +The `TELEGRAM_API_BASE_URL` is useful when running behind a proxy or using a +self-hosted Telegram API. + ## How do I use Jotbot? Jotbot is easy to use you have to be "registered" to start recording entries. diff --git a/constants/paths.ts b/constants/paths.ts index 1939309..7360bfc 100644 --- a/constants/paths.ts +++ b/constants/paths.ts @@ -3,4 +3,6 @@ export const dbFile: string = `${dbFileBasePath}/jotbot.db`; export const testDbFileBasePath = `db/test_db`; export const testDbFile: string = `${testDbFileBasePath}/jotbot_test.db`; export const selfieDirPath: string = `${Deno.cwd()}/assets/selfies`; +export const custom404DirPath: string = `${Deno.cwd()}/assets/custom_404`; export const sqlFilePath = "db/sql"; +export const default404ImagePath = "assets/404.png"; diff --git a/constants/strings.ts b/constants/strings.ts index 5869f6f..d094bb2 100644 --- a/constants/strings.ts +++ b/constants/strings.ts @@ -1,7 +1,5 @@ export const startString: string = `Hello! Welcome to JotBot. I'm here to help you record your emotions and emotion!`; -export const telegramDownloadUrl = - "https://api.telegram.org/file/bot/"; export const catImagesApiBaseUrl = `https://cataas.com`; export const quotesApiBaseUrl = `https://zenquotes.io/api/quotes/`; diff --git a/db/migration.ts b/db/migration.ts index 79798d0..9e04a1b 100644 --- a/db/migration.ts +++ b/db/migration.ts @@ -71,6 +71,18 @@ export function createSettingsTable(dbFile: PathLike) { } } +export function addCustom404ImagePathColumn(dbFile: PathLike) { + try { + const db = new DatabaseSync(dbFile); + db.exec("PRAGMA foreign_keys = ON;"); + db.prepare("ALTER TABLE settings_db ADD COLUMN custom404ImagePath TEXT;") + .run(); + db.close(); + } catch (err) { + console.error(`Failed to add custom404ImagePath column: ${err}`); + } +} + export function createJournalTable(dbFile: PathLike) { try { const db = new DatabaseSync(dbFile); diff --git a/db/sql/create_settings_table.sql b/db/sql/create_settings_table.sql index 07d1411..9e83788 100644 --- a/db/sql/create_settings_table.sql +++ b/db/sql/create_settings_table.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS settings_db ( id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER, storeMentalHealthInfo INTEGER DEFAULT 0, + custom404ImagePath TEXT, FOREIGN KEY (userId) REFERENCES user_db(telegramId) ON DELETE CASCADE ); \ No newline at end of file diff --git a/handlers/delete_account.ts b/handlers/delete_account.ts index 39ea9f7..0dcb0f9 100644 --- a/handlers/delete_account.ts +++ b/handlers/delete_account.ts @@ -2,6 +2,7 @@ import { Context } from "grammy"; import { Conversation } from "@grammyjs/conversations"; import { deleteAccountConfirmKeyboard } from "../utils/keyboards.ts"; import { deleteUser } from "../models/user.ts"; +import { getSettingsById } from "../models/settings.ts"; import { dbFile } from "../constants/paths.ts"; export async function delete_account(conversation: Conversation, ctx: Context) { @@ -17,6 +18,14 @@ export async function delete_account(conversation: Conversation, ctx: Context) { ]); if (deleteAccountCtx.callbackQuery.data === "delete-account-yes") { + const settings = getSettingsById(ctx.from?.id!, dbFile); + if (settings?.custom404ImagePath) { + try { + await Deno.remove(settings.custom404ImagePath); + } catch (err) { + console.error(`Failed to delete custom 404 image: ${err}`); + } + } await conversation.external(() => deleteUser(ctx.from?.id!, dbFile)); } else if (deleteAccountCtx.callbackQuery.data === "delete-account-no") { conversation.halt(); diff --git a/handlers/new_entry.ts b/handlers/new_entry.ts index af5bd24..813ce85 100644 --- a/handlers/new_entry.ts +++ b/handlers/new_entry.ts @@ -2,8 +2,8 @@ import { Context, InlineKeyboard } from "grammy"; import { Conversation } from "@grammyjs/conversations"; import { Emotion, Entry } from "../types/types.ts"; import { insertEntry } from "../models/entry.ts"; -import { telegramDownloadUrl } from "../constants/strings.ts"; import { dbFile } from "../constants/paths.ts"; +import { getTelegramDownloadUrl } from "../utils/telegram.ts"; export async function new_entry(conversation: Conversation, ctx: Context) { // Describe situation @@ -74,7 +74,7 @@ export async function new_entry(conversation: Conversation, ctx: Context) { const tmpFile = await selfiePathCtx.getFile(); // console.log(selfiePathCtx.message.c); const selfieResponse = await fetch( - telegramDownloadUrl.replace("", ctx.api.token).replace( + getTelegramDownloadUrl().replace("", ctx.api.token).replace( "", tmpFile.file_path!, ), diff --git a/handlers/register.ts b/handlers/register.ts index 90c8076..ffe0aa4 100644 --- a/handlers/register.ts +++ b/handlers/register.ts @@ -1,10 +1,19 @@ import { Context, InlineKeyboard } from "grammy"; import { Conversation } from "@grammyjs/conversations"; -import { insertUser } from "../models/user.ts"; +import { insertUser, userExists } from "../models/user.ts"; import { User } from "../types/types.ts"; import { dbFile } from "../constants/paths.ts"; +import { getSettingsById, insertSettings } from "../models/settings.ts"; export async function register(conversation: Conversation, ctx: Context) { + // Check if user already exists + if (userExists(ctx.from?.id!, dbFile)) { + await ctx.reply( + `You are already registered, ${ctx.from?.username}! Use /new_entry to create a new entry.`, + ); + return; + } + let dob; try { while (true) { @@ -37,11 +46,41 @@ export async function register(conversation: Conversation, ctx: Context) { console.log(user); try { insertUser(user, dbFile); + console.log("User inserted, now inserting settings..."); + insertSettings(ctx.from?.id!, dbFile); + console.log("Settings inserted for user:", ctx.from?.id); + const settingsCheck = getSettingsById(ctx.from?.id!, dbFile); + console.log("Settings after insert:", settingsCheck); } catch (err) { ctx.reply(`Failed to save user ${user.username}: ${err}`); console.log(`Error inserting user ${user.username}: ${err}`); } - ctx.reply( + + await ctx.editMessageText( + `Before we finish, would you like to set a custom 404 image? This image will be shown when viewing entries without a selfie.`, + { + reply_markup: new InlineKeyboard().text("Yes", "set-custom-404").text( + "No", + "skip-custom-404", + ), + }, + ); + + const custom404Ctx = await conversation.waitForCallbackQuery([ + "set-custom-404", + "skip-custom-404", + ]); + + if (custom404Ctx.callbackQuery.data === "set-custom-404") { + await custom404Ctx.editMessageText("Setting up custom 404 image..."); + await conversation.select("set_custom_404_image"); + } else { + await custom404Ctx.editMessageText( + "Skipped. You can set a custom 404 image anytime from Settings.", + ); + } + + await ctx.reply( `Welcome ${user.username}! You have been successfully registered. Would you like to start by recording an entry?`, { reply_markup: new InlineKeyboard().text("New Entry", "new-entry") }, ); diff --git a/handlers/set_custom_404_image.ts b/handlers/set_custom_404_image.ts new file mode 100644 index 0000000..67185cd --- /dev/null +++ b/handlers/set_custom_404_image.ts @@ -0,0 +1,103 @@ +import { Context } from "grammy"; +import { Conversation } from "@grammyjs/conversations"; +import { getSettingsById, updateSettings } from "../models/settings.ts"; +import { custom404DirPath, dbFile } from "../constants/paths.ts"; +import { getTelegramDownloadUrl } from "../utils/telegram.ts"; + +export async function set_custom_404_image( + conversation: Conversation, + ctx: Context, +) { + const userId = ctx.from?.id!; + const settings = getSettingsById(userId, dbFile); + console.log("Current settings:", settings); + + if (settings?.custom404ImagePath) { + await ctx.reply( + `You already have a custom 404 image set at: ${settings.custom404ImagePath}. Send a new image to replace it, /default to reset to default, or /cancel to keep current.`, + ); + } else { + await ctx.reply( + `Send an image to use as your custom 404 image, /default to reset to default, or /cancel to skip.`, + ); + } + + const choiceCtx = await conversation.wait(); + + if (choiceCtx.message?.text === "/cancel") { + await ctx.reply("Cancelled. Your custom 404 image has not been changed."); + return; + } + + if (choiceCtx.message?.text === "/default") { + const currentPath = settings?.custom404ImagePath; + if (currentPath) { + try { + await Deno.remove(currentPath); + console.log("Deleted old custom 404 image:", currentPath); + } catch (err) { + console.error(`Failed to delete custom 404 image: ${err}`); + } + } + settings!.custom404ImagePath = null; + await conversation.external(() => + updateSettings(userId, settings!, dbFile) + ); + console.log("Reset custom404ImagePath to null"); + await ctx.reply("Reset to default 404 image."); + return; + } + + if (choiceCtx.message?.photo) { + try { + const tmpFile = await choiceCtx.getFile(); + console.log("Downloading file from:", tmpFile.file_path); + const selfieResponse = await fetch( + getTelegramDownloadUrl().replace("", ctx.api.token).replace( + "", + tmpFile.file_path!, + ), + ); + + if (selfieResponse.body) { + await conversation.external(async () => { + const fileName = `404_${userId}.jpg`; + const filePath = `${custom404DirPath}/${fileName}`; + console.log("Saving custom 404 image to:", filePath); + const file = await Deno.open(filePath, { + write: true, + create: true, + }); + + const currentPath = settings?.custom404ImagePath; + if (currentPath && currentPath !== filePath) { + try { + Deno.removeSync(currentPath); + console.log("Deleted old custom 404 image:", currentPath); + } catch (err) { + console.error(`Failed to delete old custom 404 image: ${err}`); + } + } + + const realPath = await Deno.realPath(filePath); + await selfieResponse.body.pipeTo(file.writable); + console.log("Custom 404 image saved to:", realPath); + + settings!.custom404ImagePath = realPath; + updateSettings(userId, settings!, dbFile); + console.log("Updated settings with custom404ImagePath:", realPath); + }); + + await ctx.reply("Custom 404 image saved successfully!"); + } + } catch (err) { + console.log(`Jotbot Error: Failed to save custom 404 image: ${err}`); + await ctx.reply("Failed to save custom 404 image. Please try again."); + } + return; + } + + await ctx.reply( + "Invalid input. Please send an image, /default, or /cancel.", + ); +} diff --git a/handlers/view_entries.ts b/handlers/view_entries.ts index 3885b41..3191a9c 100644 --- a/handlers/view_entries.ts +++ b/handlers/view_entries.ts @@ -9,18 +9,25 @@ import { Entry } from "../types/types.ts"; import { viewEntriesKeyboard } from "../utils/keyboards.ts"; import { entryFromString } from "../utils/misc.ts"; import { InputFile } from "grammy/types"; -import { dbFile } from "../constants/paths.ts"; +import { dbFile, default404ImagePath } from "../constants/paths.ts"; +import { getSettingsById } from "../models/settings.ts"; export async function view_entries(conversation: Conversation, ctx: Context) { let entries: Entry[] = await conversation.external(() => getAllEntriesByUserId(ctx.from?.id!, dbFile) ); - // If there are no stored entries inform user and stop conversation if (entries.length === 0) { return await ctx.api.sendMessage(ctx.chatId!, "No entries to view."); } + const settings = getSettingsById(ctx.from?.id!, dbFile); + console.log("Settings for user:", settings); + const custom404Path = settings?.custom404ImagePath; + console.log("Custom 404 path:", custom404Path); + const fallback404Image = custom404Path || default404ImagePath; + console.log("Using fallback image:", fallback404Image); + let currentEntry: number = 0; let lastEditedTimestampString = `Last Edited ${ entries[currentEntry].lastEditedTimestamp @@ -54,7 +61,7 @@ Page ${currentEntry + 1} of ${entries.length} // Reply initially with first entry before starting loop const displaySelfieMsg = await ctx.replyWithPhoto( - new InputFile(entries[currentEntry].selfiePath! || "assets/404.png"), + new InputFile(entries[currentEntry].selfiePath! || fallback404Image), { caption: selfieCaptionString, parse_mode: "HTML" }, ); @@ -266,7 +273,7 @@ Page ${currentEntry + 1} of ${entries.length} ctx.chatId!, displaySelfieMsg.message_id, InputMediaBuilder.photo( - new InputFile(entries[currentEntry].selfiePath! || "assets/404.png"), + new InputFile(entries[currentEntry].selfiePath! || fallback404Image), { caption: selfieCaptionString, parse_mode: "HTML" }, ), ); diff --git a/main.ts b/main.ts index b8cb4aa..253794d 100644 --- a/main.ts +++ b/main.ts @@ -19,9 +19,9 @@ import { } from "@grammyjs/commands"; import { FileFlavor, hydrateFiles } from "@grammyjs/files"; import { + getSettingsKeyboard, mainCustomKeyboard, registerKeyboard, - settingsKeyboard, } from "./utils/keyboards.ts"; import { delete_account } from "./handlers/delete_account.ts"; import { view_entries } from "./handlers/view_entries.ts"; @@ -30,14 +30,34 @@ import { kitties } from "./handlers/kitties.ts"; import { phq9_assessment } from "./handlers/phq9_assessment.ts"; import { gad7_assessment } from "./handlers/gad7_assessment.ts"; import { new_journal_entry } from "./handlers/new_journal_entry.ts"; -import { dbFile } from "./constants/paths.ts"; -import { createDatabase, getLatestId } from "./utils/dbUtils.ts"; +import { set_custom_404_image } from "./handlers/set_custom_404_image.ts"; +import { custom404DirPath, dbFile } from "./constants/paths.ts"; +import { createDatabase, getLatestId, runMigrations } from "./utils/dbUtils.ts"; import { getSettingsById, updateSettings } from "./models/settings.ts"; import { getPhqScoreById } from "./models/phq9_score.ts"; import { getGadScoreById } from "./models/gad7_score.ts"; import { view_journal_entries } from "./handlers/view_journal_entries.ts"; if (import.meta.main) { + // Load .env file if it exists + if (existsSync(".env")) { + try { + const envContent = await Deno.readTextFile(".env"); + for (const line of envContent.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + const [key, ...valueParts] = trimmed.split("="); + if (key && valueParts.length) { + Deno.env.set(key, valueParts.join("=")); + } + } + } + console.log("Loaded .env file"); + } catch (err) { + console.error(`Failed to load .env file: ${err}`); + } + } + // Check if database is present and if not create one // Check if db file exists if not create it and the tables @@ -49,6 +69,8 @@ if (import.meta.main) { console.error(`Failed to created database: ${err}`); } } else { + console.log("Database found! Running migrations..."); + runMigrations(dbFile); console.log("Database found! Starting bot."); } @@ -62,6 +84,24 @@ if (import.meta.main) { } } + if (!existsSync(custom404DirPath)) { + try { + Deno.mkdir(custom404DirPath); + } catch (err) { + console.error(`Failed to create custom 404 image directory: ${err}`); + Deno.exit(1); + } + } + + if (!existsSync("assets/journal_entry_images")) { + try { + Deno.mkdir("assets/journal_entry_images"); + } catch (err) { + console.error(`Failed to create journal entry images directory: ${err}`); + Deno.exit(1); + } + } + type JotBotContext = & Context & CommandsFlavor @@ -86,6 +126,7 @@ if (import.meta.main) { jotBot.use(createConversation(gad7_assessment)); jotBot.use(createConversation(new_journal_entry)); jotBot.use(createConversation(view_journal_entries)); + jotBot.use(createConversation(set_custom_404_image)); jotBotCommands.command("start", "Starts the bot.", async (ctx) => { // Check if user exists in Database @@ -117,7 +158,10 @@ if (import.meta.main) { }); jotBotCommands.command("settings", "Open the settings menu", async (ctx) => { - await ctx.reply("Settings", { reply_markup: settingsKeyboard }); + const settings = getSettingsById(ctx.from?.id!, dbFile); + await ctx.reply("Settings", { + reply_markup: getSettingsKeyboard(settings), + }); }); jotBotCommands.command("kitties", "Start the kitty engine!", async (ctx) => { @@ -327,7 +371,7 @@ ${entries[entry].automaticThoughts} }); jotBot.callbackQuery( - ["smhs", "settings-back"], + ["smhs", "custom-404-image", "settings-back"], async (ctx) => { switch (ctx.callbackQuery.data) { case "smhs": { @@ -338,7 +382,7 @@ ${entries[entry].automaticThoughts} await ctx.editMessageText( `I will NOT store your GAD-7 and PHQ-9 scores`, { - reply_markup: settingsKeyboard, + reply_markup: getSettingsKeyboard(settings), parse_mode: "HTML", }, ); @@ -347,7 +391,7 @@ ${entries[entry].automaticThoughts} await ctx.editMessageText( `I WILL store your GAD-7 and PHQ-9 scores`, { - reply_markup: settingsKeyboard, + reply_markup: getSettingsKeyboard(settings), parse_mode: "HTML", }, ); @@ -355,6 +399,10 @@ ${entries[entry].automaticThoughts} updateSettings(ctx.from?.id!, settings!, dbFile); break; } + case "custom-404-image": { + await ctx.conversation.enter("set_custom_404_image"); + break; + } case "settings-back": { await ctx.editMessageText("Done with settings."); break; diff --git a/models/entry.ts b/models/entry.ts index 9e3bafa..00d8ffa 100644 --- a/models/entry.ts +++ b/models/entry.ts @@ -19,6 +19,10 @@ export function insertEntry(entry: Entry, dbFile: PathLike) { !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") ) throw new Error("JotBot Error: Databaes integrety check failed!"); db.exec("PRAGMA foreign_keys = ON;"); + const emotionEmoji = + entry.emotion.emotionEmoji && entry.emotion.emotionEmoji.length > 0 + ? entry.emotion.emotionEmoji + : null; const queryResult = db.prepare(query).run( entry.userId, entry.timestamp!, @@ -26,7 +30,7 @@ export function insertEntry(entry: Entry, dbFile: PathLike) { entry.situation, entry.automaticThoughts, entry.emotion.emotionName, - entry.emotion.emotionEmoji || null, + emotionEmoji, entry.emotion.emotionDescription, entry.selfiePath || null, ); diff --git a/models/settings.ts b/models/settings.ts index 6e534ad..e75b7f2 100644 --- a/models/settings.ts +++ b/models/settings.ts @@ -40,9 +40,10 @@ export function updateSettings( db.exec("PRAGMA foreign_keys = ON;"); const queryResult = db.prepare( - `UPDATE OR FAIL settings_db SET storeMentalHealthInfo = ? WHERE userId = ${userId}`, + `UPDATE OR FAIL settings_db SET storeMentalHealthInfo = ?, custom404ImagePath = ? WHERE userId = ${userId}`, ).run( Number(updatedSettings.storeMentalHealthInfo), + updatedSettings.custom404ImagePath || null, ); db.close(); return queryResult; @@ -76,7 +77,7 @@ export function getSettingsById(userId: number, dbFile: PathLike) { storeMentalHealthInfo: Boolean( Number(queryResult?.storeMentalHealthInfo!), ), - selfieDirectory: String(queryResult?.selfieDirectory!), + custom404ImagePath: queryResult?.custom404ImagePath?.toString() || null, }; } catch (err) { console.error( diff --git a/run.sh b/run.sh index b58528f..85a609b 100755 --- a/run.sh +++ b/run.sh @@ -25,22 +25,30 @@ function setup_env() { } if [ ! $file ]; then - echo "No key file passed, exitting."; - exit 1; -fi - -# Get keys -get_keys; -if [ $? != 0 ]; then - echo "Failed to extract keys from file path provided! Failed to start bot."; - exit 1; -fi + # Check for .env file + if [ -f .env ]; then + echo "Loading environment from .env file..."; + set -a; + source .env; + set +a; + else + echo "No key file passed, exiting."; + exit 1; + fi +else + # Get keys + get_keys; + if [ $? != 0 ]; then + echo "Failed to extract keys from file path provided! Failed to start bot."; + exit 1; + fi -# Setup the environment variables -setup_env; -if [ $? != 0 ]; then - echo "Failed to setup environment variables! Failed to start bot."; - exit 1; + # Setup the environment variables + setup_env; + if [ $? != 0 ]; then + echo "Failed to setup environment variables! Failed to start bot."; + exit 1; + fi fi # Process args diff --git a/tests/journal_entry_photo_test.ts b/tests/journal_entry_photo_test.ts index 6c0ded8..35a22ba 100644 --- a/tests/journal_entry_photo_test.ts +++ b/tests/journal_entry_photo_test.ts @@ -79,12 +79,14 @@ Deno.test("Test updateJournalEntryPhoto", () => { }); Deno.test("Test deleteJournalEntryPhoto", () => { - // TODO + // TODO: Write proper test for journal entry photo deleteion }); Deno.test("Test getJournalEntryPhotosByJournalEntryId", () => { - // TODO + // TODO: Write proper test for photos(s) retrieval from the journal by entry id(may be multiple) }); Deno.test("Test getJournalEntryPhotoById", () => { + // TODO: Write proper test for photo retrieval by entry id(one photo per entry?) + // NOTE: @NiXTheDev: isn't the above test a duplicate? }); diff --git a/tests/settings_test.ts b/tests/settings_test.ts index 75c969e..9d3c9b5 100644 --- a/tests/settings_test.ts +++ b/tests/settings_test.ts @@ -12,12 +12,14 @@ import { Settings } from "../types/types.ts"; import { assertObjectMatch } from "@std/assert/object-match"; import { existsSync } from "node:fs"; -// Create test db directory structure if (!existsSync(testDbFileBasePath)) { Deno.mkdirSync(testDbFileBasePath, { recursive: true }); } -// Create test user +if (existsSync(testDbFile)) { + Deno.removeSync(testDbFile); +} + const testUser: User = { telegramId: 12345, username: "username", @@ -28,6 +30,7 @@ const testUser: User = { const testSettings: Settings = { userId: 12345, storeMentalHealthInfo: false, + custom404ImagePath: null, }; Deno.test("Test insertSettings()", async () => { @@ -55,6 +58,7 @@ Deno.test("Test getSettingsById()", async () => { await Deno.removeSync(testDbFile); }); + Deno.test("Test updateSettings()", async () => { await createUserTable(testDbFile); await createSettingsTable(testDbFile); @@ -64,6 +68,7 @@ Deno.test("Test updateSettings()", async () => { const settings = testSettings; settings.storeMentalHealthInfo = true; + settings.custom404ImagePath = "/path/to/custom/image.jpg"; const queryResult = updateSettings( testUser.telegramId, @@ -74,5 +79,59 @@ Deno.test("Test updateSettings()", async () => { assertEquals(queryResult?.changes, 1); assertEquals(queryResult?.lastInsertRowid, 0); + const updatedSettings = getSettingsById(testUser.telegramId, testDbFile); + assertEquals(updatedSettings?.storeMentalHealthInfo, true); + assertEquals( + updatedSettings?.custom404ImagePath, + "/path/to/custom/image.jpg", + ); + + await Deno.removeSync(testDbFile); +}); + +Deno.test("Test updateSettings() with custom404ImagePath null", async () => { + await createUserTable(testDbFile); + await createSettingsTable(testDbFile); + insertUser(testUser, testDbFile); + insertSettings(testUser.telegramId, testDbFile); + + const settings = getSettingsById(testUser.telegramId, testDbFile)!; + settings.custom404ImagePath = "/path/to/custom/image.jpg"; + updateSettings(testUser.telegramId, settings, testDbFile); + + settings.custom404ImagePath = null; + updateSettings(testUser.telegramId, settings, testDbFile); + + const updatedSettings = getSettingsById(testUser.telegramId, testDbFile); + assertEquals(updatedSettings?.custom404ImagePath, null); + + await Deno.removeSync(testDbFile); +}); + +Deno.test("Test custom404ImagePath functionality", async () => { + await createUserTable(testDbFile); + await createSettingsTable(testDbFile); + insertUser(testUser, testDbFile); + insertSettings(testUser.telegramId, testDbFile); + + let settings = getSettingsById(testUser.telegramId, testDbFile); + assertEquals(settings?.custom404ImagePath, null); + + settings!.custom404ImagePath = "/path/to/custom/image.jpg"; + updateSettings(testUser.telegramId, settings!, testDbFile); + + let updatedSettings = getSettingsById(testUser.telegramId, testDbFile); + assertEquals( + updatedSettings?.custom404ImagePath, + "/path/to/custom/image.jpg", + ); + + settings = updatedSettings!; + settings.custom404ImagePath = null; + updateSettings(testUser.telegramId, settings, testDbFile); + + updatedSettings = getSettingsById(testUser.telegramId, testDbFile); + assertEquals(updatedSettings?.custom404ImagePath, null); + await Deno.removeSync(testDbFile); }); diff --git a/types/types.ts b/types/types.ts index 7530dfc..6ad3e77 100644 --- a/types/types.ts +++ b/types/types.ts @@ -68,6 +68,7 @@ export type Settings = { id?: number; userId: number; storeMentalHealthInfo: boolean; + custom404ImagePath?: string | null; }; export type JournalEntryPhoto = { diff --git a/utils/dbUtils.ts b/utils/dbUtils.ts index 11aaa42..01ab865 100644 --- a/utils/dbUtils.ts +++ b/utils/dbUtils.ts @@ -1,10 +1,14 @@ import { existsSync, PathLike } from "node:fs"; import { + addCustom404ImagePathColumn, createEntryTable, createGadScoreTable, + createJournalEntryPhotosTable, + createJournalTable, createPhqScoreTable, createSettingsTable, createUserTable, + createVoiceRecordingTable, } from "../db/migration.ts"; import { DatabaseSync } from "node:sqlite"; import { dbFileBasePath } from "../constants/paths.ts"; @@ -25,12 +29,56 @@ export function createDatabase(dbFile: PathLike) { createPhqScoreTable(dbFile); createEntryTable(dbFile); createSettingsTable(dbFile); + createJournalTable(dbFile); + createJournalEntryPhotosTable(dbFile); + createVoiceRecordingTable(dbFile); } catch (err) { console.error(err); throw new Error(`Failed to create database: ${err}`); } } +export function runMigrations(dbFile: PathLike) { + try { + const db = new DatabaseSync(dbFile); + db.exec("PRAGMA foreign_keys = ON;"); + + // Check if journal_db exists, if not create it + const journalTableExists = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='journal_db'", + ).get(); + if (!journalTableExists) { + console.log("Running migration: creating journal_db table"); + createJournalTable(dbFile); + } + + // Check if journal_entry_photos table exists + const photosTableExists = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='photo_db'", + ).get(); + if (!photosTableExists) { + console.log("Running migration: creating photo_db table"); + createJournalEntryPhotosTable(dbFile); + } + + // Check if custom404ImagePath column exists in settings_db + const columnInfo = db.prepare( + "PRAGMA table_info(settings_db)", + ).all(); + const columnExists = columnInfo.find((col) => + (col as Record).name === "custom404ImagePath" + ); + if (!columnExists) { + console.log("Running migration: adding custom404ImagePath column"); + addCustom404ImagePathColumn(dbFile); + } + + db.close(); + } catch (err) { + console.error(`Failed to run migrations: ${err}`); + } +} + /** * @param dbFile * @param tableName diff --git a/utils/keyboards.ts b/utils/keyboards.ts index b91eca6..b5ece7e 100644 --- a/utils/keyboards.ts +++ b/utils/keyboards.ts @@ -1,4 +1,5 @@ import { InlineKeyboard, Keyboard } from "grammy"; +import { Settings } from "../types/types.ts"; export const registerKeyboard = new InlineKeyboard().text( "Register", @@ -51,6 +52,20 @@ export const keyboardFinal: InlineKeyboard = new InlineKeyboard() .text("Very difficult").row() .text("Extremely difficult"); +export function getSettingsKeyboard( + settings: Settings | undefined, +): InlineKeyboard { + const custom404Text = settings?.custom404ImagePath + ? "🖼️ Custom 404 Image: ✅" + : "🖼️ Custom 404 Image: ❌"; + + return new InlineKeyboard() + .text("Save Mental Health Scores", "smhs").row() + .text(custom404Text, "custom-404-image").row() + .text("Back", "settings-back"); +} + export const settingsKeyboard: InlineKeyboard = new InlineKeyboard() .text("Save Mental Health Scores", "smhs").row() + .text("🖼️ Custom 404 Image: ❌", "custom-404-image").row() .text("Back", "settings-back"); diff --git a/utils/misc.ts b/utils/misc.ts index d8ff220..fc181ff 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -9,8 +9,8 @@ import { import { anxietyExplanations, depressionExplanations, - telegramDownloadUrl, } from "../constants/strings.ts"; +import { getTelegramDownloadUrl } from "../utils/telegram.ts"; import { File } from "grammy/types"; export function sleep(ms: number) { @@ -256,7 +256,7 @@ export async function downloadTelegramImage( }; try { const selfieResponse = await fetch( - telegramDownloadUrl.replace("", token).replace( + getTelegramDownloadUrl().replace("", token).replace( "", telegramFile.file_path!, ), diff --git a/utils/telegram.ts b/utils/telegram.ts new file mode 100644 index 0000000..e30cf6f --- /dev/null +++ b/utils/telegram.ts @@ -0,0 +1,5 @@ +export function getTelegramDownloadUrl(): string { + const baseUrl = Deno.env.get("TELEGRAM_API_BASE_URL") || + "https://api.telegram.org"; + return `${baseUrl}/file/bot/`; +}