From 5bb1ba4a41be740c4e51fce51fb085d0c5fb53af Mon Sep 17 00:00:00 2001 From: Gorbachev Egor <7gorbachevm@gmail.com> Date: Tue, 19 Dec 2023 15:59:05 +0700 Subject: [PATCH] Allow quick adding card via Telegram messenger input --- functions/bot.ts | 11 +-- functions/db/databaseTypes.ts | 21 +++++ functions/db/deck/decks-with-cards-schema.ts | 1 + functions/db/deck/get-decks-created-by-me.ts | 25 ++++++ ...ks-db.ts => get-my-decks-with-cards-db.ts} | 2 +- .../db/user/user-set-server-bot-state.ts | 52 +++++++++++ functions/my-info.ts | 4 +- functions/server-bot/callback-query-type.ts | 7 ++ functions/server-bot/on-callback-query.ts | 89 +++++++++++++++++++ functions/server-bot/on-message.ts | 63 +++++++++++++ functions/server-bot/on-start.ts | 7 ++ functions/server-bot/parse-deck-from-text.ts | 12 +++ .../send-card-create-confirm-message.ts | 46 ++++++++++ 13 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 functions/db/deck/get-decks-created-by-me.ts rename functions/db/deck/{get-my-decks-db.ts => get-my-decks-with-cards-db.ts} (95%) create mode 100644 functions/db/user/user-set-server-bot-state.ts create mode 100644 functions/server-bot/callback-query-type.ts create mode 100644 functions/server-bot/on-callback-query.ts create mode 100644 functions/server-bot/on-message.ts create mode 100644 functions/server-bot/on-start.ts create mode 100644 functions/server-bot/parse-deck-from-text.ts create mode 100644 functions/server-bot/send-card-create-confirm-message.ts diff --git a/functions/bot.ts b/functions/bot.ts index 30bdf5b8..5ebeaa43 100644 --- a/functions/bot.ts +++ b/functions/bot.ts @@ -2,6 +2,9 @@ import { Bot, webhookCallback } from "grammy"; import { envSchema } from "./env/env-schema.ts"; import { handleError } from "./lib/handle-error/handle-error.ts"; import { createAuthFailedResponse } from "./lib/json-response/create-auth-failed-response.ts"; +import { onMessage } from "./server-bot/on-message.ts"; +import { onCallbackQuery } from "./server-bot/on-callback-query.ts"; +import { onStart } from "./server-bot/on-start.ts"; export const onRequestPost: PagesFunction = handleError( async ({ env, request }) => { @@ -12,11 +15,9 @@ export const onRequestPost: PagesFunction = handleError( } const bot = new Bot(envSafe.BOT_TOKEN); - bot.command("start", (ctx) => - ctx.reply( - `Improve your memory with spaced repetition. Learn languages, history or other subjects with the proven flashcard method. Click "MemoCard" 👇`, - ), - ); + bot.command("start", onStart); + bot.on("message", onMessage(envSafe)); + bot.on("callback_query:data", onCallbackQuery(envSafe)); const handleWebhook = webhookCallback(bot, "cloudflare-mod"); return handleWebhook(request); diff --git a/functions/db/databaseTypes.ts b/functions/db/databaseTypes.ts index 55179269..9d8581c3 100644 --- a/functions/db/databaseTypes.ts +++ b/functions/db/databaseTypes.ts @@ -195,6 +195,7 @@ export interface Database { last_name: string | null last_reminded_date: string | null last_used: string | null + server_bot_state: Json | null username: string | null } Insert: { @@ -208,6 +209,7 @@ export interface Database { last_name?: string | null last_reminded_date?: string | null last_used?: string | null + server_bot_state?: Json | null username?: string | null } Update: { @@ -221,6 +223,7 @@ export interface Database { last_name?: string | null last_reminded_date?: string | null last_used?: string | null + server_bot_state?: Json | null username?: string | null } Relationships: [] @@ -261,6 +264,24 @@ export interface Database { [_ in never]: never } Functions: { + get_active_decks_by_author: { + Args: { + user_id: number + } + Returns: { + author_id: number | null + available_in: string | null + category_id: string | null + created_at: string + description: string | null + id: number + is_public: boolean + name: string + share_id: string + speak_field: string | null + speak_locale: string | null + }[] + } get_cards_to_review: { Args: { usr_id: number diff --git a/functions/db/deck/decks-with-cards-schema.ts b/functions/db/deck/decks-with-cards-schema.ts index d683586e..10d66a9f 100644 --- a/functions/db/deck/decks-with-cards-schema.ts +++ b/functions/db/deck/decks-with-cards-schema.ts @@ -40,6 +40,7 @@ export const deckWithCardsSchema = deckSchema.merge( export const decksWithCardsSchema = z.array(deckWithCardsSchema); +export type DeckWithoutCardsDbType = z.infer; export type DeckWithCardsDbType = z.infer; export type DeckCardDbType = z.infer; export type DeckSpeakFieldEnum = z.infer; diff --git a/functions/db/deck/get-decks-created-by-me.ts b/functions/db/deck/get-decks-created-by-me.ts new file mode 100644 index 00000000..8647955d --- /dev/null +++ b/functions/db/deck/get-decks-created-by-me.ts @@ -0,0 +1,25 @@ +import { EnvSafe } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { DatabaseException } from "../database-exception.ts"; +import { + deckSchema, + DeckWithoutCardsDbType, +} from "./decks-with-cards-schema.ts"; +import { z } from "zod"; + +export const getDecksCreatedByMe = async ( + env: EnvSafe, + userId: number, +): Promise => { + const db = getDatabase(env); + + const result = await db.rpc("get_active_decks_by_author", { + user_id: userId, + }); + + if (result.error) { + throw new DatabaseException(result.error); + } + + return z.array(deckSchema).parse(result.data); +}; diff --git a/functions/db/deck/get-my-decks-db.ts b/functions/db/deck/get-my-decks-with-cards-db.ts similarity index 95% rename from functions/db/deck/get-my-decks-db.ts rename to functions/db/deck/get-my-decks-with-cards-db.ts index cf6076b4..3846e287 100644 --- a/functions/db/deck/get-my-decks-db.ts +++ b/functions/db/deck/get-my-decks-with-cards-db.ts @@ -7,7 +7,7 @@ import { } from "./decks-with-cards-schema.ts"; import { z } from "zod"; -export const getMyDecksDb = async ( +export const getMyDecksWithCardsDb = async ( env: EnvSafe, userId: number, ): Promise => { diff --git a/functions/db/user/user-set-server-bot-state.ts b/functions/db/user/user-set-server-bot-state.ts new file mode 100644 index 00000000..7b6d61e5 --- /dev/null +++ b/functions/db/user/user-set-server-bot-state.ts @@ -0,0 +1,52 @@ +import { EnvSafe } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { DatabaseException } from "../database-exception.ts"; + +export type ServerBotState = + | null + | { type: "cardAdded"; cardFront: string; cardBack: string } + | { + type: "deckSelected"; + cardFront: string; + cardBack: string; + deckId: number; + editingField?: "cardFront" | "cardBack"; + }; + +export const userSetServerBotState = async ( + envSafe: EnvSafe, + userId: number, + state: ServerBotState, +) => { + const db = getDatabase(envSafe); + const { error } = await db + .from("user") + .update({ server_bot_state: state }) + .eq("id", userId); + + if (error) { + throw new DatabaseException(error); + } +}; + +export const userGetServerBotState = async ( + envSafe: EnvSafe, + userId: number, +): Promise => { + const db = getDatabase(envSafe); + const result = await db + .from("user") + .select("server_bot_state") + .eq("id", userId) + .single(); + + if (result.error) { + throw new DatabaseException(result.error); + } + + if (!result.data) { + return null; + } + + return result.data.server_bot_state as ServerBotState; +}; diff --git a/functions/my-info.ts b/functions/my-info.ts index d34e92da..57a7e685 100644 --- a/functions/my-info.ts +++ b/functions/my-info.ts @@ -5,7 +5,7 @@ import { handleError } from "./lib/handle-error/handle-error.ts"; import { UserDbType } from "./db/user/upsert-user-db.ts"; import { DeckWithCardsDbType } from "./db/deck/decks-with-cards-schema.ts"; import { envSchema } from "./env/env-schema.ts"; -import { getMyDecksDb } from "./db/deck/get-my-decks-db.ts"; +import { getMyDecksWithCardsDb } from "./db/deck/get-my-decks-with-cards-db.ts"; import { CardToReviewDbType, getCardsToReviewDb, @@ -26,7 +26,7 @@ export const onRequest = handleError(async ({ request, env }) => { const [publicDecks, myDecks, cardsToReview] = await Promise.all([ await getUnAddedPublicDecksDb(envSafe, user.id), - await getMyDecksDb(envSafe, user.id), + await getMyDecksWithCardsDb(envSafe, user.id), await getCardsToReviewDb(envSafe, user.id), ]); diff --git a/functions/server-bot/callback-query-type.ts b/functions/server-bot/callback-query-type.ts new file mode 100644 index 00000000..fae30942 --- /dev/null +++ b/functions/server-bot/callback-query-type.ts @@ -0,0 +1,7 @@ +export enum CallbackQueryType { + Deck = "deck", + ConfirmCreateCard = "confirm", + EditFront = "edit-front", + EditBack = "edit-back", + Cancel = "cancel", +} diff --git a/functions/server-bot/on-callback-query.ts b/functions/server-bot/on-callback-query.ts new file mode 100644 index 00000000..c0181e8f --- /dev/null +++ b/functions/server-bot/on-callback-query.ts @@ -0,0 +1,89 @@ +import { EnvSafe } from "../env/env-schema.ts"; +import { Context } from "grammy"; +import { assert } from "../lib/typescript/assert.ts"; +import { getDatabase } from "../db/get-database.ts"; +import { CallbackQueryType } from "./callback-query-type.ts"; +import { + userGetServerBotState, + userSetServerBotState, +} from "../db/user/user-set-server-bot-state.ts"; +import { sendCardCreateConfirmMessage } from "./send-card-create-confirm-message.ts"; +import { DatabaseException } from "../db/database-exception.ts"; + +export const onCallbackQuery = (envSafe: EnvSafe) => async (ctx: Context) => { + assert(ctx.callbackQuery); + assert(ctx.from); + + const data = ctx.callbackQuery.data; + const db = getDatabase(envSafe); + if (!data) { + await ctx.answerCallbackQuery(); + return; + } + + if (data.startsWith(CallbackQueryType.Deck)) { + const deckId = Number(data.split(":")[1]); + if (!deckId) { + throw new Error(`Deck id ${deckId} is not valid`); + } + const state = await userGetServerBotState(envSafe, ctx.from.id); + assert(state?.type === "cardAdded", "State is not cardAdded"); + await userSetServerBotState(envSafe, ctx.from.id, { + type: "deckSelected", + cardBack: state.cardBack, + cardFront: state.cardFront, + deckId, + }); + + await sendCardCreateConfirmMessage(envSafe, ctx); + await ctx.answerCallbackQuery(); + return; + } + + if ( + data === CallbackQueryType.EditFront || + data === CallbackQueryType.EditBack + ) { + const isFront = data === CallbackQueryType.EditFront; + const state = await userGetServerBotState(envSafe, ctx.from.id); + assert(state?.type === "deckSelected", "State is not deckSelected"); + await userSetServerBotState(envSafe, ctx.from.id, { + ...state, + editingField: isFront ? "cardFront" : "cardBack", + }); + await ctx.deleteMessage(); + await ctx.reply( + `Send a message with the new ${isFront ? "front" : "back"}:`, + ); + return; + } + + if (data === CallbackQueryType.Cancel) { + await ctx.answerCallbackQuery("Cancelled"); + await ctx.deleteMessage(); + await userSetServerBotState(envSafe, ctx.from.id, null); + return; + } + + if (data === CallbackQueryType.ConfirmCreateCard) { + const state = await userGetServerBotState(envSafe, ctx.from.id); + assert(state?.type === "deckSelected", "State is not deckSelected"); + + const createCardsResult = await db.from("deck_card").insert({ + deck_id: state.deckId, + front: state.cardFront, + back: state.cardBack, + }); + + if (createCardsResult.error) { + throw new DatabaseException(createCardsResult.error); + } + + await ctx.reply('Card has been created. Click "MemoCard" to review it 👇'); + await ctx.deleteMessage(); + await userSetServerBotState(envSafe, ctx.from.id, null); + return; + } + + console.log("Unknown button event with payload", data); +}; diff --git a/functions/server-bot/on-message.ts b/functions/server-bot/on-message.ts new file mode 100644 index 00000000..d2e4b72d --- /dev/null +++ b/functions/server-bot/on-message.ts @@ -0,0 +1,63 @@ +import { EnvSafe } from "../env/env-schema.ts"; +import { Context, InlineKeyboard } from "grammy"; +import { assert } from "../lib/typescript/assert.ts"; +import { + userGetServerBotState, + userSetServerBotState, +} from "../db/user/user-set-server-bot-state.ts"; +import { sendCardCreateConfirmMessage } from "./send-card-create-confirm-message.ts"; +import { parseDeckFromText } from "./parse-deck-from-text.ts"; +import { getDecksCreatedByMe } from "../db/deck/get-decks-created-by-me.ts"; +import { CallbackQueryType } from "./callback-query-type.ts"; + +export const onMessage = (envSafe: EnvSafe) => async (ctx: Context) => { + if (!ctx.message?.text) { + return; + } + assert(ctx.from); + + const userState = await userGetServerBotState(envSafe, ctx.from.id); + if (userState?.type === "deckSelected" && userState.editingField) { + await userSetServerBotState(envSafe, ctx.from.id, { + ...userState, + [userState.editingField]: ctx.message.text, + editingField: undefined, + }); + + await sendCardCreateConfirmMessage(envSafe, ctx); + + return; + } + + const cardAsText = parseDeckFromText(ctx.message.text); + if (!cardAsText) { + await ctx.reply( + "Please send a message in the format: `front \\- back`\n\n*Example:*\nMe gusta \\- I like it", + { + parse_mode: "MarkdownV2", + }, + ); + return; + } + const [decks] = await Promise.all([ + getDecksCreatedByMe(envSafe, ctx.from.id), + userSetServerBotState(envSafe, ctx.from.id, { + type: "cardAdded", + cardFront: cardAsText.front, + cardBack: cardAsText.back, + }), + ]); + + await ctx.reply("To create a card from it, select a deck: ", { + reply_markup: InlineKeyboard.from( + decks + .map((deck) => [ + InlineKeyboard.text( + deck.name, + `${CallbackQueryType.Deck}:${deck.id}`, + ), + ]) + .concat([[InlineKeyboard.text("❌ Cancel", CallbackQueryType.Cancel)]]), + ), + }); +}; diff --git a/functions/server-bot/on-start.ts b/functions/server-bot/on-start.ts new file mode 100644 index 00000000..3b2b03da --- /dev/null +++ b/functions/server-bot/on-start.ts @@ -0,0 +1,7 @@ +import { Context } from "grammy"; + +export const onStart = (ctx: Context) => { + return ctx.reply( + `Improve your memory with spaced repetition. Learn languages, history or other subjects with the proven flashcard method. Click "MemoCard" 👇`, + ); +}; diff --git a/functions/server-bot/parse-deck-from-text.ts b/functions/server-bot/parse-deck-from-text.ts new file mode 100644 index 00000000..c0be0006 --- /dev/null +++ b/functions/server-bot/parse-deck-from-text.ts @@ -0,0 +1,12 @@ +export const parseDeckFromText = ( + text: string, +): { + front: string; + back: string; +} | null => { + const [front, back] = text.split(" - "); + if (!front || !back) { + return null; + } + return { front, back }; +}; diff --git a/functions/server-bot/send-card-create-confirm-message.ts b/functions/server-bot/send-card-create-confirm-message.ts new file mode 100644 index 00000000..274d006a --- /dev/null +++ b/functions/server-bot/send-card-create-confirm-message.ts @@ -0,0 +1,46 @@ +import { EnvSafe } from "../env/env-schema.ts"; +import { Context, InlineKeyboard } from "grammy"; +import { assert } from "../lib/typescript/assert.ts"; +import { + userGetServerBotState, + userSetServerBotState, +} from "../db/user/user-set-server-bot-state.ts"; +import { CallbackQueryType } from "./callback-query-type.ts"; + +export const sendCardCreateConfirmMessage = async ( + envSafe: EnvSafe, + ctx: Context, +) => { + assert(ctx.from); + const state = await userGetServerBotState(envSafe, ctx.from.id); + assert(state?.type === "deckSelected"); + + await userSetServerBotState(envSafe, ctx.from.id, { + type: "deckSelected", + cardBack: state.cardBack, + cardFront: state.cardFront, + deckId: state.deckId, + }); + + await ctx.deleteMessage(); + + await ctx.reply( + `Confirm card creation:\n\n*Front:*\n${state.cardFront}\n\n*Back:*\n${state.cardBack}`, + { + parse_mode: "MarkdownV2", + reply_markup: InlineKeyboard.from([ + [ + InlineKeyboard.text( + `✅ Confirm`, + CallbackQueryType.ConfirmCreateCard, + ), + ], + [ + InlineKeyboard.text(`✏️ Edit front`, CallbackQueryType.EditFront), + InlineKeyboard.text(`✏️ Edit back`, CallbackQueryType.EditBack), + ], + [InlineKeyboard.text(`❌ Cancel`, CallbackQueryType.Cancel)], + ]), + }, + ); +};