Skip to content

Commit

Permalink
Allow quick adding card via Telegram messenger input
Browse files Browse the repository at this point in the history
  • Loading branch information
kubk committed Dec 19, 2023
1 parent 051d4e7 commit 5bb1ba4
Show file tree
Hide file tree
Showing 13 changed files with 332 additions and 8 deletions.
11 changes: 6 additions & 5 deletions functions/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions functions/db/databaseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: []
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions functions/db/deck/decks-with-cards-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const deckWithCardsSchema = deckSchema.merge(

export const decksWithCardsSchema = z.array(deckWithCardsSchema);

export type DeckWithoutCardsDbType = z.infer<typeof deckSchema>;
export type DeckWithCardsDbType = z.infer<typeof deckWithCardsSchema>;
export type DeckCardDbType = z.infer<typeof deckCardSchema>;
export type DeckSpeakFieldEnum = z.infer<typeof deckSpeakField>;
25 changes: 25 additions & 0 deletions functions/db/deck/get-decks-created-by-me.ts
Original file line number Diff line number Diff line change
@@ -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<DeckWithoutCardsDbType[]> => {
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);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeckWithCardsDbType[]> => {
Expand Down
52 changes: 52 additions & 0 deletions functions/db/user/user-set-server-bot-state.ts
Original file line number Diff line number Diff line change
@@ -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<ServerBotState> => {
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;
};
4 changes: 2 additions & 2 deletions functions/my-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
]);

Expand Down
7 changes: 7 additions & 0 deletions functions/server-bot/callback-query-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export enum CallbackQueryType {
Deck = "deck",
ConfirmCreateCard = "confirm",
EditFront = "edit-front",
EditBack = "edit-back",
Cancel = "cancel",
}
89 changes: 89 additions & 0 deletions functions/server-bot/on-callback-query.ts
Original file line number Diff line number Diff line change
@@ -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);
};
63 changes: 63 additions & 0 deletions functions/server-bot/on-message.ts
Original file line number Diff line number Diff line change
@@ -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)]]),
),
});
};
7 changes: 7 additions & 0 deletions functions/server-bot/on-start.ts
Original file line number Diff line number Diff line change
@@ -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" 👇`,
);
};
12 changes: 12 additions & 0 deletions functions/server-bot/parse-deck-from-text.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
Loading

0 comments on commit 5bb1ba4

Please sign in to comment.