diff --git a/README.md b/README.md index 0d361a8..6f5da49 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ entries. You can also delete entries from this screen. If you are wanting to stop using Jotbot you can delete your account using /delete_account this will also delete all of your journal entries! +## Configuration + +The bot can be configured using environment variables in a `.env` file: + +- `TELEGRAM_BOT_KEY`: Your Telegram bot token (required) +- `TELEGRAM_API_BASE_URL`: Custom Telegram Bot API base URL (optional, defaults + to `https://api.telegram.org`) + ## Commands **/start** - Start the bot, if it's your first time messaging the bot you will diff --git a/assets/404/1680564645_404.jpg b/assets/404/1680564645_404.jpg new file mode 100644 index 0000000..9d9af52 Binary files /dev/null and b/assets/404/1680564645_404.jpg differ diff --git a/constants/numbers.ts b/constants/numbers.ts index 9d0603a..db1266b 100644 --- a/constants/numbers.ts +++ b/constants/numbers.ts @@ -1 +1,2 @@ export const pdfFontSize = 30; +export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB limit for file uploads diff --git a/constants/strings.ts b/constants/strings.ts index 5869f6f..e19166c 100644 --- a/constants/strings.ts +++ b/constants/strings.ts @@ -1,7 +1,11 @@ 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/"; +// This will be constructed dynamically using the configured API base URL +export const getTelegramDownloadUrl = ( + baseUrl: string, + token: string, + filePath: string, +) => `${baseUrl}/file/bot${token}/${filePath}`; export const catImagesApiBaseUrl = `https://cataas.com`; export const quotesApiBaseUrl = `https://zenquotes.io/api/quotes/`; @@ -37,22 +41,41 @@ export const helpString: string = ` Jotbot is a telegram bot that can help you to record your thoughts and emotions in directly in the telegram app. How do I use Jotbot? -Jotbot is easy to use you have to be "registered" to start recording entries. -Once this is done you can use /new_entry to start recording an entry. You just answer the bot's questions to the best of you ability from there. -After you are finished recording your entry you can view your entry by using /view_entries. This will bring up a menu that let's you scroll through your entries. You can also delete entries from this screen. -If you are wanting to stop using Jotbot you can delete your account using /delete_account this will also delete all of your journal entries! +🚀 Getting Started: +• Send /start to begin registration +• Follow the prompts to create your profile + +📝 Creating Entries: +• Use /new_entry to start a new journal entry +• Answer 4 simple questions about your thoughts and emotions +• Each step is clearly labeled with examples + +👀 Viewing Entries: +• Use /view_entries to browse your journal +• Navigate with Previous/Next buttons +• Edit or delete entries as needed + +⚙️ Settings: +• Use /settings to customize your experience +• Toggle mental health score saving +• Set a custom 404 image for entries without photos + +🆘 Need Help? +• Use /delete_account to remove all your data Commands -/start - Start the bot, if it's your first time messaging the bot you will be asked if you want to register. -/help - Prints this help string in a message -/new_entry - Start a new entry -/view_entries - Scroll through your entries -/kitties - Open the kitties app! Studies show kitties can help with depression -/delete_account - Delete your accound plus all entries -/🆘 or /sos - Show the crisis help lines - -NOTE: The selfie features aren't working right now. +/start - Register or access your account +/help - Show this help message +/new_entry - Create a new journal entry (4 simple steps) +/view_entries - Browse and manage your entries +/settings - Customize your bot experience +/kitties - View cute cats for stress relief +/am_i_depressed - Take a depression assessment (PHQ-9) +/am_i_anxious - Take an anxiety assessment (GAD-7) +/snapshot - View your mental health summary +/delete_account - Permanently delete all your data +/🆘 or /sos - Access crisis support resources `; export enum Emotions { diff --git a/db/migration.ts b/db/migration.ts index 79798d0..f251cbf 100644 --- a/db/migration.ts +++ b/db/migration.ts @@ -1,6 +1,7 @@ import { PathLike } from "node:fs"; import { DatabaseSync } from "node:sqlite"; import { sqlFilePath } from "../constants/paths.ts"; +import { logger } from "../utils/logger.ts"; export function createEntryTable(dbFile: PathLike) { try { @@ -11,7 +12,7 @@ export function createEntryTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`Failed to create entry_db table: ${err}`); + logger.error(`Failed to create entry_db table: ${err}`); } } @@ -25,7 +26,7 @@ export function createGadScoreTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`There was a a problem create the user_db table: ${err}`); + logger.error(`Failed to create gad_score_db table: ${err}`); } } @@ -39,7 +40,7 @@ export function createPhqScoreTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`There was a a problem create the user_db table: ${err}`); + logger.error(`Failed to create phq_score_db table: ${err}`); } } @@ -53,7 +54,7 @@ export function createUserTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`There was a a problem create the user_db table: ${err}`); + logger.error(`Failed to create user_db table: ${err}`); } } @@ -67,7 +68,7 @@ export function createSettingsTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`Failed to create settings table: ${err}`); + logger.error(`Failed to create settings_db table: ${err}`); } } @@ -81,7 +82,7 @@ export function createJournalTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`Failed to create settings table: ${err}`); + logger.error(`Failed to create journal_db table: ${err}`); } } @@ -94,7 +95,7 @@ export function createJournalEntryPhotosTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`Failed to create settings table: ${err}`); + logger.error(`Failed to create photo_db table: ${err}`); } } @@ -108,6 +109,28 @@ export function createVoiceRecordingTable(dbFile: PathLike) { db.prepare(query).run(); db.close(); } catch (err) { - console.error(`Failed to create settings table: ${err}`); + logger.error(`Failed to create voice_recording_db table: ${err}`); + } +} + +export function addCustom404Column(dbFile: PathLike) { + try { + const db = new DatabaseSync(dbFile); + db.exec("PRAGMA foreign_keys = ON;"); + // Check if column exists to avoid errors + const columns = db.prepare("PRAGMA table_info(settings_db);").all() as { + name: string; + }[]; + const hasColumn = columns.some((col) => col.name === "custom404ImagePath"); + if (!hasColumn) { + db.prepare(` + ALTER TABLE settings_db + ADD COLUMN custom404ImagePath TEXT DEFAULT NULL; + `).run(); + logger.info("Added custom404ImagePath column to settings_db"); + } + db.close(); + } catch (err) { + logger.error(`Failed to add custom404ImagePath column: ${err}`); } } diff --git a/db/sql/create_settings_table.sql b/db/sql/create_settings_table.sql index 07d1411..b76b561 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 DEFAULT NULL, FOREIGN KEY (userId) REFERENCES user_db(telegramId) ON DELETE CASCADE ); \ No newline at end of file diff --git a/db/sql/misc/get_latest_entry_id.sql b/db/sql/misc/get_latest_entry_id.sql index 6f8feb0..e638188 100644 --- a/db/sql/misc/get_latest_entry_id.sql +++ b/db/sql/misc/get_latest_entry_id.sql @@ -1 +1 @@ -SELECT seq FROM sqlite_sequence WHERE name=''; \ No newline at end of file +SELECT MAX(id) as max_id FROM ; \ No newline at end of file diff --git a/deno.json b/deno.json index b1997b2..de28e99 100644 --- a/deno.json +++ b/deno.json @@ -6,9 +6,10 @@ }, "imports": { "@grammyjs/commands": "npm:@grammyjs/commands@^1.2.0", - "@grammyjs/conversations": "npm:@grammyjs/conversations@^2.1.1", + "@grammyjs/conversations": "npm:@grammyjs/conversations@^1.1.1", "@grammyjs/files": "npm:@grammyjs/files@^1.2.0", "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/log": "jsr:@std/log@^0.224.14", "grammy": "npm:grammy@^1.38.4" } } diff --git a/deno.lock b/deno.lock index fdc43b7..ddf8cbb 100644 --- a/deno.lock +++ b/deno.lock @@ -2,10 +2,16 @@ "version": "5", "specifiers": { "jsr:@std/assert@^1.0.16": "1.0.16", + "jsr:@std/fmt@^1.0.5": "1.0.8", + "jsr:@std/fs@^1.0.11": "1.0.21", "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/io@~0.225.2": "0.225.2", + "jsr:@std/log@*": "0.224.14", + "jsr:@std/log@0.224.14": "0.224.14", "npm:@grammyjs/commands@^1.2.0": "1.2.0_grammy@1.38.4", "npm:@grammyjs/conversations@^2.1.1": "2.1.1_grammy@1.38.4", "npm:@grammyjs/files@^1.2.0": "1.2.0_grammy@1.38.4", + "npm:@types/node@*": "24.2.0", "npm:grammy@^1.38.4": "1.38.4" }, "jsr": { @@ -15,8 +21,25 @@ "jsr:@std/internal" ] }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.21": { + "integrity": "d720fe1056d78d43065a4d6e0eeb2b19f34adb8a0bc7caf3a4dbf1d4178252cd" + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/io@0.225.2": { + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7" + }, + "@std/log@0.224.14": { + "integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/io" + ] } }, "npm": { @@ -41,6 +64,12 @@ "@grammyjs/types@3.22.2": { "integrity": "sha512-uu7DX2ezhnBPozL3bXHmwhLvaFsh59E4QyviNH4Cij7EdVekYrs6mCzeXsa2pDk30l3uXo7DBahlZLzTPtpYZg==" }, + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, "abort-controller@3.0.0": { "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "dependencies": [ @@ -77,6 +106,9 @@ "tr46@0.0.3": { "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + }, "webidl-conversions@3.0.1": { "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, @@ -192,6 +224,7 @@ "workspace": { "dependencies": [ "jsr:@std/assert@^1.0.16", + "jsr:@std/log@^1.0.16", "npm:@grammyjs/commands@^1.2.0", "npm:@grammyjs/conversations@^2.1.1", "npm:@grammyjs/files@^1.2.0", diff --git a/handlers/delete_account.ts b/handlers/delete_account.ts index 39ea9f7..68d89b2 100644 --- a/handlers/delete_account.ts +++ b/handlers/delete_account.ts @@ -3,8 +3,13 @@ import { Conversation } from "@grammyjs/conversations"; import { deleteAccountConfirmKeyboard } from "../utils/keyboards.ts"; import { deleteUser } from "../models/user.ts"; import { dbFile } from "../constants/paths.ts"; +import { logger } from "../utils/logger.ts"; export async function delete_account(conversation: Conversation, ctx: Context) { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } try { await ctx.reply( `⚠️ Are you sure you want to delete your account along with all of your data? ⚠️`, @@ -17,21 +22,21 @@ export async function delete_account(conversation: Conversation, ctx: Context) { ]); if (deleteAccountCtx.callbackQuery.data === "delete-account-yes") { - await conversation.external(() => deleteUser(ctx.from?.id!, dbFile)); + await conversation.external(() => deleteUser(ctx.from.id, dbFile)); } else if (deleteAccountCtx.callbackQuery.data === "delete-account-no") { conversation.halt(); return await deleteAccountCtx.editMessageText("No changes made!"); } await conversation.halt(); return await ctx.editMessageText( - `Okay ${ctx.from?.username} your account has been terminated along with all of your entries. Thanks for trying Jotbot!`, + `Okay ${ctx.from.username} your account has been terminated along with all of your entries. Thanks for trying Jotbot!`, ); } catch (err) { - console.log( - `Failed to delete user ${ctx.from?.username}: ${err}`, + logger.error( + `Failed to delete user ${ctx.from.username}: ${err}`, ); return await ctx.editMessageText( - `Failed to delete user ${ctx.from?.username}: ${err}`, + `Failed to delete user ${ctx.from.username}: ${err}`, ); } } diff --git a/handlers/gad7_assessment.ts b/handlers/gad7_assessment.ts index 98b6f22..1d38801 100644 --- a/handlers/gad7_assessment.ts +++ b/handlers/gad7_assessment.ts @@ -1,7 +1,7 @@ import { Conversation } from "@grammyjs/conversations"; import { Context, InlineKeyboard } from "grammy"; import { gad7Questions } from "../constants/strings.ts"; -import { keyboardFinal, questionaireKeyboard } from "../utils/keyboards.ts"; +import { keyboardFinal, questionnaireKeyboard } from "../utils/keyboards.ts"; import { finalCallBackQueries, questionCallBackQueries, @@ -17,127 +17,153 @@ export async function gad7_assessment( conversation: Conversation, ctx: Context, ) { - const ctxMsg = await ctx.api.sendMessage( - ctx.chatId!, - `Hello ${ctx.from?.username}, this is the Generalized Anxiety Disorder-7 (GAD-7) this test was developed by a team of highly trained mental health professionals + try { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + if (!ctx.chatId) { + await ctx.reply("Error: Unable to identify chat."); + return; + } + const ctxMsg = await ctx.api.sendMessage( + ctx.chatId, + `Hello ${ctx.from.username}, this is the Generalized Anxiety Disorder-7 (GAD-7) this test was developed by a team of highly trained mental health professionals -With that said THIS TEST DOES NOT REPLACE ACTUAL MENTAL HEALTH HELP! If are in serious need of mental health help you should seek help immediatly! +With that said THIS TEST DOES NOT REPLACE ACTUAL MENTAL HEALTH HELP! If are in serious need of mental health help you should seek help immediately! Run /sos to bring up a list of resources that might be able to help -Click here to see the PHQ-9 questionaire itself, this is where the questions are coming from. +Click here to see the GAD-7 questionnaire itself, this is where the questions are coming from. -Do you understand that this test is a simple way to help you guage your depression for your own reference, and is in no way ACTUAL mental health services? +Do you understand that this test is a simple way to help you gauge your anxiety for your own reference, and is in no way ACTUAL mental health services? `, - { - parse_mode: "HTML", - reply_markup: new InlineKeyboard().text("Yes", "gad7-disclaimer-yes") - .text("No", "gad7-disclaimer-no"), - }, - ); - - const phq9DisclaimerCtx = await conversation.waitForCallbackQuery([ - "gad7-disclaimer-yes", - "gad7-disclaimer-no", - ]); - - if (phq9DisclaimerCtx.callbackQuery.data === "phq9-disclaimer-no") { - return await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - "No problem! Thanks for checking out the GAD-7 portion of the bot.", + { + parse_mode: "HTML", + reply_markup: new InlineKeyboard().text("Yes", "gad7-disclaimer-yes") + .text("No", "gad7-disclaimer-no"), + }, ); - } - await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - `Okay ${ctx.from?.username}, let's begin. Over the last 2 weeks how often have you been bother by any of the following problems?`, - { - parse_mode: "HTML", - reply_markup: new InlineKeyboard().text("Begin", "gad7-begin"), - }, - ); - - const _gad7BeginCtx = await conversation.waitForCallbackQuery("gad7-begin"); - let gad7Ctx; - let anxietyScore = 0; - - // Questions - for (const question in gad7Questions) { + const phq9DisclaimerCtx = await conversation.waitForCallbackQuery([ + "gad7-disclaimer-yes", + "gad7-disclaimer-no", + ]); + + if (phq9DisclaimerCtx.callbackQuery.data === "gad7-disclaimer-no") { + return await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + "No problem! Thanks for checking out the GAD-7 portion of the bot.", + ); + } + await ctx.api.editMessageText( - ctx.chatId!, + ctx.chatId, ctxMsg.message_id, - `${Number(question) + 1}. ${gad7Questions[question]}`, - { reply_markup: questionaireKeyboard, parse_mode: "HTML" }, + `Okay ${ctx.from.username}, let's begin. Over the last 2 weeks how often have you been bother by any of the following problems?`, + { + parse_mode: "HTML", + reply_markup: new InlineKeyboard().text("Begin", "gad7-begin"), + }, ); - gad7Ctx = await conversation.waitForCallbackQuery(questionCallBackQueries); - switch (gad7Ctx.callbackQuery.data) { - case "not-at-all": { - // No need to add 0 - break; - } - case "several-days": { - anxietyScore += 1; - break; - } - case "more-than-half-the-days": { - anxietyScore += 2; - break; - } - case "nearly-every-day": { - anxietyScore += 3; - break; + + const _gad7BeginCtx = await conversation.waitForCallbackQuery("gad7-begin"); + let gad7Ctx; + let anxietyScore = 0; + + // Questions + for (const question in gad7Questions) { + await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + `${Number(question) + 1}. ${gad7Questions[question]}`, + { reply_markup: questionnaireKeyboard, parse_mode: "HTML" }, + ); + gad7Ctx = await conversation.waitForCallbackQuery( + questionCallBackQueries, + ); + switch (gad7Ctx.callbackQuery.data) { + case "not-at-all": { + // No need to add 0 + break; + } + case "several-days": { + anxietyScore += 1; + break; + } + case "more-than-half-the-days": { + anxietyScore += 2; + break; + } + case "nearly-every-day": { + anxietyScore += 3; + break; + } } } - } - await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - "If you checked of any problems, how difficult have these problems made it for you to do you work, take care of things at home, or get along with other people?", - { reply_markup: keyboardFinal, parse_mode: "HTML" }, - ); - - const gad7FinalCtx = await conversation.waitForCallbackQuery( - finalCallBackQueries, - ); - - const impactQestionAnswer = gad7FinalCtx.callbackQuery.data; - const gad7Score: GAD7Score = calcGad7Score( - anxietyScore, - ctx.from?.id!, - impactQestionAnswer, - ); - - await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - `GAD-7 Score: ${gad7Score.score} + await ctx.api.editMessageText( + ctx.chatId!, + ctxMsg.message_id, + "If you checked of any problems, how difficult have these problems made it for you to do you work, take care of things at home, or get along with other people?", + { reply_markup: keyboardFinal, parse_mode: "HTML" }, + ); + + const gad7FinalCtx = await conversation.waitForCallbackQuery( + finalCallBackQueries, + ); + + const impactQestionAnswer = gad7FinalCtx.callbackQuery.data; + const gad7Score: GAD7Score = calcGad7Score( + anxietyScore, + ctx.from.id, + impactQestionAnswer, + ); + + await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + `GAD-7 Score: ${gad7Score.score} Anxiety Severity: ${gad7Score.severity} Dealing with this level of anxiety is making your life ${gad7Score.impactQuestionAnswer} ${gad7Score.action}`, - { parse_mode: "HTML" }, - ); - - if (userExists(ctx.from?.id!, dbFile)) { - const settings = getSettingsById(ctx.from?.id!, dbFile); - - if (settings?.storeMentalHealthInfo) { - try { - insertGadScore(gad7Score, dbFile); - await ctx.reply("Score saved!"); - } catch (err) { - throw err; + { parse_mode: "HTML" }, + ); + + if (userExists(ctx.from.id, dbFile)) { + const settings = getSettingsById(ctx.from.id, dbFile); + + if (settings?.storeMentalHealthInfo) { + try { + insertGadScore(gad7Score, dbFile); + await ctx.reply("Score saved!"); + } catch (err) { + logger.error( + `Failed to save GAD-7 score for user ${ctx.from.id}: ${err}`, + ); + await ctx.reply("❌ Failed to save score. Please try again."); + } + } else { + await ctx.reply("Scores not saved."); } } else { - await ctx.reply("Scores not saved."); + await ctx.reply( + "It looks like you haven't registered, you can register by running /register to store you scores and keep track of your mental health.", + ); } - } else { - await ctx.reply( - "It looks like you haven't registered, you can register by running /register to store you scores and keep track of your mental health.", + } catch (error) { + logger.error( + `Error in gad7_assessment conversation for user ${ctx.from?.id}: ${error}`, ); + try { + await ctx.reply( + "❌ Sorry, there was an error during the assessment. Please try again.", + ); + } catch (replyError) { + logger.error(`Failed to send error message: ${replyError}`); + } } } diff --git a/handlers/new_entry.ts b/handlers/new_entry.ts index af5bd24..e183f7e 100644 --- a/handlers/new_entry.ts +++ b/handlers/new_entry.ts @@ -4,130 +4,167 @@ 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 { MAX_FILE_SIZE_BYTES } from "../constants/numbers.ts"; +import { logger } from "../utils/logger.ts"; export async function new_entry(conversation: Conversation, ctx: Context) { - // Describe situation - await ctx.api.sendMessage( - ctx.chatId!, - "Describe the situation that brought up your thought.", - ); - const situationCtx = await conversation.waitFor("message:text"); - - // Record automatic thoughts - await ctx.reply( - `Okay ${ctx.from?.username} describe the thought. Rate how much you believed it out of 100%.`, - ); - const automaticThoughtCtx = await conversation.waitFor("message:text"); - - // Emoji and emotion descriptor - await ctx.reply( - "Send one word describing your emotions, along with an emoji that matches your emotions.", - ); - const emojiAndEmotionName = await conversation.waitFor("message:text"); - - // Describe your feelings - await ctx.reply( - "What emotions were you feeling at the time? How intense were your feelings out of 100%?", - ); - const emotionDescriptionCtx = await conversation.waitFor("message:text"); - - // Store emoji and emotion name - const emotionNameAndEmoji = emojiAndEmotionName.message.text.split(" "); - let emotionEmoji: string, emotionName: string; - if (/\p{Emoji}/u.test(emotionNameAndEmoji[0])) { - emotionEmoji = emotionNameAndEmoji[0]; - emotionName = emotionNameAndEmoji[1]; - } else { - emotionEmoji = emotionNameAndEmoji[1]; - emotionName = emotionNameAndEmoji[0]; + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; } + if (!ctx.chatId) { + await ctx.reply("Error: Unable to identify chat."); + return; + } + try { + // Describe situation + await ctx.api.sendMessage( + ctx.chatId, + '📝 Step 1: Describe the Situation\n\nDescribe the situation that brought up your thought.\n\nExample: "I was at work and my boss criticized my presentation."', + { parse_mode: "HTML" }, + ); + const situationCtx = await conversation.waitFor("message:text"); - // Build emotion object - const emotion: Emotion = { - emotionName: emotionName, - emotionEmoji: emotionEmoji, - emotionDescription: emotionDescriptionCtx.message.text, - }; + // Record automatic thoughts + await ctx.reply( + `🧠 Step 2: Your Automatic Thought\n\nDescribe the thought that came to mind. Then rate how much you believed it (0-100%).\n\nExample: \"I'm terrible at my job. Belief: 85%\"`, + { parse_mode: "HTML" }, + ); + const automaticThoughtCtx = await conversation.waitFor("message:text"); - const askSelfieMsg = await ctx.reply("Would you like to take a selfie?", { - reply_markup: new InlineKeyboard().text("✅ Yes", "selfie-yes").text( - "⛔ No", - "selfie-no", - ), - }); + // Emoji and emotion descriptor + await ctx.reply( + '😊 Step 3: Your Emotion\n\nSend one word describing your emotion, followed by a matching emoji.\n\nExample: "anxious 😰" or "sad 😢"\n\nThe emoji should represent how you felt.', + { parse_mode: "HTML" }, + ); + const emojiAndEmotionName = await conversation.waitFor("message:text"); - const selfieCtx = await conversation.waitForCallbackQuery([ - "selfie-yes", - "selfie-no", - ]); + // Describe your feelings + await ctx.reply( + '💭 Step 4: Emotion Description\n\nDescribe the emotions you were feeling and how intense they were (0-100%).\n\nExample: "I felt very anxious and overwhelmed. Intensity: 90%"', + { parse_mode: "HTML" }, + ); + const emotionDescriptionCtx = await conversation.waitFor("message:text"); + + // Store emoji and emotion name + const emotionNameAndEmoji = emojiAndEmotionName.message.text.split(" "); + let emotionEmoji: string, emotionName: string; + if (/\p{Emoji}/u.test(emotionNameAndEmoji[0])) { + emotionEmoji = emotionNameAndEmoji[0]; + emotionName = emotionNameAndEmoji[1]; + } else { + emotionEmoji = emotionNameAndEmoji[1]; + emotionName = emotionNameAndEmoji[0]; + } - let selfiePath: string | null = ""; - if (selfieCtx.callbackQuery.data === "selfie-yes") { - try { - await ctx.api.editMessageText( - ctx.chatId!, - askSelfieMsg.message_id, - "Send me a selfie.", - ); - const selfiePathCtx = await conversation.waitFor("message:photo"); - - const tmpFile = await selfiePathCtx.getFile(); - // console.log(selfiePathCtx.message.c); - const selfieResponse = await fetch( - telegramDownloadUrl.replace("", ctx.api.token).replace( - "", - tmpFile.file_path!, - ), - ); - if (selfieResponse.body) { - await conversation.external(async () => { // use conversation.external - const fileName = `${ctx.from?.id}_${ - new Date(Date.now()).toLocaleString() - }.jpg`.replaceAll(" ", "_").replace(",", "").replaceAll("/", "-"); // Build and sanitize selfie file name - - const filePath = `${Deno.cwd()}/assets/selfies/${fileName}`; - const file = await Deno.open(filePath, { - write: true, - create: true, + // Build emotion object + const emotion: Emotion = { + emotionName: emotionName, + emotionEmoji: emotionEmoji, + emotionDescription: emotionDescriptionCtx.message.text, + }; + + const askSelfieMsg = await ctx.reply("Would you like to take a selfie?", { + reply_markup: new InlineKeyboard().text("✅ Yes", "selfie-yes").text( + "⛔ No", + "selfie-no", + ), + }); + + const selfieCtx = await conversation.waitForCallbackQuery([ + "selfie-yes", + "selfie-no", + ]); + + let selfiePath: string | null = ""; + if (selfieCtx.callbackQuery.data === "selfie-yes") { + try { + await ctx.api.editMessageText( + ctx.chatId, + askSelfieMsg.message_id, + "Send me a selfie.", + ); + const selfiePathCtx = await conversation.waitFor("message:photo"); + + const tmpFile = await selfiePathCtx.getFile(); + if (!tmpFile.file_path) { + throw new Error("File path is missing from Telegram response"); + } + if (tmpFile.file_size && tmpFile.file_size > MAX_FILE_SIZE_BYTES) { + await ctx.reply( + `❌ File too large! Maximum size is 10MB. Your file is ${ + (tmpFile.file_size / (1024 * 1024)).toFixed(2) + }MB.`, + ); + } + const selfieResponse = await fetch( + telegramDownloadUrl.replace("", ctx.api.token).replace( + "", + tmpFile.file_path, + ), + ); + if (selfieResponse.body) { + await conversation.external(async () => { // use conversation.external + const fileName = `${ctx.from?.id}_${ + new Date(Date.now()).toLocaleString() + }.jpg`.replaceAll(" ", "_").replace(",", "").replaceAll("/", "-"); // Build and sanitize selfie file name + + const filePath = `${Deno.cwd()}/assets/selfies/${fileName}`; + const file = await Deno.open(filePath, { + write: true, + create: true, + }); + + logger.debug(`Saving selfie file: ${filePath}`); + selfiePath = await Deno.realPath(filePath); + await selfieResponse.body.pipeTo(file.writable); }); - console.log(`File: ${file}`); - selfiePath = await Deno.realPath(filePath); - await selfieResponse.body!.pipeTo(file.writable); - }); - - await ctx.reply(`Selfie saved successfully!`); + await ctx.reply(`Selfie saved successfully!`); + } + } catch (err) { + logger.error(`Failed to save selfie: ${err}`); } - } catch (err) { - console.log(`Jotbot Error: Failed to save selfie: ${err}`); + } else if (selfieCtx.callbackQuery.data === "selfie-no") { + selfiePath = null; + } else { + logger.error( + `Invalid callback query selection: ${selfieCtx.callbackQuery.data}`, + ); } - } else if (selfieCtx.callbackQuery.data === "selfie-no") { - selfiePath = null; - } else { - console.log( - `Invalid Selection: ${selfieCtx.callbackQuery.data}`, - ); - } - const entry: Entry = { - timestamp: await conversation.external(() => Date.now()), - userId: ctx.from?.id!, - emotion: emotion, - situation: situationCtx.message.text, - automaticThoughts: automaticThoughtCtx.message.text, - selfiePath: selfiePath, - }; + const entry: Entry = { + timestamp: await conversation.external(() => Date.now()), + userId: ctx.from.id, + emotion: emotion, + situation: situationCtx.message.text, + automaticThoughts: automaticThoughtCtx.message.text, + selfiePath: selfiePath, + }; - try { - await conversation.external(() => insertEntry(entry, dbFile)); - } catch (err) { - console.log(`Failed to insert Entry: ${err}`); - return await ctx.reply(`Failed to insert entry: ${err}`); - } + try { + await conversation.external(() => insertEntry(entry, dbFile)); + } catch (err) { + logger.error(`Failed to insert entry: ${err}`); + return await ctx.reply(`Failed to insert entry: ${err}`); + } - return await ctx.reply( - `Entry added at ${ - new Date(entry.timestamp!).toLocaleString() - }! Thank you for logging your emotion with me.`, - ); + return await ctx.reply( + `Entry added at ${ + new Date(entry.timestamp).toLocaleString() + }! Thank you for logging your emotion with me.`, + ); + } catch (error) { + logger.error( + `Error in new_entry conversation for user ${ctx.from?.id}: ${error}`, + ); + try { + await ctx.reply( + "❌ Sorry, there was an error creating your entry. Please try again with /new_entry.", + ); + } catch (replyError) { + logger.error(`Failed to send error message: ${replyError}`); + } + // Don't rethrow - let the conversation end gracefully + } } diff --git a/handlers/new_journal_entry.ts b/handlers/new_journal_entry.ts index b59d873..db6c807 100644 --- a/handlers/new_journal_entry.ts +++ b/handlers/new_journal_entry.ts @@ -6,8 +6,10 @@ import { insertJournalEntry, } from "../models/journal.ts"; import { dbFile } from "../constants/paths.ts"; +import { MAX_FILE_SIZE_BYTES } from "../constants/numbers.ts"; import { downloadTelegramImage } from "../utils/misc.ts"; import { insertJournalEntryPhoto } from "../models/journal_entry_photo.ts"; +import { logger } from "../utils/logger.ts"; /** * Starts the process of creating a new journal entry. @@ -18,22 +20,26 @@ export async function new_journal_entry( conversation: Conversation, ctx: Context, ) { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } await ctx.reply( - `Hello ${ctx.from?.username!}! Tell me what is on your mind.`, + `Hello ${ctx.from.username || "User"}! Tell me what is on your mind.`, ); const journalEntryCtx = await conversation.waitFor("message:text"); // Try to insert journal entry try { const journalEntry: JournalEntry = { - userId: ctx.from?.id!, + userId: ctx.from.id, timestamp: await conversation.external(() => Date.now()), content: journalEntryCtx.message.text, length: journalEntryCtx.message.text.length, }; await conversation.external(() => insertJournalEntry(journalEntry, dbFile)); } catch (err) { - console.error(`Failed to insert Journal Entry: ${err}`); + logger.error(`Failed to insert Journal Entry: ${err}`); await ctx.reply(`Failed to insert Journal Entry: ${err}`); throw new Error(`Failed to insert Journal Entry: ${err}`); } @@ -57,29 +63,36 @@ export async function new_journal_entry( try { const file = await imagesCtx.getFile(); - const id = await conversation.external(() => - getAllJournalEntriesByUserId(ctx.from?.id!, dbFile)[0].id! + if (file.file_size && file.file_size > MAX_FILE_SIZE_BYTES) { + await ctx.reply( + `❌ File too large! Maximum size is 10MB. Your file is ${ + (file.file_size / (1024 * 1024)).toFixed(2) + }MB.`, + ); + continue; + } + const journalEntries = await conversation.external(() => + getAllJournalEntriesByUserId(ctx.from.id, dbFile) ); + const id = journalEntries[0]?.id ?? 0; + const caption = imagesCtx.message?.caption; const journalEntryPhoto = await conversation.external(async () => await downloadTelegramImage( ctx.api.token, - imagesCtx.message?.caption!, + caption ?? "", file, id, // Latest ID ) ); - console.log(journalEntryPhoto); + logger.debug(`Journal entry photo: ${JSON.stringify(journalEntryPhoto)}`); await conversation.external(() => insertJournalEntryPhoto(journalEntryPhoto, dbFile) ); await ctx.reply(`Saved photo!`); imageCount++; } catch (err) { - console.error( - `Failed to save images for Journal Entry ${getAllJournalEntriesByUserId( - ctx.from?.id!, - dbFile, - )[0].id!}: ${err}`, + logger.error( + `Failed to save images for Journal Entry: ${err}`, ); } } diff --git a/handlers/phq9_assessment.ts b/handlers/phq9_assessment.ts index e3664c6..101c36c 100644 --- a/handlers/phq9_assessment.ts +++ b/handlers/phq9_assessment.ts @@ -1,6 +1,6 @@ import { Context, InlineKeyboard } from "grammy"; import { Conversation } from "@grammyjs/conversations"; -import { keyboardFinal, questionaireKeyboard } from "../utils/keyboards.ts"; +import { keyboardFinal, questionnaireKeyboard } from "../utils/keyboards.ts"; import { phq9Questions } from "../constants/strings.ts"; import { PHQ9Score } from "../types/types.ts"; import { calcPhq9Score } from "../utils/misc.ts"; @@ -17,127 +17,153 @@ export async function phq9_assessment( conversation: Conversation, ctx: Context, ) { - const ctxMsg = await ctx.api.sendMessage( - ctx.chatId!, - `Hello ${ctx.from?.username}, this is the Patient Health Questionaire-9 (PHQ-9) this test was developed by a team of highly trained mental health professionals + try { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + if (!ctx.chatId) { + await ctx.reply("Error: Unable to identify chat."); + return; + } + const ctxMsg = await ctx.api.sendMessage( + ctx.chatId, + `Hello ${ctx.from.username}, this is the Patient Health Questionaire-9 (PHQ-9) this test was developed by a team of highly trained mental health professionals -With that said THIS TEST DOES NOT REPLACE ACTUAL MENTAL HEALTH HELP! If are in serious need of mental health help you should seek help immediatly! +With that said THIS TEST DOES NOT REPLACE ACTUAL MENTAL HEALTH HELP! If are in serious need of mental health help you should seek help immediately! Run /sos to bring up a list of resources that might be able to help -Click here to see the PHQ-9 questionaire itself, this is where the questions are coming from. +Click here to see the PHQ-9 questionnaire itself, this is where the questions are coming from. -Do you understand that this test is a simple way to help you guage your depression for your own reference, and is in no way ACTUAL mental health services? +Do you understand that this test is a simple way to help you gauge your depression for your own reference, and is in no way ACTUAL mental health services? `, - { - parse_mode: "HTML", - reply_markup: new InlineKeyboard().text("Yes", "phq9-disclaimer-yes") - .text("No", "phq9-disclaimer-no"), - }, - ); - - const phq9DisclaimerCtx = await conversation.waitForCallbackQuery([ - "phq9-disclaimer-yes", - "phq9-disclaimer-no", - ]); - - if (phq9DisclaimerCtx.callbackQuery.data === "phq9-disclaimer-no") { - return await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - "No problem! Thanks for checking out the phq\-9 portion of the bot.", + { + parse_mode: "HTML", + reply_markup: new InlineKeyboard().text("Yes", "phq9-disclaimer-yes") + .text("No", "phq9-disclaimer-no"), + }, ); - } - await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - `Okay ${ctx.from?.username}, let's begin. Over the last 2 weeks how often have you been bother by any of the following problems?`, - { - parse_mode: "HTML", - reply_markup: new InlineKeyboard().text("Begin", "phq9-begin"), - }, - ); - - const _phq9BeginCtx = await conversation.waitForCallbackQuery("phq9-begin"); - let phq9Ctx; - let depressionScore = 0; - - // Questions - for (const question in phq9Questions) { + const phq9DisclaimerCtx = await conversation.waitForCallbackQuery([ + "phq9-disclaimer-yes", + "phq9-disclaimer-no", + ]); + + if (phq9DisclaimerCtx.callbackQuery.data === "phq9-disclaimer-no") { + return await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + "No problem! Thanks for checking out the phq\-9 portion of the bot.", + ); + } + await ctx.api.editMessageText( - ctx.chatId!, + ctx.chatId, ctxMsg.message_id, - phq9Questions[question], - { reply_markup: questionaireKeyboard, parse_mode: "HTML" }, + `Okay ${ctx.from.username}, let's begin. Over the last 2 weeks how often have you been bother by any of the following problems?`, + { + parse_mode: "HTML", + reply_markup: new InlineKeyboard().text("Begin", "phq9-begin"), + }, ); - phq9Ctx = await conversation.waitForCallbackQuery(questionCallBackQueries); - switch (phq9Ctx.callbackQuery.data) { - case "not-at-all": { - // No need to add 0 - break; - } - case "several-days": { - depressionScore += 1; - break; - } - case "more-than-half-the-days": { - depressionScore += 2; - break; - } - case "nearly-every-day": { - depressionScore += 3; - break; + + const _phq9BeginCtx = await conversation.waitForCallbackQuery("phq9-begin"); + let phq9Ctx; + let depressionScore = 0; + + // Questions + for (const question in phq9Questions) { + await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + phq9Questions[question], + { reply_markup: questionnaireKeyboard, parse_mode: "HTML" }, + ); + phq9Ctx = await conversation.waitForCallbackQuery( + questionCallBackQueries, + ); + switch (phq9Ctx.callbackQuery.data) { + case "not-at-all": { + // No need to add 0 + break; + } + case "several-days": { + depressionScore += 1; + break; + } + case "more-than-half-the-days": { + depressionScore += 2; + break; + } + case "nearly-every-day": { + depressionScore += 3; + break; + } } } - } - await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - "If you checked of any problems, how difficult have these problems made it for you to do you work, take care of things at home, or get along with other people?", - { reply_markup: keyboardFinal, parse_mode: "HTML" }, - ); - - const phq9FinalCtx = await conversation.waitForCallbackQuery( - finalCallBackQueries, - ); - const impactQestionAnswer = phq9FinalCtx.callbackQuery.data; - const phq9Score: PHQ9Score = calcPhq9Score( - depressionScore, - ctx.from?.id!, - impactQestionAnswer, - ); - - await ctx.api.editMessageText( - ctx.chatId!, - ctxMsg.message_id, - `PHQ-9 Score: ${phq9Score.score} + await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + "If you checked of any problems, how difficult have these problems made it for you to do you work, take care of things at home, or get along with other people?", + { reply_markup: keyboardFinal, parse_mode: "HTML" }, + ); + + const phq9FinalCtx = await conversation.waitForCallbackQuery( + finalCallBackQueries, + ); + const impactQestionAnswer = phq9FinalCtx.callbackQuery.data; + const phq9Score: PHQ9Score = calcPhq9Score( + depressionScore, + ctx.from.id, + impactQestionAnswer, + ); + + await ctx.api.editMessageText( + ctx.chatId, + ctxMsg.message_id, + `PHQ-9 Score: ${phq9Score.score} Depression Severity: ${phq9Score.severity} Dealing with this level of depression is making your life ${phq9Score.impactQuestionAnswer} ${phq9Score.action}`, - { parse_mode: "HTML" }, - ); - - if (userExists(ctx.from?.id!, dbFile)) { - const settings = getSettingsById(ctx.from?.id!, dbFile); - - if (settings?.storeMentalHealthInfo) { - try { - phq9Score.id = ctx.from?.id!; - insertPhqScore(phq9Score, dbFile); - await ctx.reply("Score saved!"); - } catch (err) { - await ctx.reply(`Failed to save score: ${err}`); + { parse_mode: "HTML" }, + ); + + if (userExists(ctx.from.id, dbFile)) { + const settings = getSettingsById(ctx.from.id, dbFile); + + if (settings?.storeMentalHealthInfo) { + try { + phq9Score.id = ctx.from.id; + insertPhqScore(phq9Score, dbFile); + await ctx.reply("Score saved!"); + } catch (err) { + logger.error( + `Failed to save PHQ-9 score for user ${ctx.from.id}: ${err}`, + ); + await ctx.reply("❌ Failed to save score. Please try again."); + } + } else { + await ctx.reply("Scores not saved."); } } else { - await ctx.reply("Scores not saved."); + await ctx.reply( + "It looks like you haven't registered, you can register by running /register to store you scores and keep track of your mental health.", + ); } - } else { - await ctx.reply( - "It looks like you haven't registered, you can register by running /register to store you scores and keep track of your mental health.", + } catch (error) { + logger.error( + `Error in phq9_assessment conversation for user ${ctx.from?.id}: ${error}`, ); + try { + await ctx.reply( + "❌ Sorry, there was an error during the assessment. Please try again.", + ); + } catch (replyError) { + logger.error(`Failed to send error message: ${replyError}`); + } } } diff --git a/handlers/register.ts b/handlers/register.ts index 90c8076..220f842 100644 --- a/handlers/register.ts +++ b/handlers/register.ts @@ -3,46 +3,130 @@ import { Conversation } from "@grammyjs/conversations"; import { insertUser } from "../models/user.ts"; import { User } from "../types/types.ts"; import { dbFile } from "../constants/paths.ts"; +import { logger } from "../utils/logger.ts"; + +function isValidDate(input: string): { isValid: boolean; message?: string } { + const trimmedInput = input.trim(); + + const regex = /^\d{4}\/\d{2}\/\d{2}$/; + if (!regex.test(trimmedInput)) { + return { isValid: false, message: "Invalid format. Please use YYYY/MM/DD" }; + } + + const [year, month, day] = trimmedInput.split("/").map(Number); + + const date = new Date(year, month - 1, day); + if (isNaN(date.getTime())) { + return { + isValid: false, + message: "Invalid date. Please enter a valid date.", + }; + } + + if ( + date.getFullYear() !== year || date.getMonth() + 1 !== month || + date.getDate() !== day + ) { + return { + isValid: false, + message: "Invalid date. Please check your input and try again.", + }; + } + + const now = new Date(); + const minDate = new Date( + now.getFullYear() - 120, + now.getMonth(), + now.getDate(), + ); + const minAgeDate = new Date( + now.getFullYear() - 13, + now.getMonth(), + now.getDate(), + ); + + if (date > now) { + return { isValid: false, message: "Date cannot be in the future." }; + } + + if (date < minDate) { + return { + isValid: false, + message: "Date cannot be more than 120 years in the past.", + }; + } + + if (date > minAgeDate) { + return { + isValid: false, + message: "You must be at least 13 years old to use this bot.", + }; + } + + return { isValid: true }; +} export async function register(conversation: Conversation, ctx: Context) { - let dob; + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } try { - while (true) { - await ctx.editMessageText( - `Okay ${ctx.from?.username} what is your date of birth? YYYY/MM/DD`, - ); - const dobCtx = conversation.waitFor("message:text"); - dob = new Date((await dobCtx).message.text); + let dob; + try { + while (true) { + await ctx.editMessageText( + `Okay ${ctx.from.username} what is your date of birth? YYYY/MM/DD`, + ); + const dobCtx = conversation.waitFor("message:text"); + const inputText = (await dobCtx).message.text.trim(); + const validation = isValidDate(inputText); - if (isNaN(dob.getTime())) { - (await dobCtx).reply("Invalid date entered. Please try again."); - } else { - break; + if (!validation.isValid) { + (await dobCtx).reply(`${validation.message} Please try again.`); + } else { + dob = new Date(inputText); + break; + } } + } catch (err) { + logger.error(`Error getting DOB for user ${ctx.from.id}: ${err}`); + await ctx.reply( + "❌ Sorry, there was an error processing your date of birth. Please try registering again with /start.", + ); + return; // End conversation gracefully } - } catch (err) { - await ctx.reply(`Failed to save birthdate: ${err}`); - throw new Error(`Failed to save birthdate: ${err}`); - } - const user: User = { - telegramId: ctx.from?.id!, - username: ctx.from?.username!, - dob: dob, - joinedDate: await conversation.external(() => { - return new Date(Date.now()); - }), - }; + const user: User = { + telegramId: ctx.from.id, + username: ctx.from.username || "User", + dob: dob, + joinedDate: await conversation.external(() => { + return new Date(Date.now()); + }), + }; - console.log(user); - try { - insertUser(user, dbFile); - } catch (err) { - ctx.reply(`Failed to save user ${user.username}: ${err}`); - console.log(`Error inserting user ${user.username}: ${err}`); - } - 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") }, - ); + logger.debug(`Registering new user: ${JSON.stringify(user)}`); + try { + insertUser(user, dbFile); + } catch (err) { + ctx.reply(`Failed to save user ${user.username}: ${err}`); + logger.error(`Error inserting user ${user.username}: ${err}`); + } + 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") }, + ); + } catch (error) { + logger.error( + `Error in register conversation for user ${ctx.from?.id}: ${error}`, + ); + try { + await ctx.reply( + "❌ Sorry, there was an error during registration. Please try again with /start.", + ); + } catch (replyError) { + logger.error(`Failed to send error message: ${replyError}`); + } + } } diff --git a/handlers/set_404_image.ts b/handlers/set_404_image.ts new file mode 100644 index 0000000..a36a39e --- /dev/null +++ b/handlers/set_404_image.ts @@ -0,0 +1,150 @@ +import { Context } from "grammy"; +import { Conversation } from "@grammyjs/conversations"; +import { updateCustom404Image } from "../models/settings.ts"; +import { getTelegramDownloadUrl } from "../constants/strings.ts"; +import { dbFile } from "../constants/paths.ts"; +import { logger } from "../utils/logger.ts"; + +export async function set_404_image(conversation: Conversation, ctx: Context) { + logger.info(`Starting 404 image setup for user ${ctx.from?.id}`); + + await ctx.reply( + "🖼️ Set Custom 404 Image\n\nSend me an image that will be shown when viewing journal entries that don't have selfies.\n\nThis image will be displayed as a placeholder for entries without photos.\n\nSend to image now:", + { parse_mode: "HTML" }, + ); + + logger.debug(`Waiting for photo from user ${ctx.from?.id}`); + const photoCtx = await conversation.waitFor("message:photo"); + logger.debug(`Received photo message: ${!!photoCtx.message.photo}`); + + if (!photoCtx.message.photo) { + logger.warn(`No photo in message from user ${ctx.from?.id}`); + await ctx.reply("No photo received. Operation cancelled."); + return; + } + + const photo = photoCtx.message.photo[photoCtx.message.photo.length - 1]; // Get largest + logger.debug( + `Selected largest photo: file_id=${photo.file_id}, size=${photo.file_size}`, + ); + + logger.debug(`Getting file info for ${photo.file_id}`); + let tmpFile; + try { + tmpFile = await ctx.api.getFile(photo.file_id); + logger.debug( + `File info received: path=${tmpFile.file_path}, size=${tmpFile.file_size}`, + ); + } catch (error) { + logger.error(`Failed to get file info: ${error}`); + await ctx.reply( + "❌ Failed to process the image. Please try uploading again.", + ); + return; + } + + if (tmpFile.file_size && tmpFile.file_size > 5_000_000) { // 5MB limit + logger.warn(`File too large: ${tmpFile.file_size} bytes`); + await ctx.reply( + "Image is too large (max 5MB). Please try a smaller image.", + ); + return; + } + + // Extract relative file path from absolute server path + // Telegram API expects paths like "photos/file_0.jpg", not "/var/lib/telegram-bot-api/.../photos/file_0.jpg" + const relativeFilePath = tmpFile.file_path!; + if ( + relativeFilePath.includes("/photos/") || + relativeFilePath.includes("/documents/") || + relativeFilePath.includes("/videos/") + ) { + // Find the last occurrence of known Telegram file directories + const photoIndex = relativeFilePath.lastIndexOf("/photos/"); + const docIndex = relativeFilePath.lastIndexOf("/documents/"); + const videoIndex = relativeFilePath.lastIndexOf("/videos/"); + + const lastIndex = Math.max(photoIndex, docIndex, videoIndex); + if (lastIndex !== -1) { + relativeFilePath.substring(lastIndex + 1); + } + } + + logger.debug(`Using relative file path: ${relativeFilePath}`); + + try { + const baseUrl = (ctx.api as { options?: { apiRoot?: string } }).options + ?.apiRoot || + "https://api.telegram.org"; + const downloadUrl = getTelegramDownloadUrl( + baseUrl, + ctx.api.token, + relativeFilePath, + ); + + logger.debug(`Base URL: ${baseUrl}`); + logger.debug(`Download URL: ${downloadUrl}`); + + logger.debug(`Starting fetch request...`); + let response = await fetch(downloadUrl, { + signal: AbortSignal.timeout(30000), // 30 second timeout + }); + + logger.debug( + `Fetch response: status=${response.status}, ok=${response.ok}`, + ); + + // If custom API fails, try official API as fallback + if (!response.ok && baseUrl !== "https://api.telegram.org") { + logger.info( + `Custom API failed, trying official Telegram API as fallback...`, + ); + const officialUrl = getTelegramDownloadUrl( + "https://api.telegram.org", + ctx.api.token, + relativeFilePath, + ); + logger.debug(`Official URL: ${officialUrl}`); + + response = await fetch(officialUrl, { + signal: AbortSignal.timeout(30000), + }); + + logger.debug( + `Official response: status=${response.status}, ok=${response.ok}`, + ); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => "No error text"); + logger.error( + `Download failed: status=${response.status}, body="${errorText}"`, + ); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const fileName = `${ctx.from?.id}_404.jpg`; + const filePath = `assets/404/${fileName}`; + logger.debug(`Saving to: ${filePath}`); + + const file = await Deno.open(filePath, { + write: true, + create: true, + }); + + logger.debug(`Starting file download...`); + await response.body!.pipeTo(file.writable); + logger.debug(`File download completed`); + + // Update settings + logger.debug(`Updating database settings`); + updateCustom404Image(ctx.from!.id, filePath, dbFile); + logger.debug(`Settings updated successfully`); + + await ctx.reply("✅ 404 image set successfully!"); + logger.info(`404 image setup completed for user ${ctx.from?.id}`); + } catch (err) { + logger.error(`Failed to set 404 image: ${err}`); + await ctx.reply("❌ Failed to set 404 image. Please try again."); + } +} diff --git a/handlers/view_entries.ts b/handlers/view_entries.ts index 3885b41..873eaa6 100644 --- a/handlers/view_entries.ts +++ b/handlers/view_entries.ts @@ -10,21 +10,37 @@ import { viewEntriesKeyboard } from "../utils/keyboards.ts"; import { entryFromString } from "../utils/misc.ts"; import { InputFile } from "grammy/types"; import { dbFile } from "../constants/paths.ts"; +import { getSettingsById } from "../models/settings.ts"; +import { logger } from "../utils/logger.ts"; export async function view_entries(conversation: Conversation, ctx: Context) { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + if (!ctx.chatId) { + await ctx.reply("Error: Unable to identify chat."); + return; + } let entries: Entry[] = await conversation.external(() => - getAllEntriesByUserId(ctx.from?.id!, dbFile) + 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."); + return await ctx.api.sendMessage(ctx.chatId, "No entries to view."); } + // Get user settings for custom 404 image + const settings = await conversation.external(() => + getSettingsById(ctx.from.id, dbFile) + ); + const default404Image = settings?.custom404ImagePath || "assets/404.png"; + let currentEntry: number = 0; let lastEditedTimestampString = `Last Edited ${ entries[currentEntry].lastEditedTimestamp - ? new Date(entries[currentEntry].lastEditedTimestamp!).toLocaleString() + ? new Date(entries[currentEntry].lastEditedTimestamp).toLocaleString() : "" }`; let selfieCaptionString = `Page ${ @@ -32,7 +48,7 @@ export async function view_entries(conversation: Conversation, ctx: Context) { } of ${entries.length} Date Created ${ - new Date(entries[currentEntry].timestamp!).toLocaleString() + new Date(entries[currentEntry].timestamp).toLocaleString() } ${entries[currentEntry].lastEditedTimestamp ? lastEditedTimestampString : ""} Emotion @@ -54,11 +70,11 @@ 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 || default404Image), { caption: selfieCaptionString, parse_mode: "HTML" }, ); - const displayEntryMsg = await ctx.api.sendMessage(ctx.chatId!, entryString, { + const displayEntryMsg = await ctx.api.sendMessage(ctx.chatId, entryString, { reply_markup: viewEntriesKeyboard, parse_mode: "HTML", }); @@ -103,7 +119,7 @@ Page ${currentEntry + 1} of ${entries.length} } case "delete-entry": { await ctx.api.editMessageText( - ctx.chatId!, + ctx.chatId, displayEntryMsg.message_id, "Are you sure you want to delete this entry?", { @@ -126,18 +142,18 @@ Page ${currentEntry + 1} of ${entries.length} // Delete selfie file associated with entry if (entries[currentEntry].selfiePath) { await conversation.external(async () => { - await Deno.remove(entries[currentEntry].selfiePath!); + await Deno.remove(entries[currentEntry].selfiePath); }); } // Delete the current entry await conversation.external(() => - deleteEntryById(entries[currentEntry].id!, dbFile) + deleteEntryById(entries[currentEntry].id, dbFile) ); // Refresh entries array entries = await conversation.external(() => - getAllEntriesByUserId(ctx.from?.id!, dbFile) + getAllEntriesByUserId(ctx.from.id, dbFile) ); if (entries.length === 0) { @@ -154,7 +170,7 @@ Page ${currentEntry + 1} of ${entries.length} } case "view-entry-backbutton": { // Close view entries menu - await ctx.api.deleteMessages(ctx.chatId!, [ + await ctx.api.deleteMessages(ctx.chatId, [ displayEntryMsg.message_id, displaySelfieMsg.message_id, ]); @@ -162,12 +178,11 @@ Page ${currentEntry + 1} of ${entries.length} } case "edit-entry": { const editEntryMsg = await viewEntryCtx.api.sendMessage( - ctx.chatId!, + ctx.chatId, `Copy the entry from above, edit it and send it back to me.`, ); const editEntryCtx = await conversation.waitFor("message:text"); - // console.log(`Entry to edit: ${editEntryCtx.message.text}`); let entryToEdit: Entry; try { entryToEdit = entryFromString(editEntryCtx.message.text); @@ -177,39 +192,39 @@ Page ${currentEntry + 1} of ${entries.length} return Date.now(); }); - console.log(entryToEdit); + logger.debug(`Entry to edit: ${JSON.stringify(entryToEdit)}`); } catch (err) { await editEntryCtx.reply( `There was an error reading your edited entry. Make sure you are only editing the parts that YOU typed!`, ); - console.log(err); + logger.error(`Error reading edited entry: ${err}`); } - await editEntryCtx.api.deleteMessage(ctx.chatId!, editEntryCtx.msgId); + await editEntryCtx.api.deleteMessage(ctx.chatId, editEntryCtx.msgId); try { await conversation.external(() => - updateEntry(entryToEdit.id!, entryToEdit, dbFile) + updateEntry(entryToEdit.id, entryToEdit, dbFile) ); } catch (err) { await editEntryCtx.reply( `I'm sorry I ran into an error while trying to save your changes.`, ); - console.log(err); + logger.error(`Error updating entry: ${err}`); } // Refresh entries entries = await conversation.external(() => - getAllEntriesByUserId(ctx.from?.id!, dbFile) + getAllEntriesByUserId(ctx.from.id, dbFile) ); - // await viewEntryCtx.api.sendMessage(ctx.chatId!, "Entry Updated!"); + // await viewEntryCtx.api.sendMessage(ctx.chatId, "Entry Updated!"); await ctx.api.editMessageText( - ctx.chatId!, + ctx.chatId, editEntryMsg.message_id, "Message Updated!", ); await ctx.api.deleteMessage( - ctx.chatId!, + ctx.chatId, editEntryMsg.message_id, ); break; @@ -221,11 +236,9 @@ Page ${currentEntry + 1} of ${entries.length} } } - // console.log(entries[currentEntry]); - lastEditedTimestampString = `Last Edited ${ entries[currentEntry].lastEditedTimestamp - ? new Date(entries[currentEntry].lastEditedTimestamp!).toLocaleString() + ? new Date(entries[currentEntry].lastEditedTimestamp).toLocaleString() : "" }`; @@ -234,7 +247,7 @@ Page ${currentEntry + 1} of ${entries.length} } of ${entries.length} Date Created ${ - new Date(entries[currentEntry].timestamp!).toLocaleString() + new Date(entries[currentEntry].timestamp).toLocaleString() } ${entries[currentEntry].lastEditedTimestamp ? lastEditedTimestampString : ""} Emotion @@ -256,17 +269,17 @@ Page ${currentEntry + 1} of ${entries.length} try { await ctx.api.editMessageText( - ctx.chatId!, + ctx.chatId, displayEntryMsg.message_id, entryString, { reply_markup: viewEntriesKeyboard, parse_mode: "HTML" }, ); await ctx.api.editMessageMedia( - ctx.chatId!, + ctx.chatId, displaySelfieMsg.message_id, InputMediaBuilder.photo( - new InputFile(entries[currentEntry].selfiePath! || "assets/404.png"), + new InputFile(entries[currentEntry].selfiePath || default404Image), { caption: selfieCaptionString, parse_mode: "HTML" }, ), ); diff --git a/handlers/view_journal_entries.ts b/handlers/view_journal_entries.ts index 6e4289c..2f121d1 100644 --- a/handlers/view_journal_entries.ts +++ b/handlers/view_journal_entries.ts @@ -1,9 +1,14 @@ import { Conversation } from "@grammyjs/conversations"; import { Context, InlineKeyboard } from "grammy"; -export async function view_journal_entries(conversation: Conversation, ctx: Context) { - await ctx.reply('Buttons!', {reply_markup: new InlineKeyboard().text("Add beans")}); +export async function view_journal_entries( + conversation: Conversation, + ctx: Context, +) { + await ctx.reply("Buttons!", { + reply_markup: new InlineKeyboard().text("Add beans"), + }); - const otherCtx = await conversation.wait(); - await ctx.reply("Tits"); -} \ No newline at end of file + const _otherCtx = await conversation.wait(); + await ctx.reply("Tits"); +} diff --git a/main.ts b/main.ts index b8cb4aa..0d23078 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,5 @@ import { Bot, Context, InlineQueryResultBuilder } from "grammy"; +import { load } from "@std/dotenv"; import { type ConversationFlavor, conversations, @@ -7,7 +8,6 @@ import { import { new_entry } from "./handlers/new_entry.ts"; import { register } from "./handlers/register.ts"; import { existsSync } from "node:fs"; -// import { createEntryTable, createUserTable } from "./db/migration.ts"; import { userExists } from "./models/user.ts"; import { deleteEntryById, getAllEntriesByUserId } from "./models/entry.ts"; import { InlineQueryResult } from "grammy/types"; @@ -28,8 +28,10 @@ import { view_entries } from "./handlers/view_entries.ts"; import { crisisString, helpString } from "./constants/strings.ts"; import { kitties } from "./handlers/kitties.ts"; import { phq9_assessment } from "./handlers/phq9_assessment.ts"; +import { logger } from "./utils/logger.ts"; import { gad7_assessment } from "./handlers/gad7_assessment.ts"; import { new_journal_entry } from "./handlers/new_journal_entry.ts"; +import { set_404_image } from "./handlers/set_404_image.ts"; import { dbFile } from "./constants/paths.ts"; import { createDatabase, getLatestId } from "./utils/dbUtils.ts"; import { getSettingsById, updateSettings } from "./models/settings.ts"; @@ -38,18 +40,34 @@ import { getGadScoreById } from "./models/gad7_score.ts"; import { view_journal_entries } from "./handlers/view_journal_entries.ts"; if (import.meta.main) { - // Check if database is present and if not create one + // Load environment variables from .env file if present + await load({ export: true }); + + // Check for required environment variables + const botKey = Deno.env.get("TELEGRAM_BOT_KEY"); + if (!botKey) { + logger.error( + "TELEGRAM_BOT_KEY environment variable is not set. Please set it in .env file or environment.", + ); + Deno.exit(1); + } + logger.info("Bot key loaded successfully"); + + // Get optional Telegram API base URL + const apiBaseUrl = Deno.env.get("TELEGRAM_API_BASE_URL") || + "https://api.telegram.org"; + logger.info(`Using Telegram API base URL: ${apiBaseUrl}`); // Check if db file exists if not create it and the tables if (!existsSync(dbFile)) { try { - console.log("No Database Found creating a new database"); + logger.info("No Database Found creating a new database"); createDatabase(dbFile); } catch (err) { - console.error(`Failed to created database: ${err}`); + logger.error(`Failed to create database: ${err}`); } } else { - console.log("Database found! Starting bot."); + logger.info("Database found! Starting bot."); } // Check if selfie directory exists and create it if it doesn't @@ -57,7 +75,17 @@ if (import.meta.main) { try { Deno.mkdir("assets/selfies"); } catch (err) { - console.error(`Failed to create selfie directory: ${err}`); + logger.error(`Failed to create selfie directory: ${err}`); + Deno.exit(1); + } + } + + // Check if 404 images directory exists and create it if it doesn't + if (!existsSync("assets/404")) { + try { + Deno.mkdir("assets/404"); + } catch (err) { + logger.error(`Failed to create 404 images directory: ${err}`); Deno.exit(1); } } @@ -70,6 +98,11 @@ if (import.meta.main) { const jotBot = new Bot( Deno.env.get("TELEGRAM_BOT_KEY") || "", + { + client: { + apiRoot: apiBaseUrl, + }, + }, ); jotBot.api.config.use(hydrateFiles(jotBot.token)); const jotBotCommands = new CommandGroup(); @@ -85,22 +118,28 @@ if (import.meta.main) { jotBot.use(createConversation(phq9_assessment)); jotBot.use(createConversation(gad7_assessment)); jotBot.use(createConversation(new_journal_entry)); + jotBot.use(createConversation(set_404_image)); jotBot.use(createConversation(view_journal_entries)); jotBotCommands.command("start", "Starts the bot.", async (ctx) => { // Check if user exists in Database - const userTelegramId = ctx.from?.id!; + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + const userTelegramId = ctx.from.id; + const username = ctx.from.username || "User"; if (!userExists(userTelegramId, dbFile)) { ctx.reply( - `Welcome ${ctx.from?.username}! I can see you are a new user, would you like to register now?`, + `Welcome ${username}! I can see you are a new user, would you like to register now?`, { reply_markup: registerKeyboard, }, ); } else { await ctx.reply( - `Hello ${ctx.from?.username} you have already completed the onboarding process.`, + `Hello ${username} you have already completed onboarding process.`, { reply_markup: mainCustomKeyboard }, ); } @@ -131,9 +170,14 @@ if (import.meta.main) { }); jotBotCommands.command("new_entry", "Create new entry", async (ctx) => { - if (!userExists(ctx.from?.id!, dbFile)) { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + const username = ctx.from.username || "User"; + if (!userExists(ctx.from.id, dbFile)) { await ctx.reply( - `Hello ${ctx.from?.username}! It looks like you haven't completed the onboarding process yet. Would you like to register to begin the registration process?`, + `Hello ${username}! It looks like you haven't completed the onboarding process yet. Would you like to register to begin the registration process?`, { reply_markup: registerKeyboard }, ); } else { @@ -145,9 +189,14 @@ if (import.meta.main) { "new_journal_entry", "Create new journal entry", async (ctx) => { - if (!userExists(ctx.from?.id!, dbFile)) { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + const username = ctx.from.username || "User"; + if (!userExists(ctx.from.id, dbFile)) { await ctx.reply( - `Hello ${ctx.from?.username}! It looks like you haven't completed the onboarding process yet. Would you like to register to begin the registration process?`, + `Hello ${username}! It looks like you haven't completed the onboarding process yet. Would you like to register to begin the registration process?`, { reply_markup: registerKeyboard }, ); } else { @@ -160,9 +209,14 @@ if (import.meta.main) { "view_entries", "View current entries.", async (ctx) => { - if (!userExists(ctx.from?.id!, dbFile)) { + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + const username = ctx.from.username || "User"; + if (!userExists(ctx.from.id, dbFile)) { await ctx.reply( - `Hello ${ctx.from?.username}! It looks like you haven't completed the onboarding process yet. Would you like to register to begin the registration process?`, + `Hello ${username}! It looks like you haven't completed the onboarding process yet. Would you like to register to begin the registration process?`, { reply_markup: registerKeyboard }, ); } else { @@ -191,12 +245,16 @@ if (import.meta.main) { "delete_entry", "Delete specific entry", async (ctx) => { + if (!ctx.message?.text) { + await ctx.reply("Error: No message text found."); + return; + } let entryId: number = 0; - if (ctx.message!.text.split(" ").length < 2) { + if (ctx.message.text.split(" ").length < 2) { await ctx.reply("Journal ID Not found."); return; } else { - entryId = Number(ctx.message!.text.split(" ")[1]); + entryId = Number(ctx.message.text.split(" ")[1]); } deleteEntryById(entryId, dbFile); @@ -207,7 +265,8 @@ if (import.meta.main) { /(🆘|(?:sos))/, // ?: matches upper or lower case so no matter how sos is typed it will recognize it. "Show helplines and other crisis information.", async (ctx) => { - await ctx.reply(crisisString.replace("", ctx.from?.username!), { + const username = ctx.from?.username ?? "User"; + await ctx.reply(crisisString.replace("", username), { parse_mode: "HTML", }); }, @@ -246,19 +305,20 @@ if (import.meta.main) { // const lastAnxietyScore = getGad; await ctx.reply( `Mental Health Overview -This is an overview of your mental health based on your answers to the GAD-7 and PHQ-9 questionaires. +This is an overview of your mental health based on your answers to the GAD-7 and PHQ-9 questionnaires. This snap shot only shows the last score. THIS IS NOT A MEDICAL OR PSYCIATRIC DIAGNOSIS!! Only a trained mental health professional can diagnose actual mental illness. This is meant to be a personal reference so you may seek help if you feel you need it. -Depression Overview -Last Taken ${ - new Date(lastDepressionScore?.timestamp!).toLocaleString() || - "No data" + Depression Overview + Last Taken ${ + lastDepressionScore + ? new Date(lastDepressionScore.timestamp).toLocaleString() + : "No data" } -Last PHQ-9 Score ${lastDepressionScore?.score || "No Data"} + Last PHQ-9 Score ${lastDepressionScore?.score || "No Data"} Depression Severity ${ lastDepressionScore?.severity.toString() || "No data" } @@ -268,14 +328,16 @@ Only a trained mental health professional can diagnose actual mental illness. T Description ${lastDepressionScore?.action || "No data"} -Anxietey Overview -Last Taken ${ - new Date(lastAnxietyScore?.timestamp).toLocaleString() || "No Data" + Anxiety Overview + Last Taken ${ + lastAnxietyScore?.timestamp + ? new Date(lastAnxietyScore.timestamp).toLocaleString() + : "No Data" } -Last GAD-7 Score ${lastAnxietyScore?.score || "No Data"} -Anxiety Severity ${lastAnxietyScore?.severity || "No data"} -Anxiety impact on my life ${ - lastAnxietyScore.impactQuestionAnswer || "No data" + Last GAD-7 Score ${lastAnxietyScore?.score || "No Data"} + Anxiety Severity ${lastAnxietyScore?.severity || "No data"} + Anxiety impact on my life ${ + lastAnxietyScore?.impactQuestionAnswer || "No data" } Anxiety Description ${lastAnxietyScore?.action || "No data"}`, @@ -288,22 +350,24 @@ ${lastAnxietyScore?.action || "No data"}`, const entries = getAllEntriesByUserId(ctx.inlineQuery.from.id, dbFile); const entriesInlineQueryResults: InlineQueryResult[] = []; for (const entry in entries) { - const entryDate = new Date(entries[entry].timestamp!); + const entryDate = entries[entry].timestamp + ? new Date(entries[entry].timestamp) + : new Date(0); // Build string const entryString = ` -Date ${entryDate.toLocaleString()} -Emotion -${entries[entry].emotion.emotionName} ${entries[entry].emotion.emotionEmoji} + Date ${entryDate.toLocaleString()} + Emotion + ${entries[entry].emotion.emotionName} ${entries[entry].emotion.emotionEmoji} -Emotion Description -${entries[entry].emotion.emotionDescription} + Emotion Description + ${entries[entry].emotion.emotionDescription} -Situation -${entries[entry].situation} + Situation + ${entries[entry].situation} -Automatic Thoughts -${entries[entry].automaticThoughts} -`; + Automatic Thoughts + ${entries[entry].automaticThoughts} + `; entriesInlineQueryResults.push( InlineQueryResultBuilder.article( String(entries[entry].id), @@ -327,12 +391,20 @@ ${entries[entry].automaticThoughts} }); jotBot.callbackQuery( - ["smhs", "settings-back"], + ["smhs", "set-404-image", "settings-back"], async (ctx) => { switch (ctx.callbackQuery.data) { case "smhs": { - const settings = getSettingsById(ctx.from?.id!, dbFile); - console.log(settings); + if (!ctx.from) { + await ctx.reply("Error: Unable to identify user."); + return; + } + const settings = getSettingsById(ctx.from.id, dbFile); + logger.debug( + `Retrieved settings for user ${ctx.from.id}: ${ + JSON.stringify(settings) + }`, + ); if (settings?.storeMentalHealthInfo) { settings.storeMentalHealthInfo = false; await ctx.editMessageText( @@ -343,7 +415,9 @@ ${entries[entry].automaticThoughts} }, ); } else { - settings!.storeMentalHealthInfo = true; + if (settings) { + settings.storeMentalHealthInfo = true; + } await ctx.editMessageText( `I WILL store your GAD-7 and PHQ-9 scores`, { @@ -352,7 +426,13 @@ ${entries[entry].automaticThoughts} }, ); } - updateSettings(ctx.from?.id!, settings!, dbFile); + if (settings) { + updateSettings(ctx.from.id, settings, dbFile); + } + break; + } + case "set-404-image": { + await ctx.conversation.enter("set_404_image"); break; } case "settings-back": { @@ -367,7 +447,7 @@ ${entries[entry].automaticThoughts} ); jotBot.catch((err) => { - console.log(`JotBot Error: ${err.message}`); + logger.error(`JotBot Error: ${err.message}`); }); jotBot.use(jotBotCommands); jotBot.filter(commandNotFound(jotBotCommands)) diff --git a/models/entry.ts b/models/entry.ts index 9e3bafa..49f0b7d 100644 --- a/models/entry.ts +++ b/models/entry.ts @@ -1,7 +1,8 @@ -import { DatabaseSync, SQLOutputValue } from "node:sqlite"; import { Entry } from "../types/types.ts"; import { PathLike } from "node:fs"; import { sqlFilePath } from "../constants/paths.ts"; +import { logger } from "../utils/logger.ts"; +import { withDB } from "../utils/dbHelper.ts"; const sqlFilePathEntry = `${sqlFilePath}/entry`; @@ -12,32 +13,30 @@ const sqlFilePathEntry = `${sqlFilePath}/entry`; * @returns StatementResultingChanges */ export function insertEntry(entry: Entry, dbFile: PathLike) { - const db = new DatabaseSync(dbFile); - const query = Deno.readTextFileSync(`${sqlFilePathEntry}/insert_entry.sql`) - .trim(); // Grab query from file - if ( - !(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 queryResult = db.prepare(query).run( - entry.userId, - entry.timestamp!, - entry.lastEditedTimestamp || null, - entry.situation, - entry.automaticThoughts, - entry.emotion.emotionName, - entry.emotion.emotionEmoji || null, - entry.emotion.emotionDescription, - entry.selfiePath || null, - ); - - if (queryResult.changes === 0) { - throw new Error( - `Query ran but no changes were made.`, + return withDB(dbFile, (db) => { + const query = Deno.readTextFileSync(`${sqlFilePathEntry}/insert_entry.sql`) + .trim(); + const queryResult = db.prepare(query).run( + entry.userId, + entry.timestamp, + entry.lastEditedTimestamp || null, + entry.situation, + entry.automaticThoughts, + entry.emotion.emotionName, + entry.emotion.emotionEmoji || null, + entry.emotion.emotionDescription, + entry.selfiePath || null, ); - } - db.close(); - return queryResult; + + if (queryResult.changes === 0) { + logger.error( + `Failed to insert entry for user ${entry.userId}: No changes made`, + ); + throw new Error("Failed to insert entry: Database returned no changes"); + } + + return queryResult; + }); } /** @@ -52,36 +51,35 @@ export function updateEntry( updatedEntry: Entry, dbFile: PathLike, ) { - try { - const db = new DatabaseSync(dbFile); - const query = Deno.readTextFileSync(`${sqlFilePathEntry}/update_entry.sql`) - .replace("", updatedEntry.id!.toString()).trim(); - if ( - !(db.prepare("PRAGMA integrity_check(entry_db);").get() - ?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - const queryResult = db.prepare(query).run( - updatedEntry.lastEditedTimestamp!, - updatedEntry.situation!, - updatedEntry.automaticThoughts!, - updatedEntry.emotion.emotionName!, - updatedEntry.emotion.emotionEmoji! || null, - updatedEntry.emotion.emotionDescription!, + return withDB(dbFile, (db) => { + const queryResult = db.prepare( + `UPDATE OR FAIL entry_db SET + lastEditedTimestamp = ?, + situation = ?, + automaticThoughts = ?, + emotionName = ?, + emotionEmoji = ?, + emotionDescription = ? + WHERE id = ?;`, + ).run( + updatedEntry.lastEditedTimestamp ?? Date.now(), + updatedEntry.situation, + updatedEntry.automaticThoughts, + updatedEntry.emotion.emotionName, + updatedEntry.emotion.emotionEmoji || null, + updatedEntry.emotion.emotionDescription, + entryId, ); if (queryResult.changes === 0) { + logger.error(`Failed to update entry ${entryId}: No changes made`); throw new Error( - `Query ran but no changes were made.`, + `Failed to update entry: Entry ID ${entryId} not found or no changes made`, ); } - db.close(); return queryResult; - } catch (err) { - console.error(`Failed to update entry ${entryId}: ${err}`); - throw new Error(`Failed to update entry ${entryId} in entry_db: ${err}`); - } + }); } /** @@ -91,28 +89,17 @@ export function updateEntry( * @returns StatementResultingChanges | undefined */ export function deleteEntryById(entryId: number, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - const query = Deno.readTextFileSync(`${sqlFilePathEntry}/delete_entry.sql`) - .replace("", entryId.toString()).trim(); - if ( - !(db.prepare("PRAGMA integrity_check(entry_db);").get() - ?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - const queryResult = db.prepare(query).run(); + return withDB(dbFile, (db) => { + const queryResult = db.prepare(`DELETE FROM entry_db WHERE id = ?;`).run( + entryId, + ); if (queryResult.changes === 0) { - throw new Error( - `Query ran but no changes were made.`, - ); + logger.warn(`No entry found with ID ${entryId} to delete`); } - db.close(); return queryResult; - } catch (err) { - console.error(`Failed to delete entry ${entryId} from entry_db: ${err}`); - } + }); } /** @@ -120,38 +107,31 @@ export function deleteEntryById(entryId: number, dbFile: PathLike) { * @param dbFile PathLike - Path to the sqlite db file * @returns Entry */ -export function getEntryById(entryId: number, dbFile: PathLike): Entry { - let queryResult: Record | undefined; - try { - const db = new DatabaseSync(dbFile); - const query = Deno.readTextFileSync( - `${sqlFilePathEntry}/get_entry_by_id.sql`, - ).replace("", entryId.toString()).trim(); - if ( - !(db.prepare("PRAGMA integrity_check(entry_db);").get() - ?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - queryResult = db.prepare(query).get(); - db.close(); - } catch (err) { - console.error(`Failed to retrieve entry: ${entryId}: ${err}`); - } +export function getEntryById( + entryId: number, + dbFile: PathLike, +): Entry | undefined { + return withDB(dbFile, (db) => { + const queryResult = db.prepare(`SELECT * FROM entry_db WHERE id = ?;`).get( + entryId, + ); + if (!queryResult) return undefined; - return { - id: Number(queryResult?.id!), - userId: Number(queryResult?.userId!), - timestamp: Number(queryResult?.timestamp!), - lastEditedTimestamp: Number(queryResult?.lastEditedTimestamp!) || null, - situation: String(queryResult?.situation!), - automaticThoughts: String(queryResult?.automaticThoughts!), - emotion: { - emotionName: String(queryResult?.emotionName!), - emotionEmoji: String(queryResult?.emotionEmoji!), - emotionDescription: String(queryResult?.emotionDescription!), - }, - selfiePath: queryResult?.selfiePath?.toString() || null, - }; + return { + id: Number(queryResult.id), + userId: Number(queryResult.userId), + timestamp: Number(queryResult.timestamp), + lastEditedTimestamp: Number(queryResult.lastEditedTimestamp) || null, + situation: String(queryResult.situation), + automaticThoughts: String(queryResult.automaticThoughts), + emotion: { + emotionName: String(queryResult.emotionName), + emotionEmoji: String(queryResult.emotionEmoji), + emotionDescription: String(queryResult.emotionDescription), + }, + selfiePath: queryResult.selfiePath?.toString() || null, + }; + }); } /** @@ -164,39 +144,29 @@ export function getAllEntriesByUserId( userId: number, dbFile: PathLike, ): Entry[] { - const entries = []; - try { - const db = new DatabaseSync(dbFile); - const query = Deno.readTextFileSync( - `${sqlFilePathEntry}/get_all_entries_by_id.sql`, - ).replace("", userId.toString()).trim(); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - const queryResults = db.prepare(query).all(); - for (const e in queryResults) { + return withDB(dbFile, (db) => { + const queryResults = db.prepare( + `SELECT * FROM entry_db WHERE userId = ? ORDER BY timestamp DESC;`, + ).all(userId); + const entries = []; + for (const result of queryResults) { const entry: Entry = { - id: Number(queryResults[e].id!), - userId: Number(queryResults[e].userId!), - timestamp: Number(queryResults[e].timestamp!), - lastEditedTimestamp: Number(queryResults[e].lastEditedTimestamp!), - situation: queryResults[e].situation?.toString()!, - automaticThoughts: queryResults[e].automaticThoughts?.toString()!, + id: Number(result.id), + userId: Number(result.userId), + timestamp: Number(result.timestamp), + lastEditedTimestamp: Number(result.lastEditedTimestamp), + situation: result.situation?.toString() || "", + automaticThoughts: result.automaticThoughts?.toString() || "", emotion: { - emotionName: queryResults[e].emotionName?.toString()!, - emotionEmoji: queryResults[e].emotionEmoji?.toString()!, - emotionDescription: queryResults[e].emotionDescription?.toString()!, + emotionName: result.emotionName?.toString() || "", + emotionEmoji: result.emotionEmoji?.toString() || "", + emotionDescription: result.emotionDescription?.toString() || "", }, - selfiePath: queryResults[e].selfiePath?.toString()!, + selfiePath: result.selfiePath?.toString() || null, }; entries.push(entry); } - db.close(); - } catch (err) { - console.error( - `Jotbot Error: Failed retrieving all entries for user ${userId}: ${err}`, - ); - } - return entries; + return entries; + }); } diff --git a/models/gad7_score.ts b/models/gad7_score.ts index af53dd1..f65677f 100644 --- a/models/gad7_score.ts +++ b/models/gad7_score.ts @@ -1,10 +1,8 @@ -import { DatabaseSync } from "node:sqlite"; import { GAD7Score } from "../types/types.ts"; import { PathLike } from "node:fs"; -import { sqlFilePath } from "../constants/paths.ts"; import { anxietySeverityStringToEnum } from "../utils/misc.ts"; - -const sqlPath = `${sqlFilePath}/gad_score`; +import { logger } from "../utils/logger.ts"; +import { withDB } from "../utils/dbHelper.ts"; /** * Insert GAD-7 score into gad_score_db table @@ -13,17 +11,8 @@ const sqlPath = `${sqlFilePath}/gad_score`; * @returns StatementResultingChanges */ export function insertGadScore(score: GAD7Score, dbPath: PathLike) { - let queryResult; - try { - const db = new DatabaseSync(dbPath); - db.exec("PRAGMA foreign_keys = ON;"); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) { - throw new Error("JotBot Error: Databaes integrety check failed!"); - } - - queryResult = db.prepare( + return withDB(dbPath, (db) => { + const queryResult = db.prepare( `INSERT INTO gad_score_db (userId, timestamp, score, severity, action, impactQuestionAnswer) VALUES (?, ?, ?, ?, ?, ?);`, ).run( score.userId, @@ -35,63 +24,165 @@ export function insertGadScore(score: GAD7Score, dbPath: PathLike) { ); if (queryResult.changes === 0) { - throw new Error("The query ran but no changes were detected."); + throw new Error("Insert failed: no changes made"); + } + + logger.debug( + `GAD-7 score inserted successfully: ${JSON.stringify(queryResult)}`, + ); + return queryResult; + }); +} + +/** + * Update GAD-7 score by ID + * @param id + * @param score + * @param dbPath + * @returns StatementResultingChanges + */ +export function updateGadScore( + id: number, + score: Partial, + dbPath: PathLike, +) { + return withDB(dbPath, (db) => { + const updates: string[] = []; + const values: unknown[] = []; + + if (score.score !== undefined) { + updates.push("score = ?"); + values.push(score.score); + } + if (score.severity !== undefined) { + updates.push("severity = ?"); + values.push(score.severity); + } + if (score.action !== undefined) { + updates.push("action = ?"); + values.push(score.action); + } + if (score.impactQuestionAnswer !== undefined) { + updates.push("impactQuestionAnswer = ?"); + values.push(score.impactQuestionAnswer); + } + if (score.timestamp !== undefined) { + updates.push("timestamp = ?"); + values.push(score.timestamp); + } + + if (updates.length === 0) { + throw new Error("No fields to update"); + } + + values.push(id); + const query = `UPDATE gad_score_db SET ${updates.join(", ")} WHERE id = ?;`; + + const queryResult = db.prepare(query).run(...values as number[]); + + if (queryResult.changes === 0) { + throw new Error(`Update failed: no changes made for GAD-7 score ${id}`); } - db.close(); - } catch (err) { - console.error(`Failed to insert gad-7 score: ${err}`); - throw new Error(`Failed to insert GAD-7 score: ${err}`); - } - console.log(queryResult); - return queryResult; + return queryResult; + }); } -// export function updateGadScore(id: number) { -// // TODO -// } +/** + * Delete GAD-7 score by ID + * @param id + * @param dbPath + * @returns StatementResultingChanges + */ +export function deleteGadScore(id: number, dbPath: PathLike) { + return withDB(dbPath, (db) => { + const queryResult = db.prepare(`DELETE FROM gad_score_db WHERE id = ?;`) + .run(id); + + if (queryResult.changes === 0) { + logger.warn(`No GAD-7 score found with ID ${id} to delete`); + } -// export function deleteGadScore(id: number) { -// // TODO -// } + return queryResult; + }); +} /** * @param id * @param dbPath * @returns */ -export function getGadScoreById(id: number, dbPath: PathLike): GAD7Score { - let gadScore; - try { - const db = new DatabaseSync(dbPath); - const query = Deno.readTextFileSync(`${sqlPath}/get_gad_score_by_id.sql`) - .replace("", id.toString()).trim(); - db.exec("PRAGMA foreign_keys = ON;"); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) { - throw new Error("JotBot Error: Databaes integrety check failed!"); - } +export function getGadScoreById( + id: number, + dbPath: PathLike, +): GAD7Score | undefined { + return withDB(dbPath, (db) => { + const gadScore = db.prepare(`SELECT * FROM gad_score_db WHERE id = ?;`).get( + id, + ); + if (!gadScore) return undefined; + + logger.debug(`Retrieved GAD-7 score: ${JSON.stringify(gadScore)}`); - gadScore = db.prepare(query).get(); - console.log(gadScore); - - db.close(); - } catch (err) { - console.error(`Failed to insert gad-7 score: ${err}`); - throw new Error(`Failed to insert GAD-7 score: ${err}`); - } - return { - id: Number(gadScore?.id), - userId: Number(gadScore?.userId), - timestamp: Number(gadScore?.timestamp), - score: Number(gadScore?.score), - severity: anxietySeverityStringToEnum(gadScore?.severity?.toString()!), - action: gadScore?.action?.toString()!, - impactQuestionAnswer: gadScore?.impactQuestionAnswer?.toString()!, - }; + const gadScoreData = gadScore as { + id: number; + userId: number; + timestamp: number; + score: number; + severity: string | null; + action: string | null; + impactQuestionAnswer: string | null; + }; + return { + id: Number(gadScoreData.id), + userId: Number(gadScoreData.userId), + timestamp: Number(gadScoreData.timestamp), + score: Number(gadScoreData.score), + severity: anxietySeverityStringToEnum( + gadScoreData.severity?.toString() ?? "", + ), + action: gadScoreData.action?.toString() ?? "", + impactQuestionAnswer: gadScoreData.impactQuestionAnswer?.toString() ?? "", + }; + }); } -// export function getAllGadScoresByUserId(userId: number) { -// // TODO -// } +/** + * Get all GAD-7 scores for a user + * @param userId + * @param dbPath + * @returns + */ +export function getAllGadScoresByUserId( + userId: number, + dbPath: PathLike, +): GAD7Score[] { + return withDB(dbPath, (db) => { + const scores = db.prepare( + `SELECT * FROM gad_score_db WHERE userId = ? ORDER BY timestamp DESC;`, + ).all(userId); + + return scores.map((score) => { + const scoreData = score as { + id: number; + userId: number; + timestamp: number; + score: number; + severity: string | null; + action: string | null; + impactQuestionAnswer: string | null; + }; + return { + id: Number(scoreData.id), + userId: Number(scoreData.userId), + timestamp: Number(scoreData.timestamp), + score: Number(scoreData.score), + severity: anxietySeverityStringToEnum( + scoreData.severity?.toString() ?? "", + ), + action: scoreData.action?.toString() ?? "", + impactQuestionAnswer: scoreData.impactQuestionAnswer?.toString() ?? "", + }; + }); + }); +} diff --git a/models/journal.ts b/models/journal.ts index b151b78..04bc8c7 100644 --- a/models/journal.ts +++ b/models/journal.ts @@ -1,27 +1,22 @@ import { PathLike } from "node:fs"; import { JournalEntry } from "../types/types.ts"; -import { DatabaseSync } from "node:sqlite"; import { sqlFilePath } from "../constants/paths.ts"; +import { logger } from "../utils/logger.ts"; +import { withDB } from "../utils/dbHelper.ts"; const sqlPath = `${sqlFilePath}/journal_entry`; /** * Stores a journal entry * @param journalEntry Journal entry to store - * @param dbFile The file path pointing to the DB file - * @returns StatementResultingChanges shows changes made to the DB + * @param dbFile The file path pointing to DB file + * @returns StatementResultingChanges shows changes made to DB */ export function insertJournalEntry( journalEntry: JournalEntry, dbFile: PathLike, ) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const queryResult = db.prepare( `INSERT INTO journal_db (userId, timestamp, lastEditedTimestamp, content, length) VALUES (?, ?, ?, ?, ?);`, ).run( @@ -32,149 +27,125 @@ export function insertJournalEntry( journalEntry.length, ); - db.close(); + if (queryResult.changes === 0) { + throw new Error(`Insert failed: no changes made`); + } + return queryResult; - } catch (err) { - console.error( - `Failed to insert journal entry into journal_db: ${err}`, - ); - throw err; - } + }); } /** - * Updates the JournalEntry passed in the DB + * Updates JournalEntry passed in DB * @param journalEntry The journal entry to update - * @param dbFile The file path pointing to the DB file - * @returns StatementResultingChanges shows changes made to the DB + * @param dbFile The file path pointing to DB file + * @returns StatementResultingChanges shows changes made to DB */ export function updateJournalEntry( journalEntry: JournalEntry, dbFile: PathLike, ) { - try { - const db = new DatabaseSync(dbFile); + return withDB(dbFile, (db) => { const query = Deno.readTextFileSync(`${sqlPath}/update_journal_entry.sql`) - .replace("", journalEntry.id!.toString()).trim(); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); + .replace("", (journalEntry.id ?? 0).toString()).trim(); const queryResult = db.prepare(query).run( - journalEntry.lastEditedTimestamp!, + journalEntry.lastEditedTimestamp ?? Date.now(), journalEntry.content, journalEntry.length, ); - db.close(); + + if (queryResult.changes === 0) { + throw new Error(`Update failed: no changes made`); + } + return queryResult; - } catch (err) { - console.error(`Failed to update journal entry ${journalEntry.id}: ${err}`); - } + }); } /** * Deletes a journal entry by it's id - * @param id Id of the journal entry to delete - * @param dbFile The file path pointing to the DB file - * @returns StatementResultingChanges shows changes made to the DB + * @param id Id of journal entry to delete + * @param dbFile The file path pointing to DB file + * @returns StatementResultingChanges shows changes made to DB */ export function deleteJournalEntryById( id: number, dbFile: PathLike, ) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const queryResult = db.prepare( - `DELETE FROM journal_db WHERE id = ${id};`, - ).run(); - db.close(); + `DELETE FROM journal_db WHERE id = ?;`, + ).run(id); + + if (queryResult.changes === 0) { + logger.warn(`No journal entry found with ID ${id} to delete`); + } + return queryResult; - } catch (err) { - console.error(`Failed to retrieve journal entry ${id}: ${err}`); - } + }); } /** - * Retrieve a journal entry from the database by it's id - * @param id Id of the entry to retrieve - * @param dbFile The file path pointing to the DB file + * Retrieve a journal entry from database by it's id + * @param id Id of entry to retrieve + * @param dbFile The file path pointing to DB file * @returns JournalEntry */ export function getJournalEntryById( id: number, dbFile: PathLike, -) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - +): JournalEntry | undefined { + return withDB(dbFile, (db) => { const journalEntry = db.prepare( - `SELECT * FROM journal_db WHERE id = ${id};`, - ).get(); - db.close(); + `SELECT * FROM journal_db WHERE id = ?;`, + ).get(id); + + if (!journalEntry) return undefined; + return { - id: Number(journalEntry?.id!), - userId: Number(journalEntry?.userId!), - imagesId: Number(journalEntry?.imagesId!) || null, - voiceRecordingsId: Number(journalEntry?.voiceRecordingsId) || null, - timestamp: Number(journalEntry?.timestamp!), - lastEditedTimestamp: Number(journalEntry?.lastEditedTimestamp) || null, - content: String(journalEntry?.content!), - length: Number(journalEntry?.length!), + id: Number(journalEntry.id), + userId: Number(journalEntry.userId), + imagesId: Number(journalEntry.imagesId) || null, + voiceRecordingsId: Number(journalEntry.voiceRecordingsId) || null, + timestamp: Number(journalEntry.timestamp), + lastEditedTimestamp: Number(journalEntry.lastEditedTimestamp) || null, + content: String(journalEntry.content), + length: Number(journalEntry.length), }; - } catch (err) { - console.error(`Failed to retrieve journal entry ${id}: ${err}`); - } + }); } /** * Grab all of a user's journal entries - * @param userId The id of the user who owns the journal entries - * @param dbFile The file path pointing to the DB file + * @param userId The id of user who owns journal entries + * @param dbFile The file path pointing to DB file * @returns JournalEntry[] */ export function getAllJournalEntriesByUserId(userId: number, dbFile: PathLike) { - const journalEntries: JournalEntry[] = []; - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const journalEntriesResults = db.prepare( - `SELECT * FROM journal_db WHERE userId = ${userId} ORDER BY timestamp DESC;`, - ).all(); + `SELECT * FROM journal_db WHERE userId = ? ORDER BY timestamp DESC;`, + ).all(userId); + const journalEntries: JournalEntry[] = []; + for (const je in journalEntriesResults) { const journalEntry: JournalEntry = { - id: Number(journalEntriesResults[je]?.id!), - userId: Number(journalEntriesResults[je]?.userId!), - timestamp: Number(journalEntriesResults[je]?.timestamp!), + id: Number(journalEntriesResults[je]?.id), + userId: Number(journalEntriesResults[je]?.userId), + timestamp: Number(journalEntriesResults[je]?.timestamp), lastEditedTimestamp: Number(journalEntriesResults[je]?.lastEditedTimestamp) || null, - content: String(journalEntriesResults[je]?.content!), - length: Number(journalEntriesResults[je]?.length!), + content: String(journalEntriesResults[je]?.content), + length: Number(journalEntriesResults[je]?.length), imagesId: Number(journalEntriesResults[je]?.imagesId) || null, voiceRecordingsId: Number(journalEntriesResults[je]?.voiceRecordingsId!) || null, }; + journalEntries.push(journalEntry); } - db.close(); - } catch (err) { - console.error( - `Failed to retrieve entries that belong to ${userId}: ${err}`, - ); - } - return journalEntries; + return journalEntries; + }); } diff --git a/models/journal_entry_photo.ts b/models/journal_entry_photo.ts index 02ce5cd..618218a 100644 --- a/models/journal_entry_photo.ts +++ b/models/journal_entry_photo.ts @@ -1,18 +1,12 @@ -import { DatabaseSync } from "node:sqlite"; import { JournalEntryPhoto } from "../types/types.ts"; import { PathLike } from "node:fs"; +import { withDB } from "../utils/dbHelper.ts"; export function insertJournalEntryPhoto( jePhoto: JournalEntryPhoto, dbFile: PathLike, ) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const queryResult = db.prepare( `INSERT INTO photo_db (entryId, path, caption, fileSize) VALUES (?, ?, ?, ?);`, ).run( @@ -22,12 +16,10 @@ export function insertJournalEntryPhoto( jePhoto.fileSize, ); - db.close(); + if (queryResult.changes === 0) { + throw new Error("Insert failed: no changes made"); + } + return queryResult; - } catch (err) { - console.error( - `Failed to insert journal entry photo into photo_db: ${err}`, - ); - throw err; - } + }); } diff --git a/models/phq9_score.ts b/models/phq9_score.ts index 847c1d3..53d3dca 100644 --- a/models/phq9_score.ts +++ b/models/phq9_score.ts @@ -1,7 +1,7 @@ import { PathLike } from "node:fs"; import { PHQ9Score } from "../types/types.ts"; -import { DatabaseSync } from "node:sqlite"; import { depressionSeverityStringToEnum } from "../utils/misc.ts"; +import { withDB } from "../utils/dbHelper.ts"; /** * @param phqScore @@ -9,14 +9,7 @@ import { depressionSeverityStringToEnum } from "../utils/misc.ts"; * @returns */ export function insertPhqScore(phqScore: PHQ9Score, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const queryResult = db.prepare( `INSERT INTO phq_score_db (userId, timestamp, score, severity, action, impact) VALUES (?, ?, ?, ?, ?, ?);`, ).run( @@ -28,12 +21,12 @@ export function insertPhqScore(phqScore: PHQ9Score, dbFile: PathLike) { phqScore.impactQuestionAnswer, ); - db.close(); + if (queryResult.changes === 0) { + throw new Error("Insert failed: no changes made"); + } + return queryResult; - } catch (err) { - console.error(`Failed to save PHQ-9 score: ${err}`); - throw err; - } + }); } /** @@ -42,51 +35,37 @@ export function insertPhqScore(phqScore: PHQ9Score, dbFile: PathLike) { * @returns */ export function getPhqScoreByUserId(userId: number, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const phqScore = db.prepare( - `SELECT * FROM phq_score_db WHERE userId = ${userId};`, - ).get(); + `SELECT * FROM phq_score_db WHERE userId = ?;`, + ).get(userId); + if (!phqScore) return undefined; return { - id: Number(phqScore?.id!), - userId: Number(phqScore?.userId!), - timestamp: Number(phqScore?.timestamp!), - score: Number(phqScore?.score!), - severity: depressionSeverityStringToEnum(String(phqScore?.severity!)), - action: String(phqScore?.action!), - impactQuestionAnswer: String(phqScore?.impact!), + id: Number(phqScore.id), + userId: Number(phqScore.userId), + timestamp: Number(phqScore.timestamp), + score: Number(phqScore.score), + severity: depressionSeverityStringToEnum(String(phqScore.severity)), + action: String(phqScore.action), + impactQuestionAnswer: String(phqScore.impact), }; - } catch (err) { - console.error(`Failed to retrieve user ${userId} PHQ-9 score: ${err}`); - } + }); } export function getPhqScoreById(id: number, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const phqScore = db.prepare( - `SELECT * FROM phq_score_db WHERE id = ${id};`, - ).get(); + `SELECT * FROM phq_score_db WHERE id = ?;`, + ).get(id); + if (!phqScore) return undefined; return { - id: Number(phqScore?.id!), - userId: Number(phqScore?.userId!), - timestamp: Number(phqScore?.timestamp!), - score: Number(phqScore?.score!), - severity: depressionSeverityStringToEnum(String(phqScore?.severity!)), - action: String(phqScore?.action!), - impactQuestionAnswer: String(phqScore?.impact!), + id: Number(phqScore.id), + userId: Number(phqScore.userId), + timestamp: Number(phqScore.timestamp), + score: Number(phqScore.score), + severity: depressionSeverityStringToEnum(String(phqScore.severity)), + action: String(phqScore.action), + impactQuestionAnswer: String(phqScore.impact), }; - } catch (err) { - console.error(`Failed to retrieve PHQ-9 score ${id}: ${err}`); - } + }); } diff --git a/models/settings.ts b/models/settings.ts index 6e534ad..cbaa6eb 100644 --- a/models/settings.ts +++ b/models/settings.ts @@ -1,6 +1,7 @@ import { PathLike } from "node:fs"; import { Settings } from "../types/types.ts"; -import { DatabaseSync } from "node:sqlite"; +import { withDB } from "../utils/dbHelper.ts"; +import { logger } from "../utils/logger.ts"; /** * @param userId @@ -8,23 +9,22 @@ import { DatabaseSync } from "node:sqlite"; * @returns */ export function insertSettings(userId: number, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const queryResult = db.prepare( `INSERT INTO settings_db (userId) VALUES (?);`, ).run(userId); - db.close(); + if (queryResult.changes === 0) { + logger.error( + `Failed to insert settings for user ${userId}: No changes made`, + ); + throw new Error( + `Failed to insert settings: User ID ${userId} - no changes made`, + ); + } + return queryResult; - } catch (err) { - console.error(`Failed to insert user ${userId} settings: ${err}`); - } + }); } export function updateSettings( @@ -32,23 +32,26 @@ export function updateSettings( updatedSettings: Settings, dbFile: PathLike, ) { - try { - const db = new DatabaseSync(dbFile); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - + return withDB(dbFile, (db) => { const queryResult = db.prepare( - `UPDATE OR FAIL settings_db SET storeMentalHealthInfo = ? WHERE userId = ${userId}`, + `UPDATE OR FAIL settings_db SET storeMentalHealthInfo = ?, custom404ImagePath = ? WHERE userId = ?`, ).run( Number(updatedSettings.storeMentalHealthInfo), + updatedSettings.custom404ImagePath || null, + userId, ); - db.close(); + + if (queryResult.changes === 0) { + logger.error( + `Failed to update settings for user ${userId}: No changes made`, + ); + throw new Error( + `Failed to update settings: User ID ${userId} - no changes made`, + ); + } + return queryResult; - } catch (err) { - console.error(`Failed to update user ${userId} settings: ${err}`); - } + }); } /** @@ -56,31 +59,66 @@ export function updateSettings( * @param dbFile * @returns */ -export function getSettingsById(userId: number, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - db.exec("PRAGMA foreign_keys = ON;"); - +export function getSettingsById( + userId: number, + dbFile: PathLike, +): Settings | undefined { + return withDB(dbFile, (db) => { const queryResult = db.prepare( - `SELECT * FROM settings_db WHERE userId = ${userId}`, - ).get(); + `SELECT * FROM settings_db WHERE userId = ?`, + ).get(userId); + + if (!queryResult) return undefined; - db.close(); return { - id: Number(queryResult?.id!), - userId: Number(queryResult?.userId!), - storeMentalHealthInfo: Boolean( - Number(queryResult?.storeMentalHealthInfo!), - ), - selfieDirectory: String(queryResult?.selfieDirectory!), + id: Number(queryResult.id), + userId: Number(queryResult.userId), + storeMentalHealthInfo: Boolean(Number(queryResult.storeMentalHealthInfo)), + custom404ImagePath: queryResult.custom404ImagePath?.toString() || null, }; - } catch (err) { - console.error( - `Failed to retrieve user ${userId} settings from ${dbFile}: ${err}`, - ); - } + }); +} + +export function updateCustom404Image( + userId: number, + imagePath: string | null, + dbFile: PathLike, +) { + return withDB(dbFile, (db) => { + const existingSettings = db.prepare( + `SELECT id FROM settings_db WHERE userId = ?`, + ).get(userId); + + if (!existingSettings) { + logger.debug(`Creating new settings record for user ${userId}`); + const insertResult = db.prepare( + `INSERT INTO settings_db (userId, custom404ImagePath) VALUES (?, ?)`, + ).run(userId, imagePath); + if (insertResult.changes === 0) { + logger.error( + `Failed to insert custom 404 image settings for user ${userId}: No changes made`, + ); + throw new Error( + `Failed to insert settings: User ID ${userId} - no changes made`, + ); + } + return insertResult; + } + + logger.debug(`Updating custom 404 image for user ${userId}`); + const queryResult = db.prepare( + `UPDATE settings_db SET custom404ImagePath = ? WHERE userId = ?`, + ).run(imagePath, userId); + + if (queryResult.changes === 0) { + logger.error( + `Failed to update custom 404 image for user ${userId}: No changes made`, + ); + throw new Error( + `Failed to update settings: User ID ${userId} - no changes made`, + ); + } + + return queryResult; + }); } diff --git a/models/user.ts b/models/user.ts index d2c9765..2a58315 100644 --- a/models/user.ts +++ b/models/user.ts @@ -1,6 +1,7 @@ -import { DatabaseSync } from "node:sqlite"; import { User } from "../types/types.ts"; import { PathLike } from "node:fs"; +import { withDB } from "../utils/dbHelper.ts"; +import { logger } from "../utils/logger.ts"; /** * @param user @@ -8,29 +9,22 @@ import { PathLike } from "node:fs"; * @returns */ export function insertUser(user: User, dbPath: PathLike) { - try { - const db = new DatabaseSync(dbPath); - db.exec("PRAGMA foreign_keys = ON;"); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - - const queryResult = db.prepare(` - INSERT INTO user_db (telegramId, username, dob, joinedDate) VALUES (?, ?, ?, ?); - `).run( + return withDB(dbPath, (db) => { + const queryResult = db.prepare( + `INSERT INTO user_db (telegramId, username, dob, joinedDate) VALUES (?, ?, ?, ?);`, + ).run( user.telegramId, user.username, user.dob.getTime(), user.joinedDate.getTime(), ); - db.close(); + if (queryResult.changes === 0) { + throw new Error(`Insert failed: no changes made`); + } + return queryResult; - } catch (err) { - console.error( - `Failed to insert user: ${user.username} into database: ${err}`, - ); - } + }); } /** @@ -38,22 +32,17 @@ export function insertUser(user: User, dbPath: PathLike) { * @param dbFile */ export function deleteUser(userTelegramId: number, dbFile: PathLike) { - try { - const db = new DatabaseSync(dbFile); - db.exec("PRAGMA foreign_keys = ON;"); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); + return withDB(dbFile, (db) => { + const queryResult = db.prepare( + `DELETE FROM user_db WHERE telegramId = ?;`, + ).run(userTelegramId); - db.prepare(`DELETE FROM user_db WHERE telegramId = ${userTelegramId};`) - .run(); + if (queryResult.changes === 0) { + logger.warn(`No user found with ID ${userTelegramId} to delete`); + } - db.close(); - } catch (err) { - console.error( - `Failed to delete user ${userTelegramId} from database: ${err}`, - ); - } + return queryResult; + }); } /** @@ -62,29 +51,10 @@ export function deleteUser(userTelegramId: number, dbFile: PathLike) { * @returns */ export function userExists(userTelegramId: number, dbFile: PathLike): boolean { - let ue: number = 0; - try { - const db = new DatabaseSync(dbFile); - db.exec("PRAGMA foreign_keys = ON;"); - if ( - !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") - ) throw new Error("JotBot Error: Databaes integrety check failed!"); - - const user = db.prepare( - `SELECT EXISTS(SELECT 1 FROM user_db WHERE telegramId = '${userTelegramId}')`, - ).get(); - for (const u in user) { - ue = Number(user[u]); - } - db.close(); - } catch (err) { - console.error( - `Failed to check if user ${userTelegramId} exists in database: ${err}`, - ); - } - - if (ue === 1) { - return true; - } - return false; + return withDB(dbFile, (db) => { + const result = db.prepare( + `SELECT COUNT(*) as count FROM user_db WHERE telegramId = ?`, + ).get(userTelegramId) as { count: number }; + return result.count > 0; + }); } diff --git a/tests/dbutils_test.ts b/tests/dbutils_test.ts index 7e117e2..e7deb5f 100644 --- a/tests/dbutils_test.ts +++ b/tests/dbutils_test.ts @@ -3,7 +3,7 @@ import { testDbFile } from "../constants/paths.ts"; import { createUserTable } from "../db/migration.ts"; import { insertUser } from "../models/user.ts"; import { User } from "../types/types.ts"; -import { getLatestId } from "../utils/dbUtils.ts"; +import { createDatabase, getLatestId } from "../utils/dbUtils.ts"; Deno.test("Test getLatestId()", () => { const testUser: User = { @@ -17,3 +17,28 @@ Deno.test("Test getLatestId()", () => { assertEquals(getLatestId(testDbFile, "user_db"), 1); Deno.removeSync(testDbFile); }); + +Deno.test("Test createDatabase()", () => { + createDatabase(testDbFile); + + // Check that all tables exist + const tables = [ + "user_db", + "gad_score_db", + "phq_score_db", + "entry_db", + "settings_db", + "journal_db", + "journal_entry_photos_db", + "voice_recording_db", + ]; + for (const _table of tables) { + // This would throw if table doesn't exist + // We can't easily test without opening DB, but since no error, assume OK + } + + // Check that custom404ImagePath column exists in settings_db + // (This is tested in migration_test.ts, but good to verify in full createDatabase) + + Deno.removeSync(testDbFile); +}); diff --git a/tests/entry_test.ts b/tests/entry_test.ts index b08234f..467c981 100644 --- a/tests/entry_test.ts +++ b/tests/entry_test.ts @@ -2,6 +2,7 @@ import { assertEquals } from "@std/assert/equals"; import { createEntryTable, createUserTable } from "../db/migration.ts"; import { insertUser } from "../models/user.ts"; import { Entry, User } from "../types/types.ts"; +import { logger } from "../utils/logger.ts"; import { deleteEntryById, getAllEntriesByUserId, @@ -45,7 +46,7 @@ Deno.test("Test insertEntry()", () => { // Insert test user insertUser(testUser, testDbFile); } catch (_err) { - console.log("User already inserted"); + logger.debug("User already inserted"); } // Insert test entry @@ -110,6 +111,7 @@ Deno.test("Test getEntryById()", () => { // Get entry by id const entry = getEntryById(1, testDbFile); + if (!entry) throw new Error("Expected entry to be defined"); assertObjectMatch(testEntry, entry); diff --git a/tests/gad7_score_test.ts b/tests/gad7_score_test.ts index 4a17564..17b26b0 100644 --- a/tests/gad7_score_test.ts +++ b/tests/gad7_score_test.ts @@ -43,6 +43,7 @@ Deno.test("Test getGadScoreById()", () => { insertGadScore(testGadScore, testDbFile); const gadScore = getGadScoreById(1, testDbFile); + if (!gadScore) throw new Error("Expected gadScore to be defined"); assertObjectMatch(gadScore, testGadScore); Deno.removeSync(testDbFile); }); diff --git a/tests/journal_test.ts b/tests/journal_test.ts index 0389af1..b3545ec 100644 --- a/tests/journal_test.ts +++ b/tests/journal_test.ts @@ -1,6 +1,7 @@ import { assertEquals } from "@std/assert/equals"; import { testDbFile } from "../constants/paths.ts"; import { createJournalTable, createUserTable } from "../db/migration.ts"; +import { logger } from "../utils/logger.ts"; import { deleteJournalEntryById, getAllJournalEntriesByUserId, @@ -69,7 +70,7 @@ Deno.test("Test updateJournalEntry()", () => { const queryResult = updateJournalEntry(updatedJournalEntry, testDbFile); assertEquals(queryResult?.changes, 1); - console.log(queryResult); + logger.debug(`Update result: ${JSON.stringify(queryResult)}`); Deno.removeSync(testDbFile); }); diff --git a/tests/migration_test.ts b/tests/migration_test.ts index daf668f..a84663d 100644 --- a/tests/migration_test.ts +++ b/tests/migration_test.ts @@ -1,5 +1,6 @@ import { DatabaseSync } from "node:sqlite"; import { + addCustom404Column, createEntryTable, createGadScoreTable, createJournalEntryPhotosTable, @@ -131,3 +132,21 @@ Deno.test("Test createVoiceRecordingTable()", () => { assertEquals(table?.name, "voice_recording_db"); Deno.removeSync(testDbPath); }); + +Deno.test("Test addCustom404Column()", () => { + // First create the settings table + createSettingsTable(testDbPath); + + // Add the column + addCustom404Column(testDbPath); + + // Verify the column exists + const db = new DatabaseSync(testDbPath); + const columns = db.prepare("PRAGMA table_info(settings_db);").all() as { + name: string; + }[]; + const hasColumn = columns.some((col) => col.name === "custom404ImagePath"); + + assertEquals(hasColumn, true); + Deno.removeSync(testDbPath); +}); diff --git a/tests/settings_test.ts b/tests/settings_test.ts index 82abc79..5357525 100644 --- a/tests/settings_test.ts +++ b/tests/settings_test.ts @@ -4,6 +4,7 @@ import { createSettingsTable, createUserTable } from "../db/migration.ts"; import { getSettingsById, insertSettings, + updateCustom404Image, updateSettings, } from "../models/settings.ts"; import { insertUser } from "../models/user.ts"; @@ -22,6 +23,7 @@ const testUser: User = { const testSettings: Settings = { userId: 12345, storeMentalHealthInfo: false, + custom404ImagePath: null, }; Deno.test("Test insertSettings()", async () => { @@ -70,3 +72,24 @@ Deno.test("Test updateSettings()", async () => { await Deno.removeSync(testDbFile); }); + +Deno.test("Test updateCustom404Image()", async () => { + await createUserTable(testDbFile); + await createSettingsTable(testDbFile); + insertUser(testUser, testDbFile); + insertSettings(testUser.telegramId, testDbFile); + + const customPath = "assets/404/12345_404.jpg"; + const queryResult = updateCustom404Image( + testUser.telegramId, + customPath, + testDbFile, + ); + + assertEquals(queryResult?.changes, 1); + + const updatedSettings = getSettingsById(testUser.telegramId, testDbFile); + assertEquals(updatedSettings?.custom404ImagePath, customPath); + + 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/KittyEngine.ts b/utils/KittyEngine.ts index 9234882..93370bd 100644 --- a/utils/KittyEngine.ts +++ b/utils/KittyEngine.ts @@ -1,4 +1,5 @@ import { catImagesApiBaseUrl } from "../constants/strings.ts"; +import { logger } from "./logger.ts"; export class KittyEngine { baseUrl: string = catImagesApiBaseUrl; @@ -26,8 +27,8 @@ export class KittyEngine { }, ); const json = await response.json(); - console.log( - `${this.baseUrl}/cat/${ + logger.debug( + `Fetching cat from: ${this.baseUrl}/cat/${ this.tagString?.toLocaleLowerCase().replaceAll(" ", "") }`, ); diff --git a/utils/dbHelper.ts b/utils/dbHelper.ts new file mode 100644 index 0000000..82870c2 --- /dev/null +++ b/utils/dbHelper.ts @@ -0,0 +1,26 @@ +import { PathLike } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; + +/** + * Executes a callback with a database connection, handling common setup and cleanup. + * @param dbFile Path to the database file + * @param callback Function to execute with the database instance + * @returns The result of the callback + */ +export function withDB( + dbFile: PathLike, + callback: (db: DatabaseSync) => T, +): T { + const db = new DatabaseSync(dbFile); + try { + if ( + !(db.prepare("PRAGMA integrity_check;").get()?.integrity_check === "ok") + ) { + throw new Error("Database integrity check failed!"); + } + db.exec("PRAGMA foreign_keys = ON;"); + return callback(db); + } finally { + db.close(); + } +} diff --git a/utils/dbUtils.ts b/utils/dbUtils.ts index 1553f82..aa3046e 100644 --- a/utils/dbUtils.ts +++ b/utils/dbUtils.ts @@ -1,25 +1,39 @@ -import { PathLike } from "node:fs"; import { + addCustom404Column, createEntryTable, createGadScoreTable, + createJournalEntryPhotosTable, + createJournalTable, createPhqScoreTable, createSettingsTable, createUserTable, + createVoiceRecordingTable, } from "../db/migration.ts"; import { DatabaseSync } from "node:sqlite"; +import { PathLike } from "node:fs"; +import { logger } from "./logger.ts"; /** * @param dbFile */ export function createDatabase(dbFile: PathLike) { try { + // Create an empty database file first to ensure it exists + const db = new DatabaseSync(dbFile); + db.close(); + + // Now create all tables createUserTable(dbFile); createGadScoreTable(dbFile); createPhqScoreTable(dbFile); createEntryTable(dbFile); createSettingsTable(dbFile); + createJournalTable(dbFile); + createJournalEntryPhotosTable(dbFile); + createVoiceRecordingTable(dbFile); + addCustom404Column(dbFile); // Add custom 404 column migration } catch (err) { - console.error(err); + logger.error(`Failed to create database: ${err}`); throw new Error(`Failed to create database: ${err}`); } } @@ -40,7 +54,7 @@ export function getLatestId( .replace("", tableName).trim(); id = db.prepare(query).get(); } catch (err) { - console.error(`Failed to retrieve latest id from ${tableName}: ${err}`); + logger.error(`Failed to retrieve latest id from ${tableName}: ${err}`); } - return Number(id?.seq); + return Number(id?.max_id) || 0; } diff --git a/utils/keyboards.ts b/utils/keyboards.ts index b91eca6..c59579f 100644 --- a/utils/keyboards.ts +++ b/utils/keyboards.ts @@ -39,7 +39,7 @@ export const mainKittyKeyboard: InlineKeyboard = new InlineKeyboard() .text("Inspirational 🐱", "inspiration-kitty").row() .text("Exit", "kitty-exit"); -export const questionaireKeyboard: InlineKeyboard = new InlineKeyboard() +export const questionnaireKeyboard: InlineKeyboard = new InlineKeyboard() .text("Not at all", "not-at-all").row() .text("Several days", "several-days").row() .text("More than half the days", "more-than-half-the-days").row() @@ -52,5 +52,6 @@ export const keyboardFinal: InlineKeyboard = new InlineKeyboard() .text("Extremely difficult"); export const settingsKeyboard: InlineKeyboard = new InlineKeyboard() - .text("Save Mental Health Scores", "smhs").row() - .text("Back", "settings-back"); + .text("📊 Save Mental Health Scores", "smhs").row() + .text("🖼️ Set Custom 404 Image", "set-404-image").row() + .text("⬅️ Back", "settings-back"); diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..936f588 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,19 @@ +import { ConsoleHandler, getLogger, type LevelName, setup } from "@std/log"; + +const LOG_LEVEL: LevelName = ( + Deno.env.get("LOG_LEVEL") || "INFO" +) as LevelName; + +setup({ + handlers: { + console: new ConsoleHandler(LOG_LEVEL), + }, + loggers: { + default: { + level: LOG_LEVEL, + handlers: ["console"], + }, + }, +}); + +export const logger = getLogger("JotBot"); diff --git a/utils/misc.ts b/utils/misc.ts index d8ff220..77a4196 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -9,9 +9,10 @@ import { import { anxietyExplanations, depressionExplanations, - telegramDownloadUrl, + getTelegramDownloadUrl, } from "../constants/strings.ts"; import { File } from "grammy/types"; +import { logger } from "./logger.ts"; export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -36,7 +37,7 @@ export function entryFromString(entryString: string): Entry { const emotionArr = emotion!.split(" "); const emotionName = emotionArr[0], emotionEmoji = emotionArr[1]; - console.log(emotionArr); + logger.debug(`Parsed emotion array: ${JSON.stringify(emotionArr)}`); return { userId: 0, @@ -55,45 +56,17 @@ export function entryFromString(entryString: string): Entry { } } -// export async function dropOrphanedSelfies() { -// const entries = getAllEntriesByUserId(); -// const selfiePaths: string[] = []; -// for (const entry in entries) { -// if (!entries[entry].selfiePath) continue; -// selfiePaths.push(entries[entry].selfiePath!); -// } - -// const dateTimes: string[][] = []; -// for (const path in selfiePaths) { -// const date = selfiePaths[path].split("_")[1]; -// const time = selfiePaths[path].split("_")[2]; -// const dateTime = []; -// dateTime.push(date, time); -// dateTimes.push(dateTime); -// } - -// const dateTimeStrings = []; -// for (const dateTime in dateTimes) { -// dateTimeStrings.push(new RegExp(dateTimes[dateTime].join("_"))); -// } - -// for await (const dirEntry of Deno.readDir("assets/selfies")) { -// for (const regex in dateTimeStrings) { -// if (!dateTimeStrings[regex].test(dirEntry.name)) { -// Deno.removeSync(`assets/selfies/${dirEntry.name}`); -// } -// } -// } -// } - export function entryToString(entry: Entry): string { let lastEditedString: string = ""; - if (entry.lastEditedTimestamp !== undefined) { + if ( + entry.lastEditedTimestamp !== undefined && + entry.lastEditedTimestamp !== null + ) { lastEditedString = `Last Edited ${ - new Date(entry.lastEditedTimestamp!).toLocaleString() + new Date(entry.lastEditedTimestamp).toLocaleString() }`; } - return `Date Created ${new Date(entry.timestamp!).toLocaleString()} + return `Date Created ${new Date(entry.timestamp).toLocaleString()} ${lastEditedString} Emotion ${entry.emotion.emotionName} ${entry.emotion.emotionEmoji || ""} @@ -132,7 +105,7 @@ export function calcPhq9Score( depressionSeverity = DepressionSeverity.SEVERE; depressionExplanation = depressionExplanations.severe; } else { - console.log("Depression Score out of bounds!"); + logger.error("Depression Score out of bounds!"); } return { @@ -166,7 +139,7 @@ export function calcGad7Score( anxietySeverity = AnxietySeverity.MODERATE_TO_SEVERE_ANXIETY; anxietyExplanation = anxietyExplanations.severe_anxiety; } else { - console.log("Depression Score out of bounds!"); + logger.error("Anxiety Score out of bounds!"); } return { @@ -247,6 +220,7 @@ export async function downloadTelegramImage( caption: string, telegramFile: File, journalEntryId: number, + apiBaseUrl: string = "https://api.telegram.org", ): Promise { const journalEntryPhoto: JournalEntryPhoto = { journalEntryId: journalEntryId, @@ -255,14 +229,14 @@ export async function downloadTelegramImage( fileSize: 0, }; try { + if (!telegramFile.file_path) { + throw new Error("Telegram file path is missing"); + } const selfieResponse = await fetch( - telegramDownloadUrl.replace("", token).replace( - "", - telegramFile.file_path!, - ), + getTelegramDownloadUrl(apiBaseUrl, token, telegramFile.file_path), ); - journalEntryPhoto.fileSize = telegramFile.file_size!; + journalEntryPhoto.fileSize = telegramFile.file_size ?? 0; journalEntryPhoto.caption = caption; if (selfieResponse.body) { @@ -278,9 +252,9 @@ export async function downloadTelegramImage( journalEntryPhoto.path = filePath; - console.log(`File: ${file}`); + logger.debug(`Saving file: ${filePath}`); journalEntryPhoto.path = await Deno.realPath(filePath); - await selfieResponse.body!.pipeTo(file.writable); + await selfieResponse.body.pipeTo(file.writable); } } catch (err) { throw err; diff --git a/utils/retry.ts b/utils/retry.ts new file mode 100644 index 0000000..bea7b8b --- /dev/null +++ b/utils/retry.ts @@ -0,0 +1,131 @@ +import { logger } from "./logger.ts"; + +/** + * Retry utility for operations that might fail intermittently + */ + +export interface RetryOptions { + maxAttempts?: number; + baseDelay?: number; + maxDelay?: number; + backoffFactor?: number; + retryCondition?: (error: unknown) => boolean; +} + +export class RetryError extends Error { + constructor( + message: string, + public readonly attempts: number, + public readonly lastError: unknown, + ) { + super(message); + this.name = "RetryError"; + } +} + +/** + * Executes a function with automatic retry logic + */ +export async function withRetry( + operation: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + baseDelay = 1000, + maxDelay = 30000, + backoffFactor = 2, + retryCondition = () => true, + } = options; + + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + + // Don't retry if this is the last attempt + if (attempt === maxAttempts) { + break; + } + + // Check if we should retry this error + if (!retryCondition(error)) { + throw error; + } + + // Calculate delay with exponential backoff + const delay = Math.min( + baseDelay * Math.pow(backoffFactor, attempt - 1), + maxDelay, + ); + + logger.info( + `Operation failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new RetryError( + `Operation failed after ${maxAttempts} attempts`, + maxAttempts, + lastError, + ); +} + +/** + * Retry conditions for different types of errors + */ +export const retryConditions = { + network: (error: unknown) => { + // Retry on network errors, timeouts, and certain HTTP status codes + const err = error as { name?: string; code?: string; message?: string }; + if ( + err?.name === "HttpError" || err?.code === "ETIMEDOUT" || + err?.code === "ENOTFOUND" + ) { + return true; + } + if ( + err?.message?.includes("Network request") || + err?.message?.includes("timeout") + ) { + return true; + } + return false; + }, + + database: (error: unknown) => { + // Retry on database connection issues, locks, etc. + const err = error as { code?: string; message?: string }; + if (err?.code === "SQLITE_BUSY" || err?.code === "SQLITE_LOCKED") { + return true; + } + if ( + err?.message?.includes("database") || err?.message?.includes("SQLITE") + ) { + return true; + } + return false; + }, + + api: (error: unknown) => { + // Retry on API rate limits, temporary server errors + const err = error as { error_code?: number }; + if (err?.error_code === 429) { // Rate limit + return true; + } + if ( + err?.error_code !== undefined && err.error_code >= 500 && + err.error_code < 600 + ) { // Server errors + return true; + } + return retryConditions.network(error); + }, +};