Skip to content

Commit

Permalink
API translation
Browse files Browse the repository at this point in the history
  • Loading branch information
kubk committed Dec 23, 2023
1 parent 1b1fd3e commit 638744d
Show file tree
Hide file tree
Showing 10 changed files with 241 additions and 32 deletions.
2 changes: 1 addition & 1 deletion functions/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const onRequestPost: PagesFunction = handleError(

const bot = new Bot(envSafe.BOT_TOKEN);
bot.use(ignoreOldMessageMiddleware);
bot.command("start", onStart);
bot.command("start", onStart(envSafe));
bot.on("message", onMessage(envSafe));
bot.on("callback_query:data", onCallbackQuery(envSafe));

Expand Down
1 change: 1 addition & 0 deletions functions/env/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const envSchema = z.object({
SUPABASE_URL: z.string(),
BOT_ERROR_REPORTING_TOKEN: z.string().optional(),
BOT_ERROR_REPORTING_USER_ID: z.string().optional(),
BOT_APP_URL_PLAIN: z.string(),
});

export type EnvSafe = z.infer<typeof envSchema>;
30 changes: 30 additions & 0 deletions functions/lib/translator/translator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import { Translator } from "./translator";

test("Translator - allows to set and retrieve current lang", () => {
type Translation = {
hello: string;
};
const en: Translation = {
hello: "Hello!",
};
const ru: Translation = {
hello: "Привет",
};

const translations = { en, ru };
type Language = keyof typeof translations;

const ts = new Translator<Language, Translation>(translations, "en");

expect(ts.getLang()).toEqual("en");
expect(ts.translate("hello")).toBe("Hello!");

ts.setLang("ru");

expect(ts.getLang()).toEqual("ru");
expect(ts.translate("hello")).toBe("Привет");

expect(ts.isSupported("en")).toBe(true);
expect(ts.isSupported("asdf")).toBe(false);
});
31 changes: 31 additions & 0 deletions functions/lib/translator/translator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type Storage<Language extends string, Resource> = {
[key in Language]: Resource;
};

type DefaultResource = { [key in string]: string };

export class Translator<
Language extends string,
Translation extends DefaultResource,
> {
constructor(
private storage: Storage<Language, Translation>,
private lang: Language,
) {}

setLang(lang: Language) {
this.lang = lang;
}

getLang() {
return this.lang;
}

translate(key: keyof Translation, defaultValue?: string): string {
return this.storage[this.lang][key] ?? defaultValue;
}

isSupported(lang: string): lang is Language {
return lang in this.storage;
}
}
23 changes: 15 additions & 8 deletions functions/server-bot/on-callback-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} 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";
import { createUserAwareTranslator } from "../translations/create-user-aware-translator.ts";
import { MemoCardTranslator } from "../translations/create-translator.ts";

type CallbackQueryEdit =
| CallbackQueryType.EditFront
Expand All @@ -28,14 +30,17 @@ const callbackQueryEditTypeToField = (data: CallbackQueryEdit) => {
}
};

const callbackQueryToHumanReadable = (data: CallbackQueryEdit) => {
const callbackQueryToHumanReadable = (
data: CallbackQueryEdit,
translator: MemoCardTranslator,
) => {
switch (data) {
case CallbackQueryType.EditFront:
return "front";
return translator.translate("send_new_front");
case CallbackQueryType.EditBack:
return "back";
return translator.translate("send_new_back");
case CallbackQueryType.EditExample:
return "example";
return translator.translate("send_new_example");
default:
return data satisfies never;
}
Expand All @@ -52,6 +57,8 @@ export const onCallbackQuery = (envSafe: EnvSafe) => async (ctx: Context) => {
return;
}

const translator = await createUserAwareTranslator(envSafe, ctx);

if (data.startsWith(CallbackQueryType.Deck)) {
const deckId = Number(data.split(":")[1]);
if (!deckId) {
Expand Down Expand Up @@ -82,18 +89,18 @@ export const onCallbackQuery = (envSafe: EnvSafe) => async (ctx: Context) => {
const state = await userGetServerBotState(envSafe, ctx.from.id);
assert(state?.type === "deckSelected", "State is not deckSelected");
const editingField = callbackQueryEditTypeToField(data);
const editingFieldHuman = callbackQueryToHumanReadable(data);
const editingFieldHuman = callbackQueryToHumanReadable(data, translator);
await userSetServerBotState(envSafe, ctx.from.id, {
...state,
editingField,
});
await ctx.deleteMessage();
await ctx.reply(`Send a message with the new ${editingFieldHuman}:`);
await ctx.reply(editingFieldHuman);
return;
}

if (data === CallbackQueryType.Cancel) {
await ctx.answerCallbackQuery("Cancelled");
await ctx.answerCallbackQuery(translator.translate("cancelled"));
await ctx.deleteMessage();
await userSetServerBotState(envSafe, ctx.from.id, null);
return;
Expand All @@ -114,7 +121,7 @@ export const onCallbackQuery = (envSafe: EnvSafe) => async (ctx: Context) => {
throw new DatabaseException(createCardsResult.error);
}

await ctx.reply("Card has been created");
await ctx.reply(translator.translate("card_created"));
await ctx.deleteMessage();
await userSetServerBotState(envSafe, ctx.from.id, null);
return;
Expand Down
31 changes: 20 additions & 11 deletions functions/server-bot/on-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { sendCardCreateConfirmMessage } from "./send-card-create-confirm-message
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";
import { createUserAwareTranslator } from "../translations/create-user-aware-translator.ts";

export const onMessage = (envSafe: EnvSafe) => async (ctx: Context) => {
if (!ctx.message?.text) {
Expand All @@ -17,6 +18,7 @@ export const onMessage = (envSafe: EnvSafe) => async (ctx: Context) => {
assert(ctx.from);

await ctx.replyWithChatAction("typing");
const translator = await createUserAwareTranslator(envSafe, ctx);

const userState = await userGetServerBotState(envSafe, ctx.from.id);
if (userState?.type === "deckSelected" && userState.editingField) {
Expand All @@ -33,20 +35,20 @@ export const onMessage = (envSafe: EnvSafe) => async (ctx: Context) => {

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",
},
);
await ctx.reply(translator.translate("invalid_card_format"), {
parse_mode: "MarkdownV2",
});
return;
}

const decks = await getDecksCreatedByMe(envSafe, ctx.from.id);
if (decks.length === 0) {
await ctx.reply(
`You don't have any personal decks yet. Create one in the app first 👇`,
);
await ctx.reply(translator.translate("no_decks_created"), {
reply_markup: new InlineKeyboard().url(
translator.translate("create_deck"),
envSafe.BOT_APP_URL_PLAIN,
),
});
return;
}

Expand All @@ -56,7 +58,7 @@ export const onMessage = (envSafe: EnvSafe) => async (ctx: Context) => {
cardBack: cardAsText.back,
});

await ctx.reply("To create a card from it, select a deck: ", {
await ctx.reply(translator.translate("create_card_from_deck_message"), {
reply_markup: InlineKeyboard.from(
decks
.map((deck) => [
Expand All @@ -65,7 +67,14 @@ export const onMessage = (envSafe: EnvSafe) => async (ctx: Context) => {
`${CallbackQueryType.Deck}:${deck.id}`,
),
])
.concat([[InlineKeyboard.text("❌ Cancel", CallbackQueryType.Cancel)]]),
.concat([
[
InlineKeyboard.text(
translator.translate("bot_button_cancel"),
CallbackQueryType.Cancel,
),
],
]),
),
});
};
11 changes: 7 additions & 4 deletions functions/server-bot/on-start.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Context } from "grammy";
import { EnvSafe } from "../env/env-schema.ts";
import { createUserAwareTranslator } from "../translations/create-user-aware-translator.ts";

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.\n\nClick "MemoCard" 👇`,
);
export const onStart = (envSafe: EnvSafe) => async (ctx: Context) => {
await ctx.replyWithChatAction("typing");
const translator = await createUserAwareTranslator(envSafe, ctx);

return ctx.reply(translator.translate("start"));
};
32 changes: 24 additions & 8 deletions functions/server-bot/send-card-create-confirm-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../db/user/user-set-server-bot-state.ts";
import { CallbackQueryType } from "./callback-query-type.ts";
import { escapeMarkdown } from "./escape-markdown.ts";
import { createUserAwareTranslator } from "../translations/create-user-aware-translator.ts";

const renderFieldValue = (value: string | null) => {
if (!value) {
Expand All @@ -21,6 +22,7 @@ export const sendCardCreateConfirmMessage = async (
ctx: Context,
) => {
assert(ctx.from);
const translator = await createUserAwareTranslator(envSafe, ctx);
const state = await userGetServerBotState(envSafe, ctx.from.id);
assert(state?.type === "deckSelected");

Expand All @@ -35,23 +37,37 @@ export const sendCardCreateConfirmMessage = async (
await ctx.deleteMessage();

await ctx.reply(
`Confirm card creation:\n\n*Front:* ${renderFieldValue(
`${translator.translate("confirm_card_creation_front")}${renderFieldValue(
state.cardFront,
)}\n\n*Back:* ${renderFieldValue(
)}${translator.translate("confirm_card_creation_back")}${renderFieldValue(
state.cardBack,
)}\n\n*Example:* ${renderFieldValue(state.cardExample)}`,
)}${translator.translate(
"confirm_card_creation_example",
)}${renderFieldValue(state.cardExample)}`,
{
parse_mode: "MarkdownV2",
reply_markup: InlineKeyboard.from([
[
InlineKeyboard.text(`✏️ Edit front`, CallbackQueryType.EditFront),
InlineKeyboard.text(`✏️ Edit back`, CallbackQueryType.EditBack),
InlineKeyboard.text(`✏️ Edit example`, CallbackQueryType.EditExample),
InlineKeyboard.text(
translator.translate("bot_button_edit_front"),
CallbackQueryType.EditFront,
),
InlineKeyboard.text(
translator.translate("bot_button_edit_back"),
CallbackQueryType.EditBack,
),
InlineKeyboard.text(
translator.translate("bot_button_edit_example"),
CallbackQueryType.EditExample,
),
],
[
InlineKeyboard.text(`❌ Cancel`, CallbackQueryType.Cancel),
InlineKeyboard.text(
`✅ Confirm`,
translator.translate("bot_button_cancel"),
CallbackQueryType.Cancel,
),
InlineKeyboard.text(
translator.translate("bot_button_confirm"),
CallbackQueryType.ConfirmCreateCard,
),
],
Expand Down
82 changes: 82 additions & 0 deletions functions/translations/create-translator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Translator } from "../lib/translator/translator.ts";

const en = {
start: `Hello! I help improving memory with spaced repetition. You can learn languages, history or other subjects.\n\nClick "MemoCard" to start 👇`,
invalid_card_format:
"Please send a message in the format: `front \\- back`\n\n*Example:*\nMe gusta \\- I like it",
no_decks_created: `You don't have any personal decks yet. Create one in the app first 👇`,
create_deck: "Create deck",
create_card_from_deck_message:
"To create a card from the text, select a deck: ",
bot_button_cancel: "❌ Cancel",
bot_button_confirm: "✅ Create",
bot_button_edit_front: `✏️ Edit front`,
bot_button_edit_back: `✏️ Edit back`,
bot_button_edit_example: `✏️ Edit example`,
cancelled: "Cancelled",
card_created: "Card has been created",
send_new_front: "Send a message with the new front",
send_new_back: "Send a message with the new back",
send_new_example: "Send a message with the new example",
confirm_card_creation_front: `Create card?\n\n*Front:* `,
confirm_card_creation_back: `\n\n*Back:* `,
confirm_card_creation_example: `\n\n*Example:* `,
};

type Translation = typeof en;

const ru: Translation = {
start: `Привет! Я помогаю улучшать память с помощью интервального повторения. Подхожу для изучения языки, истории и других предметов\n\nНажимай "MemoCard" для запуска 👇`,
invalid_card_format:
"Пожалуйста, отправь сообщение в формате: `вопрос \\- ответ`\n\n*Пример:*\nMe gusta \\- Мне нравится",
no_decks_created: `У тебя ещё нет личных колод. Создай колоду в приложении 👇`,
create_deck: "Создать колоду",
create_card_from_deck_message:
"Чтобы создать карточку из этого текста, выбери колоду: ",
bot_button_cancel: "❌ Отмена",
cancelled: "Отменено",
card_created: "Карточка создана",
send_new_front: "Отправь сообщение с новым вопросом",
send_new_back: "Отправь сообщение с новым ответом",
send_new_example: "Отправь сообщение с новым примером",
bot_button_confirm: "✅ Создать",
bot_button_edit_back: `✏️ Изменить ответ`,
bot_button_edit_example: `✏️ Изменить пример`,
bot_button_edit_front: `✏️ Изменить вопрос`,
confirm_card_creation_back: `\n\n*Ответ:* `,
confirm_card_creation_example: `\n\n*Пример:* `,
confirm_card_creation_front: `Создать карточку?:\n\n*Вопрос:* `,
};

const es: Translation = {
start:
'Hola! Te ayudo a mejorar la memoria con la repetición espaciada. Puedes aprender idiomas, historia u otras materias.\n\nHaz clic en "MemoCard" para comenzar 👇',
invalid_card_format:
"Envíe un mensaje en el formato: `pregunta \\- respuesta`\n\n*Ejemplo:*\nI like it \\- Me gusta",
no_decks_created: `Todavía no tienes mazos personales. Crea uno en la aplicación 👇`,
create_deck: "Crear mazo",
create_card_from_deck_message:
"Para crear una tarjeta a partir de este texto, seleccione un mazo: ",
bot_button_cancel: "❌ Cancelar",
cancelled: "Cancelado",
card_created: "La tarjeta ha sido creada",
send_new_front: "Enviar un mensaje con la nueva pregunta",
send_new_back: "Enviar un mensaje con la nueva respuesta",
send_new_example: "Enviar un mensaje con el nuevo ejemplo",
confirm_card_creation_back: `\n\n*Respuesta:* `,
bot_button_edit_front: `✏️ Editar pregunta`,
bot_button_edit_example: `✏️ Editar ejemplo`,
bot_button_edit_back: `✏️ Editar respuesta`,
bot_button_confirm: "✅ Crear",
confirm_card_creation_example: `\n\n*Ejemplo:* `,
confirm_card_creation_front: `Crear tarjeta?:\n\n*Pregunta:* `,
};

const translations = { en, ru, es } as const;
export type Language = keyof typeof translations;

export const createTranslator = (lang: Language) => {
return new Translator(translations, lang);
};

export type MemoCardTranslator = Translator<Language, any>;
Loading

0 comments on commit 638744d

Please sign in to comment.