diff --git a/deno.jsonc b/deno.jsonc index 705e4c5..80c59e6 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -63,6 +63,7 @@ "generate:index:telegram-bot": "cd telegram-bot && deno task generate:index", "generate:index:beabee-client": "cd beabee-client && deno task generate:index", "generate:index:beabee-common": "cd beabee-common && deno task generate:index", + "i18n": "cd telegram-bot && deno task i18n", "docker:build": "docker build -t beabee/telegram-bot:latest .docker p", "docker:start": "docker run -it --init -p 3003:3003 beabee/telegram-bot:latest", "docker:push": "docker push beabee/telegram-bot:latest" diff --git a/deno.lock b/deno.lock index f1d2393..48af233 100644 --- a/deno.lock +++ b/deno.lock @@ -1,5 +1,22 @@ { "version": "3", + "packages": { + "specifiers": { + "npm:valtio@2.0.0-beta.1": "npm:valtio@2.0.0-beta.1" + }, + "npm": { + "proxy-compare@2.6.0": { + "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==", + "dependencies": {} + }, + "valtio@2.0.0-beta.1": { + "integrity": "sha512-VarsAUZ3qxZNS9BhC1oeyCZX0vZE4SWuOqyKiXVNy9cbq9OrP+8RU5a59KVV7P0dkRsIRF9RwKL4Jrk4feQWBA==", + "dependencies": { + "proxy-compare": "proxy-compare@2.6.0" + } + } + } + }, "redirects": { "https://lib.deno.dev/x/grammy@^1.20/mod.ts": "https://deno.land/x/grammy@v1.21.1/mod.ts" }, diff --git a/telegram-bot/.env.example b/telegram-bot/.env.example index 004932b..4596a56 100644 --- a/telegram-bot/.env.example +++ b/telegram-bot/.env.example @@ -7,6 +7,9 @@ TELEGRAM_BOT_DB_PATH="./data/database.sqlite" # If tables should be dropped if they exist. TELEGRAM_BOT_DB_DROP=false +# The environment the bot is running in. +TELEGRAM_BOT_ENVIRONMENT=development + DEBUG="grammy*" # The secret for internal service communication, to generate a secure secret you can use https://www.uuidgenerator.net/version4 diff --git a/telegram-bot/README.md b/telegram-bot/README.md index af69289..362ec36 100644 --- a/telegram-bot/README.md +++ b/telegram-bot/README.md @@ -107,6 +107,9 @@ parts of the application: event manger. - `data`: Contains data which can be persisted in docker volumes such the `database.sql`. +- `deps`: In Deno it is common to import all dependencies in a central deps.ts + file, we want to follow this but due to many dependencies we have split them + cleanly in this folder. - `renderer`: Includes classes for rendering Markdown texts, including `CalloutRenderer`, `MessageRenderer`, `CalloutResponseRenderer` and more. - `enums`: Contains enums used throughout the application. @@ -115,6 +118,7 @@ parts of the application: events triggered throughout the application. The organization of this folder allows for a clear and efficient handling of the event-driven aspects of the application. +- `locales`: Contains the locale data for the application. - `models`: Contains classes representing various models used throughout the application using `typeorm`. - `renderer`: Contains classes for rendering Markdown texts, including diff --git a/telegram-bot/areas/core.area.ts b/telegram-bot/areas/core.area.ts index 786e146..ccffe5a 100644 --- a/telegram-bot/areas/core.area.ts +++ b/telegram-bot/areas/core.area.ts @@ -1,4 +1,4 @@ -import { Area } from "alosaur/mod.ts"; +import { Area } from "../deps/index.ts"; import * as Controllers from "../controllers/index.ts"; // Declare module diff --git a/telegram-bot/commands/debug.command.ts b/telegram-bot/commands/debug.command.ts new file mode 100644 index 0000000..6ef220b --- /dev/null +++ b/telegram-bot/commands/debug.command.ts @@ -0,0 +1,40 @@ +import { Singleton } from "../deps/index.ts"; +import { BaseCommand } from "../core/index.ts"; +import { I18nService } from "../services/i18n.service.ts"; +import { CommunicationService } from "../services/communication.service.ts"; +import { MessageRenderer } from "../renderer/message.renderer.ts"; +import { ChatState } from "../enums/index.ts"; + +import type { AppContext } from "../types/index.ts"; + +const IS_DEV = Deno.env.get("TELEGRAM_BOT_ENVIRONMENT") === "development"; + +@Singleton() +export class DebugCommand extends BaseCommand { + /** `/debug` */ + command = "debug"; + + // Only visible in development + visibleOnStates: ChatState[] = IS_DEV ? [] : [ChatState.None]; + + constructor( + protected readonly i18n: I18nService, + protected readonly communication: CommunicationService, + protected readonly messageRenderer: MessageRenderer, + ) { + super(); + } + + // Handle the /debug command + async action(ctx: AppContext) { + const successful = await this.checkAction(ctx); + if (!successful) { + return false; + } + await this.communication.send( + ctx, + await this.messageRenderer.debug(ctx), + ); + return successful; + } +} diff --git a/telegram-bot/commands/help.command.ts b/telegram-bot/commands/help.command.ts new file mode 100644 index 0000000..eebdf8d --- /dev/null +++ b/telegram-bot/commands/help.command.ts @@ -0,0 +1,41 @@ +import { Singleton } from "../deps/index.ts"; +import { BaseCommand } from "../core/index.ts"; +import { I18nService } from "../services/i18n.service.ts"; +import { CommunicationService } from "../services/communication.service.ts"; +import { MessageRenderer } from "../renderer/message.renderer.ts"; +import { ChatState } from "../enums/index.ts"; + +import type { AppContext } from "../types/index.ts"; + +@Singleton() +export class HelpCommand extends BaseCommand { + /** `/help` */ + command = "help"; + + visibleOnStates: ChatState[] = []; // Visible on all states + + constructor( + protected readonly i18n: I18nService, + protected readonly communication: CommunicationService, + protected readonly messageRenderer: MessageRenderer, + ) { + super(); + } + + // Handle the /help command + async action(ctx: AppContext) { + const successful = await this.checkAction(ctx); + if (!successful) { + return false; + } + // Use session.state to get context related help + const session = await ctx.session; + + await this.communication.send( + ctx, + await this.messageRenderer.help(session.state), + ); + + return successful; + } +} diff --git a/telegram-bot/commands/index.ts b/telegram-bot/commands/index.ts index afadc85..7e542ba 100644 --- a/telegram-bot/commands/index.ts +++ b/telegram-bot/commands/index.ts @@ -1,4 +1,7 @@ +export * from "./debug.command.ts"; +export * from "./help.command.ts"; export * from "./list.command.ts"; +export * from "./reset.command.ts"; export * from "./show.command.ts"; export * from "./start.command.ts"; export * from "./subscribe.command.ts"; diff --git a/telegram-bot/commands/list.command.ts b/telegram-bot/commands/list.command.ts index 44275d2..a0a1b09 100644 --- a/telegram-bot/commands/list.command.ts +++ b/telegram-bot/commands/list.command.ts @@ -1,40 +1,51 @@ -import { Context, Singleton } from "../deps.ts"; -import { Command } from "../core/command.ts"; +import { Singleton } from "../deps/index.ts"; +import { BaseCommand } from "../core/base.command.ts"; import { CalloutService } from "../services/callout.service.ts"; import { CommunicationService } from "../services/communication.service.ts"; import { KeyboardService } from "../services/keyboard.service.ts"; import { I18nService } from "../services/i18n.service.ts"; -import { CalloutRenderer } from "../renderer/index.ts"; +import { StateMachineService } from "../services/state-machine.service.ts"; +import { CalloutRenderer, MessageRenderer } from "../renderer/index.ts"; +import { ChatState } from "../enums/index.ts"; -import { UserState } from "../types/index.ts"; +import type { AppContext } from "../types/index.ts"; @Singleton() -export class ListCommand extends Command { - key = "list"; +export class ListCommand extends BaseCommand { /** /list */ command = "list"; - /** - * List active Callouts - * (Description is set in CommandService with a translation) - */ - description = ""; - visibleOnStates: UserState[] = ["start"]; + visibleOnStates: ChatState[] = [ChatState.Start, ChatState.CalloutAnswered]; constructor( protected readonly callout: CalloutService, protected readonly communication: CommunicationService, protected readonly keyboard: KeyboardService, + protected readonly messageRenderer: MessageRenderer, protected readonly calloutRenderer: CalloutRenderer, protected readonly i18n: I18nService, + protected readonly stateMachine: StateMachineService, ) { super(); } // Handle the /list command - public async action(ctx: Context) { + public async action(ctx: AppContext) { + const session = await ctx.session; + const successful = await this.checkAction(ctx); + if (!successful) { + return false; + } + + const signal = this.stateMachine.setSessionState( + session, + ChatState.CalloutList, + true, + ); + const callouts = await this.callout.list(); - const res = this.calloutRenderer.listItems(callouts); - await this.communication.sendAndReceiveAll(ctx, res); + const render = this.calloutRenderer.listItems(callouts); + await this.communication.sendAndReceiveAll(ctx, render, signal); + return successful; } } diff --git a/telegram-bot/commands/reset.command.ts b/telegram-bot/commands/reset.command.ts new file mode 100644 index 0000000..3bc497b --- /dev/null +++ b/telegram-bot/commands/reset.command.ts @@ -0,0 +1,72 @@ +import { Singleton } from "../deps/index.ts"; +import { BaseCommand } from "../core/index.ts"; +import { I18nService } from "../services/i18n.service.ts"; +import { CommunicationService } from "../services/communication.service.ts"; +import { StateMachineService } from "../services/state-machine.service.ts"; +import { MessageRenderer } from "../renderer/message.renderer.ts"; +import { ChatState } from "../enums/index.ts"; + +import type { AppContext } from "../types/index.ts"; + +@Singleton() +export class ResetCommand extends BaseCommand { + /** `/reset` */ + command = "reset"; + + // TODO: Disable this command on production + visibleOnStates: ChatState[] = [ + ChatState.Start, + ChatState.CalloutAnswer, + ChatState.CalloutAnswered, + ChatState.CalloutDetails, + ChatState.CalloutList, + ]; + + constructor( + protected readonly i18n: I18nService, + protected readonly communication: CommunicationService, + protected readonly messageRenderer: MessageRenderer, + protected readonly stateMachine: StateMachineService, + ) { + super(); + } + + // Handle the /reset command + async action(ctx: AppContext) { + // Always allow this command to reset the state even if an error occurs, so we not use `this.checkAction` here + const session = await ctx.session; + const abortController = session._data.abortController; + + if (abortController) { + // Already cancelled + if (abortController.signal.aborted) { + await this.communication.send( + ctx, + this.messageRenderer.resetCancelledMessage(), + ); + } else { + // Successful cancellation + await this.communication.send( + ctx, + this.messageRenderer.resetSuccessfulMessage(), + ); + } + } else { + // Nothing to cancel + await this.communication.send( + ctx, + this.messageRenderer.resetUnsuccessfulMessage(), + ); + } + + const successful = this.stateMachine.resetSessionState(session); + + // Use this after the reset to show the right help message for the current state + await this.communication.send( + ctx, + await this.messageRenderer.continueHelp(session.state), + ); + + return successful; + } +} diff --git a/telegram-bot/commands/show.command.ts b/telegram-bot/commands/show.command.ts index 923a6a1..8260fcc 100644 --- a/telegram-bot/commands/show.command.ts +++ b/telegram-bot/commands/show.command.ts @@ -1,28 +1,25 @@ -import { ClientApiError, Context, Singleton } from "../deps.ts"; +import { ClientApiError, Singleton } from "../deps/index.ts"; import { CalloutService } from "../services/callout.service.ts"; import { CommunicationService } from "../services/communication.service.ts"; import { KeyboardService } from "../services/keyboard.service.ts"; import { I18nService } from "../services/i18n.service.ts"; +import { StateMachineService } from "../services/state-machine.service.ts"; import { CalloutRenderer, CalloutResponseRenderer, MessageRenderer, } from "../renderer/index.ts"; +import { ChatState } from "../enums/index.ts"; -import { Command } from "../core/index.ts"; -import type { UserState } from "../types/user-state.ts"; +import { BaseCommand } from "../core/index.ts"; +import type { AppContext } from "../types/index.ts"; @Singleton() -export class ShowCommand extends Command { - key = "show"; +export class ShowCommand extends BaseCommand { + /** `/show` */ command = "show"; - /** - * Shows you information about a specific callout - * (Description is set in CommandService with a translation) - */ - description = ""; - visibleOnStates: UserState[] = []; // Only for testing + visibleOnStates: ChatState[] = [ChatState.None]; // TODO: Make this for admins visible constructor( protected readonly callout: CalloutService, @@ -32,34 +29,50 @@ export class ShowCommand extends Command { protected readonly calloutRenderer: CalloutRenderer, protected readonly calloutResponseRenderer: CalloutResponseRenderer, protected readonly i18n: I18nService, + protected readonly stateMachine: StateMachineService, ) { super(); } // Handle the /show command - async action(ctx: Context) { - console.debug("Show command called"); + async action(ctx: AppContext) { + let successful = await this.checkAction(ctx); + if (!successful) { + return false; + } // Get the slug from the `/show slug` message text const slug = ctx.message?.text?.split(" ")[1]; if (!slug) { await ctx.reply("Please specify a callout slug. E.g. `/show my-callout`"); - return; + successful = false; + return successful; } try { + const session = await ctx.session; const callout = await this.callout.get(slug); - const res = await this.calloutRenderer.callout(callout); - await this.communication.sendAndReceiveAll(ctx, res); + const render = await this.calloutRenderer.calloutDetails(callout); + + const signal = this.stateMachine.setSessionState( + session, + ChatState.CalloutDetails, + true, + ); + + await this.communication.sendAndReceiveAll(ctx, render, signal); } catch (error) { console.error("Error sending callout", error); + successful = false; if (error instanceof ClientApiError && error.httpCode === 404) { await ctx.reply(`Callout with slug "${slug}" not found.`); - return; + return successful; } await ctx.reply(`Error sending callout slug "${slug}": ${error.message}`); - return; + return successful; } + + return successful; } } diff --git a/telegram-bot/commands/start.command.ts b/telegram-bot/commands/start.command.ts index 83baa68..2791d1d 100644 --- a/telegram-bot/commands/start.command.ts +++ b/telegram-bot/commands/start.command.ts @@ -1,34 +1,45 @@ -import { Context, Singleton } from "../deps.ts"; -import { Command } from "../core/index.ts"; +import { Singleton } from "../deps/index.ts"; +import { BaseCommand } from "../core/index.ts"; import { I18nService } from "../services/i18n.service.ts"; import { CommunicationService } from "../services/communication.service.ts"; +import { StateMachineService } from "../services/state-machine.service.ts"; import { MessageRenderer } from "../renderer/message.renderer.ts"; +import { ChatState } from "../enums/index.ts"; -import type { UserState } from "../types/user-state.ts"; +import type { AppContext } from "../types/index.ts"; @Singleton() -export class StartCommand extends Command { - key = "start"; +export class StartCommand extends BaseCommand { + /** `/start` */ command = "start"; - /** - * Start the bot - * (Description is set in CommandService with a translation) - */ - description = ""; - visibleOnStates: UserState[] = ["start"]; + visibleOnStates: ChatState[] = [ChatState.Initial]; constructor( protected readonly i18n: I18nService, protected readonly communication: CommunicationService, protected readonly messageRenderer: MessageRenderer, + protected readonly stateMachine: StateMachineService, ) { super(); } // Handle the /start command, replay with markdown formatted text: https://grammy.dev/guide/basics#sending-message-with-formatting - async action(ctx: Context) { + async action(ctx: AppContext): Promise { + const session = await ctx.session; + const successful = await this.checkAction(ctx); + if (!successful) { + return false; + } + + this.stateMachine.setSessionState(session, ChatState.Start, false); + await this.communication.send(ctx, this.messageRenderer.welcome()); - await this.communication.send(ctx, await this.messageRenderer.intro()); + await this.communication.send( + ctx, + await this.messageRenderer.help(session.state), + ); + + return successful; } } diff --git a/telegram-bot/commands/subscribe.command.ts b/telegram-bot/commands/subscribe.command.ts index 06faa54..3260f68 100644 --- a/telegram-bot/commands/subscribe.command.ts +++ b/telegram-bot/commands/subscribe.command.ts @@ -1,31 +1,37 @@ -import { Context, Singleton } from "../deps.ts"; +import { Singleton } from "../deps/index.ts"; import { SubscriberService } from "../services/subscriber.service.ts"; import { I18nService } from "../services/i18n.service.ts"; -import { Command } from "../core/index.ts"; +import { CommunicationService } from "../services/communication.service.ts"; +import { MessageRenderer } from "../renderer/message.renderer.ts"; +import { BaseCommand } from "../core/index.ts"; +import { ChatState } from "../enums/index.ts"; -import type { UserState } from "../types/user-state.ts"; +import type { AppContext } from "../types/index.ts"; @Singleton() -export class SubscribeCommand extends Command { - key = "subscribe"; +export class SubscribeCommand extends BaseCommand { + /** `/subscribe` */ command = "subscribe"; - /** - * Subscribe a Callout - * (Description is set in CommandService with a translation) - */ - description = ""; - visibleOnStates: UserState[] = []; // Only for testing + visibleOnStates: ChatState[] = [ChatState.None]; // TODO: Make this for admins visible constructor( protected readonly subscriber: SubscriberService, protected readonly i18n: I18nService, + protected readonly messageRenderer: MessageRenderer, + protected readonly communication: CommunicationService, ) { super(); } - async action(ctx: Context) { - this.subscriber.create(ctx); + async action(ctx: AppContext) { + const successful = await this.checkAction(ctx); + if (!successful) { + return false; + } + await this.subscriber.create(ctx); + // TODO: Translate await ctx.reply("You are now subscribed\!"); + return successful; } } diff --git a/telegram-bot/commands/unsubscribe.command.ts b/telegram-bot/commands/unsubscribe.command.ts index 9bcf145..9840348 100644 --- a/telegram-bot/commands/unsubscribe.command.ts +++ b/telegram-bot/commands/unsubscribe.command.ts @@ -1,31 +1,37 @@ -import { Context, Singleton } from "../deps.ts"; +import { Singleton } from "../deps/index.ts"; import { SubscriberService } from "../services/subscriber.service.ts"; import { I18nService } from "../services/i18n.service.ts"; -import { Command } from "../core/index.ts"; +import { CommunicationService } from "../services/communication.service.ts"; +import { MessageRenderer } from "../renderer/message.renderer.ts"; +import { BaseCommand } from "../core/index.ts"; +import { ChatState } from "../enums/index.ts"; -import type { UserState } from "../types/user-state.ts"; +import type { AppContext } from "../types/index.ts"; @Singleton() -export class UnsubscribeCommand extends Command { - key = "unsubscribe"; +export class UnsubscribeCommand extends BaseCommand { + /** `/unsubscribe` */ command = "unsubscribe"; - /** - * Unsubscribe from a Callout - * (Description is set in CommandService with a translation) - */ - description = ""; - visibleOnStates: UserState[] = []; // Only for testing + visibleOnStates: ChatState[] = [ChatState.None]; // TODO: Make this for admins visible constructor( protected readonly subscriber: SubscriberService, protected readonly i18n: I18nService, + protected readonly messageRenderer: MessageRenderer, + protected readonly communication: CommunicationService, ) { super(); } - async action(ctx: Context) { - this.subscriber.delete(ctx); + async action(ctx: AppContext) { + const successful = await this.checkAction(ctx); + if (!successful) { + return false; + } + await this.subscriber.delete(ctx); + // TODO: Translate await ctx.reply("You are now unsubscribed\!"); + return successful; } } diff --git a/telegram-bot/controllers/callout.controller.ts b/telegram-bot/controllers/callout.controller.ts index f3e8963..c371e1b 100644 --- a/telegram-bot/controllers/callout.controller.ts +++ b/telegram-bot/controllers/callout.controller.ts @@ -1,12 +1,15 @@ -import { Controller, Get } from "alosaur/mod.ts"; +import { Controller, Get } from "../deps/index.ts"; import { SubscriberService } from "../services/subscriber.service.ts"; -import { TelegramService } from "../services/telegram.service.ts"; +import { BotService } from "../services/bot.service.ts"; +/** + * Example controller that can be used in the browser creating GET requests for testing + */ @Controller("/callout") export class CalloutController { constructor( private readonly subscriber: SubscriberService, - private readonly telegram: TelegramService, + private readonly bot: BotService, ) { console.debug(`${this.constructor.name} created`); } @@ -21,7 +24,7 @@ export class CalloutController { if (all.length === 0) return "No subscribers yet!"; console.debug("Sending hello world message to all subscribers"); all.forEach((subscriber) => - this.telegram.bot.api.sendMessage(subscriber.id, "Hello world") + this.bot.api.sendMessage(subscriber.id, "Hello world") ); return "Hello world message to all subscribers sent!"; } diff --git a/telegram-bot/controllers/core.controller.ts b/telegram-bot/controllers/core.controller.ts index 702324b..3c04974 100644 --- a/telegram-bot/controllers/core.controller.ts +++ b/telegram-bot/controllers/core.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from "alosaur/mod.ts"; +import { Controller, Get } from "../deps/index.ts"; @Controller() export class CoreController { diff --git a/telegram-bot/controllers/i18n.controller.ts b/telegram-bot/controllers/i18n.controller.ts index a5cd12a..4fbd6b9 100644 --- a/telegram-bot/controllers/i18n.controller.ts +++ b/telegram-bot/controllers/i18n.controller.ts @@ -1,6 +1,9 @@ -import { Controller, Get, NotFoundError, Param } from "alosaur/mod.ts"; +import { Controller, Get, NotFoundError, Param } from "../deps/index.ts"; import { I18nService } from "../services/i18n.service.ts"; +/** + * Example controller that can be used in the browser creating GET requests for testing + */ @Controller("/i18n") export class I18nController { constructor( diff --git a/telegram-bot/core/base.command.ts b/telegram-bot/core/base.command.ts new file mode 100644 index 0000000..d9839c6 --- /dev/null +++ b/telegram-bot/core/base.command.ts @@ -0,0 +1,115 @@ +import { BotCommand, container } from "../deps/index.ts"; +import type { ChatState } from "../enums/index.ts"; +import type { I18nService } from "../services/i18n.service.ts"; +import type { CommunicationService } from "../services/communication.service.ts"; +import type { MessageRenderer } from "../renderer/message.renderer.ts"; +import type { AppContext, SessionState } from "../types/index.ts"; + +/** + * Base class for all bot commands + * Any command must extend this class + */ +export abstract class BaseCommand implements BotCommand { + /** + * Get a singleton instance of the command. + * This method makes use of the [dependency injection](https://alosaur.com/docs/basics/DI#custom-di-container) container to resolve the service. + * @param this + * @returns + */ + static getSingleton( + // deno-lint-ignore no-explicit-any + this: new (...args: any[]) => T, + ): T { + return container.resolve(this); + } + + /** + * The command name, without the leading slash. + * For example: "list" + */ + abstract command: string; + /** + * The command description, used in the /help command or in the Telegram command list. + * For example: "List active Callouts" + * This is a getter and returns the current translation of the description + */ + get description() { + return this.i18n.t( + `bot.commands.${this.command}.description`, + {}, + ); + } + + /** + * Define the states where the command is visible. Leave empty to make it visible in all states. + */ + abstract visibleOnStates: ChatState[]; + + /** + * The i18n service, used to translate the command name and description. + */ + protected abstract readonly i18n: I18nService; + + /** + * The message renderer, used to render the messages. + */ + protected abstract readonly messageRenderer: MessageRenderer; + + /** + * The communication service, used to send messages to the chat. + */ + protected abstract readonly communication: CommunicationService; + + constructor() { + console.debug(`${this.constructor.name} created`); + } + + /** + * Check if the command is usable in the current state + * @param session The current session + * @returns True if the command is usable, false otherwise + */ + public isCommandUsable(session: SessionState): boolean { + return this.visibleOnStates.length === 0 || + this.visibleOnStates.includes(session.state); + } + + /** + * Check if the command is usable in the current state. otherwise send an error message an return `false` + * @param ctx The context of the Telegram message that triggered the command. + * @returns True if the action can be executed, false otherwise + */ + protected async checkAction(ctx: AppContext): Promise { + const session = await ctx.session; + + if (!this.isCommandUsable(session)) { + this.communication.send( + ctx, + this.messageRenderer.commandNotUsable(this, session.state), + ); + + // TODO: send error message + return false; + } + + return true; + } + + /** + * The action that is executed when the command is called. + * @param ctx The context of the Telegram message that triggered the command. + * @returns True if the command was executed successfully, false otherwise. + */ + abstract action(ctx: AppContext): Promise; + + /** + * Called when the language changes. + * @param lang The new language code. + */ + onLocaleChange(lang: string) { + console.debug(`[${this.constructor.name}] Language changed to [${lang}]`, { + command: this.command, + description: this.description, + }); + } +} diff --git a/telegram-bot/core/base.events.ts b/telegram-bot/core/base.events.ts new file mode 100644 index 0000000..d643697 --- /dev/null +++ b/telegram-bot/core/base.events.ts @@ -0,0 +1,28 @@ +import { container } from "../deps/index.ts"; +import { EventService } from "../services/event.service.ts"; + +/** + * Base class for all event managers. + * Any event manager must extend this class. + */ +export abstract class BaseEventManager { + /** + * Get a singleton instance of the event manager. + * This method makes use of the [dependency injection](https://alosaur.com/docs/basics/DI#custom-di-container) container to resolve the service. + * @param this + * @returns An instance of the class that extends BaseEventManager. + */ + static getSingleton( + // deno-lint-ignore no-explicit-any + this: new (...args: any[]) => T, + ): T { + return container.resolve(this); + } + + protected abstract readonly event: EventService; + + /** + * Add event listeners to the event manager + */ + public abstract init(): void; +} diff --git a/telegram-bot/core/base.service.ts b/telegram-bot/core/base.service.ts new file mode 100644 index 0000000..99d1c6b --- /dev/null +++ b/telegram-bot/core/base.service.ts @@ -0,0 +1,20 @@ +import { container } from "../deps/index.ts"; + +/** + * Base class for all services. + * Any service must extend this class. + */ +export abstract class BaseService { + /** + * Get a singleton instance of the service. + * This method makes use of the [dependency injection](https://alosaur.com/docs/basics/DI#custom-di-container) container to resolve the service. + * @param this + * @returns + */ + static getSingleton( + // deno-lint-ignore no-explicit-any + this: new (...args: any[]) => T, + ): T { + return container.resolve(this); + } +} diff --git a/telegram-bot/core/command.ts b/telegram-bot/core/command.ts deleted file mode 100644 index 5aa3085..0000000 --- a/telegram-bot/core/command.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { BotCommand, Context } from "../deps.ts"; -import type { I18nService } from "../services/i18n.service.ts"; -import type { UserState } from "../types/index.ts"; - -export abstract class Command implements BotCommand { - /** - * Similar to `command`, but not translatable. - * For example: "list" - */ - abstract key: string; - /** - * The command name, without the leading slash. - * For example: "list" - */ - abstract command: string; - /** - * The command description, used in the /help command or in the Telegram command list. - * For example: "List active Callouts" - */ - abstract description: string; - - /** - * Define the states where the command is visible - * @todo Not fully implemented yet, needs to implement the state manager first - */ - abstract visibleOnStates: UserState[]; - - /** - * The i18n service, used to translate the command name and description. - */ - protected abstract readonly i18n: I18nService; - - constructor() { - console.debug(`${this.constructor.name} created`); - } - - /** - * The action that is executed when the command is called. - * @param ctx The context of the Telegram message that triggered the command. - */ - abstract action(ctx: Context): Promise; - - /** - * Called when the language changes. - * @param lang The new language code. - */ - changeLocale(lang: string) { - // FIXME: This is not working on runtime - // this.command = this.i18n.t(`bot.commands.${this.key}.command`, {}, lang); - this.description = this.i18n.t( - `bot.commands.${this.key}.description`, - {}, - lang, - ); - - console.debug(`[${this.constructor.name}] Language changed to [${lang}]`, { - command: this.command, - description: this.description, - }); - } -} diff --git a/telegram-bot/core/event-manager.ts b/telegram-bot/core/event-manager.ts deleted file mode 100644 index 80cae74..0000000 --- a/telegram-bot/core/event-manager.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { EventService } from "../services/event.service.ts"; - -export abstract class EventManager { - protected abstract readonly event: EventService; - - /** - * Add event listeners to the event manager - */ - public abstract init(): void; -} diff --git a/telegram-bot/core/index.ts b/telegram-bot/core/index.ts index 4cb2f39..33e6707 100644 --- a/telegram-bot/core/index.ts +++ b/telegram-bot/core/index.ts @@ -1,2 +1,3 @@ -export * from "./command.ts"; -export * from "./event-manager.ts"; +export * from "./base.command.ts"; +export * from "./base.events.ts"; +export * from "./base.service.ts"; diff --git a/telegram-bot/deno.json b/telegram-bot/deno.json index bca777d..ef323d4 100644 --- a/telegram-bot/deno.json +++ b/telegram-bot/deno.json @@ -38,6 +38,6 @@ "docker:build": "docker build -f ../Dockerfile -t beabee/telegram-bot:latest ..", "docker:start": "docker run -it --init -p 3003:3003 beabee/telegram-bot", "docker:push": "docker push beabee/telegram-bot:latest", - "i18n": "deno run --allow-read --allow-write --allow-env --allow-net scripts/i18n.ts" + "i18n": "deno run --allow-read --allow-write --allow-env --allow-net scripts/i18n.ts && deno task format" } -} \ No newline at end of file +} diff --git a/telegram-bot/deno.lock b/telegram-bot/deno.lock index 247ce67..9fc0cce 100644 --- a/telegram-bot/deno.lock +++ b/telegram-bot/deno.lock @@ -7,7 +7,8 @@ "npm:googleapis@131.0.0": "npm:googleapis@131.0.0", "npm:jsonwebtoken": "npm:jsonwebtoken@9.0.2", "npm:markdown-it": "npm:markdown-it@14.0.0", - "npm:typeorm@0.3.17": "npm:typeorm@0.3.17" + "npm:typeorm@0.3.17": "npm:typeorm@0.3.17", + "npm:valtio@2.0.0-beta.1": "npm:valtio@2.0.0-beta.1" }, "npm": { "@babel/runtime@7.24.0": { @@ -516,6 +517,10 @@ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "dependencies": {} }, + "proxy-compare@2.6.0": { + "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==", + "dependencies": {} + }, "punycode.js@2.3.1": { "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "dependencies": {} @@ -647,6 +652,12 @@ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "dependencies": {} }, + "valtio@2.0.0-beta.1": { + "integrity": "sha512-VarsAUZ3qxZNS9BhC1oeyCZX0vZE4SWuOqyKiXVNy9cbq9OrP+8RU5a59KVV7P0dkRsIRF9RwKL4Jrk4feQWBA==", + "dependencies": { + "proxy-compare": "proxy-compare@2.6.0" + } + }, "webidl-conversions@3.0.1": { "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dependencies": {} @@ -712,6 +723,9 @@ } } }, + "redirects": { + "https://lib.deno.dev/x/grammy@^1.20/mod.ts": "https://deno.land/x/grammy@v1.21.1/mod.ts" + }, "remote": { "https://cdn.skypack.dev/-/debug@v4.3.4-o4liVvMlOnQWbLSYZMXw/dist=es2019,mode=imports/optimized/debug.js": "671100993996e39b501301a87000607916d4d2d9f8fc8e9c5200ae5ba64a1389", "https://cdn.skypack.dev/-/ms@v2.1.2-giBDZ1IA5lmQ3ZXaa87V/dist=es2019,mode=imports/optimized/ms.js": "fd88e2d51900437011f1ad232f3393ce97db1b87a7844b3c58dd6d65562c1276", @@ -1080,6 +1094,7 @@ "https://deno.land/x/grammy@v1.21.1/platform.deno.ts": "68272a7e1d9a2d74d8a45342526485dbc0531dee812f675d7f8a4e7fc8393028", "https://deno.land/x/grammy@v1.21.1/types.deno.ts": "9ef5b8524e5779b1cc6df72736b0663a103b0be549dc4d4c93df2528b27e1534", "https://deno.land/x/grammy@v1.21.1/types.ts": "729415590dfa188dbe924dea614dff4e976babdbabb28a307b869fc25777cdf0", + "https://deno.land/x/grammy_parse_mode@1.9.0/deps.deno.ts": "647effb311140ce1a688371fb4dd0d525a7c7257a90b46c571595cebf4c5692d", "https://deno.land/x/grammy_parse_mode@1.9.0/format.ts": "7debb58d4af04ea86988ce5e448ec119ba7d5d7e513fbc37d7cda106f01273b0", "https://deno.land/x/grammy_parse_mode@1.9.0/hydrate.ts": "d0d872b7e0f13d58c937e03c119ea8b9a9c75a1ed1e79369ab2e02583c658339", "https://deno.land/x/grammy_parse_mode@1.9.0/mod.ts": "58b539ea91fa72c1fd66dd5415b6c4b63104301b07d9daf860bd66343db7062c", diff --git a/telegram-bot/deps.ts b/telegram-bot/deps.ts deleted file mode 100644 index 56ded58..0000000 --- a/telegram-bot/deps.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./deps/index.ts"; diff --git a/telegram-bot/deps/alosaur.ts b/telegram-bot/deps/alosaur.ts index 2cffb3e..0a26ccf 100644 --- a/telegram-bot/deps/alosaur.ts +++ b/telegram-bot/deps/alosaur.ts @@ -1 +1,13 @@ -export { container, Singleton } from "alosaur/mod.ts"; +export { + App, + Area, + container, + Controller, + Delete, + Get, + NotFoundError, + Param, + Post, + Put, + Singleton, +} from "alosaur/mod.ts"; diff --git a/telegram-bot/deps/dotenv.ts b/telegram-bot/deps/dotenv.ts new file mode 100644 index 0000000..e7d3708 --- /dev/null +++ b/telegram-bot/deps/dotenv.ts @@ -0,0 +1 @@ +export * as dotenv from "std/dotenv/mod.ts"; diff --git a/telegram-bot/deps/grammy.ts b/telegram-bot/deps/grammy.ts index 012a618..228d887 100644 --- a/telegram-bot/deps/grammy.ts +++ b/telegram-bot/deps/grammy.ts @@ -1,4 +1,5 @@ export * from "grammy/mod.ts"; export * from "https://deno.land/x/grammy_parse_mode@1.9.0/mod.ts"; +export type * from "grammy/types.deno.ts"; export type * from "grammy_types/message.ts"; export type * from "grammy_types/mod.ts"; diff --git a/telegram-bot/deps/index.ts b/telegram-bot/deps/index.ts index 8ec8ba6..3f89a23 100644 --- a/telegram-bot/deps/index.ts +++ b/telegram-bot/deps/index.ts @@ -3,9 +3,11 @@ export * from "./ammonia.ts"; export * from "./beabee-client.ts"; export * from "./beabee-common.ts"; export * from "./djwt.ts"; +export * from "./dotenv.ts"; export * from "./googleapis.ts"; export * from "./grammy.ts"; export * from "./jsonc.ts"; export * from "./markdown-it.ts"; export * from "./std.ts"; export * from "./typeorm.ts"; +export * from "./valtio.ts"; diff --git a/telegram-bot/deps/valtio.ts b/telegram-bot/deps/valtio.ts new file mode 100644 index 0000000..0b62bec --- /dev/null +++ b/telegram-bot/deps/valtio.ts @@ -0,0 +1,2 @@ +export * from "npm:valtio@2.0.0-beta.1/vanilla"; +export * from "npm:valtio@2.0.0-beta.1/vanilla/utils"; diff --git a/telegram-bot/enums/chat-state.ts b/telegram-bot/enums/chat-state.ts new file mode 100644 index 0000000..c12047e --- /dev/null +++ b/telegram-bot/enums/chat-state.ts @@ -0,0 +1,16 @@ +export enum ChatState { + /** A chat should never have this state, it is used to hide commands */ + None = "none", + /** The user has not yet seen the welcome message */ + Initial = "initial", + /** The user has just started the bot and seen the welcome message */ + Start = "start", + /** The user has listed the callouts */ + CalloutList = "callout:list", + /** The user has selected a callout to view details */ + CalloutDetails = "callout:details", + /** The user has selected a callout to answer */ + CalloutAnswer = "callout:answer", + /** The user has answered the callout */ + CalloutAnswered = "callout:answered", +} diff --git a/telegram-bot/enums/index.ts b/telegram-bot/enums/index.ts index 3f350c4..4720db1 100644 --- a/telegram-bot/enums/index.ts +++ b/telegram-bot/enums/index.ts @@ -1,7 +1,9 @@ export * from "./bot-command-scope.ts"; +export * from "./chat-state.ts"; export * from "./i18n-event.ts"; export * from "./network-communicator-events.ts"; export * from "./parsed-response-type.ts"; export * from "./relay-accepted-file-type.ts"; export * from "./render-result-type.ts"; export * from "./replay-type.ts"; +export * from "./session-event.ts"; diff --git a/telegram-bot/enums/session-event.ts b/telegram-bot/enums/session-event.ts new file mode 100644 index 0000000..0957f6f --- /dev/null +++ b/telegram-bot/enums/session-event.ts @@ -0,0 +1,3 @@ +export enum SessionEvent { + SESSION_CHANGED = "session:changed", +} diff --git a/telegram-bot/event-managers/callout-response.events.ts b/telegram-bot/event-managers/callout-response.events.ts index ad4c67d..59dad54 100644 --- a/telegram-bot/event-managers/callout-response.events.ts +++ b/telegram-bot/event-managers/callout-response.events.ts @@ -1,18 +1,23 @@ -import { Context, Singleton } from "../deps.ts"; +import { Singleton } from "../deps/index.ts"; import { CalloutService } from "../services/callout.service.ts"; import { CommunicationService } from "../services/communication.service.ts"; import { EventService } from "../services/event.service.ts"; import { TransformService } from "../services/transform.service.ts"; import { KeyboardService } from "../services/keyboard.service.ts"; +import { StateMachineService } from "../services/state-machine.service.ts"; import { CalloutResponseRenderer, MessageRenderer } from "../renderer/index.ts"; +import { ResetCommand } from "../commands/reset.command.ts"; +import { ChatState } from "../enums/index.ts"; import { BUTTON_CALLBACK_CALLOUT_INTRO, BUTTON_CALLBACK_CALLOUT_PARTICIPATE, } from "../constants/index.ts"; -import { EventManager } from "../core/event-manager.ts"; +import { BaseEventManager } from "../core/base.events.ts"; + +import type { AppContext } from "../types/index.ts"; @Singleton() -export class CalloutResponseEventManager extends EventManager { +export class CalloutResponseEventManager extends BaseEventManager { constructor( protected readonly event: EventService, protected readonly callout: CalloutService, @@ -21,6 +26,8 @@ export class CalloutResponseEventManager extends EventManager { protected readonly calloutResponseRenderer: CalloutResponseRenderer, protected readonly transform: TransformService, protected readonly keyboard: KeyboardService, + protected readonly stateMachine: StateMachineService, + protected readonly cancel: ResetCommand, ) { super(); console.debug(`${this.constructor.name} created`); @@ -43,10 +50,11 @@ export class CalloutResponseEventManager extends EventManager { ); } - protected async onCalloutParticipateKeyboardPressed(ctx: Context) { + protected async onCalloutParticipateKeyboardPressed(ctx: AppContext) { const data = ctx.callbackQuery?.data?.split(":"); const slug = data?.[1]; const startResponse = data?.[2] as "continue" | "cancel" === "continue"; + const session = await ctx.session; // Remove the inline keyboard await this.keyboard.removeInlineKeyboard(ctx); @@ -89,14 +97,30 @@ export class CalloutResponseEventManager extends EventManager { "Disabled inline keyboard", ); + const abortSignal = this.stateMachine.setSessionState( + session, + ChatState.CalloutAnswer, + true, + ); + + // Wait for all responses const responses = await this.communication.sendAndReceiveAll( ctx, questions, + abortSignal, ); + if (responses instanceof AbortSignal) { + return; + } + const answers = this.transform.parseCalloutFormResponses(responses); - // TODO: Show summary of answers here + this.stateMachine.setSessionState( + session, + ChatState.CalloutAnswered, + false, + ); console.debug( "Got answers", @@ -104,6 +128,7 @@ export class CalloutResponseEventManager extends EventManager { ); try { + // TODO: Ask for contact details if callout requires it const response = await this.callout.createResponse(slug, { answers, guestName: ctx.from?.username, @@ -114,12 +139,28 @@ export class CalloutResponseEventManager extends EventManager { "Created response", response, ); + + await this.communication.send( + ctx, + await this.messageRenderer.continueHelp(session.state), + ); } catch (error) { console.error( `Failed to create response`, error, ); + + // TODO: Send error message to the chat + + return; } + + // TODO: Send success message and a summary of answers to the chat + + await this.communication.send( + ctx, + await this.messageRenderer.continueHelp(session.state), + ); } /** @@ -127,7 +168,7 @@ export class CalloutResponseEventManager extends EventManager { * Called when the user presses the "Yes" or "No" button on the callout response keyboard. * @param ctx */ - protected async onCalloutIntroKeyboardPressed(ctx: Context) { + protected async onCalloutIntroKeyboardPressed(ctx: AppContext) { const data = ctx.callbackQuery?.data?.split(":"); const shortSlug = data?.[1]; const startIntro = data?.[2] as "yes" | "no" === "yes"; // This is the key, so it's not localized @@ -154,7 +195,10 @@ export class CalloutResponseEventManager extends EventManager { } if (!startIntro) { + // TODO: Duplicate stop message await this.communication.send(ctx, this.messageRenderer.stop()); + // Forward cancel to the cancel command + await this.cancel.action(ctx); return; } diff --git a/telegram-bot/event-managers/callout.events.ts b/telegram-bot/event-managers/callout.events.ts index a83fa51..1c8d853 100644 --- a/telegram-bot/event-managers/callout.events.ts +++ b/telegram-bot/event-managers/callout.events.ts @@ -1,20 +1,25 @@ -import { Context, Singleton } from "../deps.ts"; +import { Singleton } from "../deps/index.ts"; import { CalloutService } from "../services/callout.service.ts"; import { CommunicationService } from "../services/communication.service.ts"; import { CalloutRenderer } from "../renderer/index.ts"; import { EventService } from "../services/event.service.ts"; import { KeyboardService } from "../services/keyboard.service.ts"; +import { StateMachineService } from "../services/state-machine.service.ts"; import { BUTTON_CALLBACK_SHOW_CALLOUT } from "../constants/index.ts"; -import { EventManager } from "../core/event-manager.ts"; +import { BaseEventManager } from "../core/base.events.ts"; +import { ChatState } from "../enums/index.ts"; + +import type { AppContext } from "../types/index.ts"; @Singleton() -export class CalloutEventManager extends EventManager { +export class CalloutEventManager extends BaseEventManager { constructor( protected readonly event: EventService, protected readonly callout: CalloutService, protected readonly communication: CommunicationService, protected readonly calloutRenderer: CalloutRenderer, protected readonly keyboard: KeyboardService, + protected readonly stateMachine: StateMachineService, ) { super(); console.debug(`${this.constructor.name} created`); @@ -30,8 +35,9 @@ export class CalloutEventManager extends EventManager { ); } - protected async onCalloutSelectionKeyboardPressed(ctx: Context) { + protected async onCalloutSelectionKeyboardPressed(ctx: AppContext) { const shortSlug = ctx.callbackQuery?.data?.split(":")[1]; + const session = await ctx.session; // Remove the inline keyboard await this.keyboard.removeInlineKeyboard(ctx); @@ -54,15 +60,24 @@ export class CalloutEventManager extends EventManager { try { const callout = await this.callout.get(slug); - const calloutFormRender = await this.calloutRenderer.callout( + const calloutFormRender = await this.calloutRenderer.calloutDetails( callout, ); - await this.communication.sendAndReceiveAll(ctx, calloutFormRender); + + const signal = this.stateMachine.setSessionState( + session, + ChatState.CalloutDetails, + true, + ); + + await this.communication.sendAndReceiveAll( + ctx, + calloutFormRender, + signal, + ); } catch (error) { console.error("Error sending callout", error); await ctx.reply("Error sending callout"); } - - await this.communication.answerCallbackQuery(ctx); // remove loading animation } } diff --git a/telegram-bot/event-managers/i18n.events.ts b/telegram-bot/event-managers/i18n.events.ts index 53c28d8..1598f82 100644 --- a/telegram-bot/event-managers/i18n.events.ts +++ b/telegram-bot/event-managers/i18n.events.ts @@ -1,16 +1,16 @@ -import { Singleton } from "alosaur/mod.ts"; +import { Singleton } from "../deps/index.ts"; import { EventService } from "../services/event.service.ts"; -import { TelegramService } from "../services/telegram.service.ts"; -import { EventManager } from "../core/event-manager.ts"; +import { AppService } from "../services/app.service.ts"; +import { BaseEventManager } from "../core/base.events.ts"; import { I18nEvent } from "../enums/i18n-event.ts"; import type { EventTelegramBot } from "../types/index.ts"; @Singleton() -export class I18nEventManager extends EventManager { +export class I18nEventManager extends BaseEventManager { constructor( protected readonly event: EventService, - protected readonly telegramService: TelegramService, + protected readonly AppService: AppService, ) { super(); console.debug(`${this.constructor.name} created`); @@ -25,6 +25,6 @@ export class I18nEventManager extends EventManager { protected onLanguageChanged(data: EventTelegramBot) { console.debug("Language changed to: ", data); - this.telegramService.changeLocale(data); + this.AppService.changeLocale(data); } } diff --git a/telegram-bot/event-managers/index.ts b/telegram-bot/event-managers/index.ts index bf28341..198f148 100644 --- a/telegram-bot/event-managers/index.ts +++ b/telegram-bot/event-managers/index.ts @@ -2,4 +2,5 @@ export * from "./callout-response.events.ts"; export * from "./callout.events.ts"; export * from "./i18n.events.ts"; export * from "./network-communicator.events.ts"; +export * from "./session.events.ts"; export * from "./telegram.events.ts"; diff --git a/telegram-bot/event-managers/network-communicator.events.ts b/telegram-bot/event-managers/network-communicator.events.ts index 2d53919..202cf0c 100644 --- a/telegram-bot/event-managers/network-communicator.events.ts +++ b/telegram-bot/event-managers/network-communicator.events.ts @@ -1,12 +1,12 @@ -import { Singleton } from "alosaur/mod.ts"; +import { Singleton } from "../deps/index.ts"; import { EventService } from "../services/event.service.ts"; import { BeabeeContentService } from "../services/beabee-content.service.ts"; import { I18nService } from "../services/i18n.service.ts"; -import { EventManager } from "../core/event-manager.ts"; +import { BaseEventManager } from "../core/base.events.ts"; import { NetworkCommunicatorEvents } from "../enums/index.ts"; @Singleton() -export class NetworkCommunicatorEventManager extends EventManager { +export class NetworkCommunicatorEventManager extends BaseEventManager { constructor( protected readonly event: EventService, protected readonly beabeeContent: BeabeeContentService, diff --git a/telegram-bot/event-managers/session.events.ts b/telegram-bot/event-managers/session.events.ts new file mode 100644 index 0000000..600b598 --- /dev/null +++ b/telegram-bot/event-managers/session.events.ts @@ -0,0 +1,29 @@ +import { Singleton } from "../deps/index.ts"; +import { EventService } from "../services/event.service.ts"; +import { CommandService } from "../services/command.service.ts"; +import { BaseEventManager } from "../core/base.events.ts"; +import { SessionEvent } from "../enums/index.ts"; + +import type { EventTelegramBot } from "../types/index.ts"; + +@Singleton() +export class SessionEventManager extends BaseEventManager { + constructor( + protected readonly event: EventService, + protected readonly command: CommandService, + ) { + super(); + console.debug(`${this.constructor.name} created`); + } + + public init() { + this.event.on( + SessionEvent.SESSION_CHANGED, + (event) => this.onSessionChanged(event), + ); + } + + protected async onSessionChanged(data: EventTelegramBot) { + await this.command.onSessionChanged(data); + } +} diff --git a/telegram-bot/event-managers/telegram.events.ts b/telegram-bot/event-managers/telegram.events.ts index 2868227..a6454ed 100644 --- a/telegram-bot/event-managers/telegram.events.ts +++ b/telegram-bot/event-managers/telegram.events.ts @@ -1,13 +1,15 @@ -import { Context, Singleton } from "../deps.ts"; +import { Singleton } from "../deps/index.ts"; import { EventService } from "../services/event.service.ts"; -import { TelegramService } from "../services/telegram.service.ts"; -import { EventManager } from "../core/event-manager.ts"; +import { BotService } from "../services/bot.service.ts"; +import { BaseEventManager } from "../core/base.events.ts"; + +import type { AppContext } from "../types/index.ts"; @Singleton() -export class TelegramEventManager extends EventManager { +export class TelegramEventManager extends BaseEventManager { constructor( protected readonly event: EventService, - protected readonly telegramService: TelegramService, + protected readonly bot: BotService, ) { super(); console.debug(`${this.constructor.name} created`); @@ -18,20 +20,20 @@ export class TelegramEventManager extends EventManager { */ public init() { // Forward callback query data, e.g. Telegram keyboard button presses - this.telegramService.bot.on( + this.bot.on( "callback_query:data", (ctx) => this.onCallbackQueryData(ctx), ); // Forward normale messages from the bot - this.telegramService.bot.on("message", (ctx) => this.onMessage(ctx)); + this.bot.on("message", (ctx) => this.onMessage(ctx)); } - protected onMessage(ctx: Context) { + protected onMessage(ctx: AppContext) { this.event.emitDetailedEvents("message", ctx); } - protected onCallbackQueryData(ctx: Context) { + protected onCallbackQueryData(ctx: AppContext) { if (!ctx.callbackQuery?.data) { // Dispatch general callback event this.event.emit("callback_query:data", ctx); diff --git a/telegram-bot/locales/de.json b/telegram-bot/locales/de.json index 1e79468..b4003c4 100644 --- a/telegram-bot/locales/de.json +++ b/telegram-bot/locales/de.json @@ -1,13 +1,25 @@ { "bot": { "commands": { + "debug": { + "command": "", + "description": "Debug-Informationen anzeigen" + }, + "help": { + "command": "hilfe", + "description": "Was kann ich jetzt tun?" + }, "list": { "command": "auflisten", - "description": "Offene Aufrufe anzeigen" + "description": "Offene Callouts auflisten" + }, + "reset": { + "command": "abbrechen", + "description": "Springe zurück an den Anfang und breche deinen aktuellen Vorgang ab" }, "show": { "command": "zeigen", - "description": "Details über einen spezifischen Aufruf anzeigen" + "description": "Details über einen spezifischen Callout anzeigen" }, "start": { "command": "starten", @@ -15,15 +27,18 @@ }, "subscribe": { "command": "abonnieren", - "description": "Einem Aufruf abonnieren" + "description": "Einen Callout abonnieren" }, "unsubscribe": { "command": "abbestellen", - "description": "Ein Abonnement für einen Aufruf kündigen" + "description": "Ein Abonnement für einen Callout kündigen" } }, "info": { "messages": { + "command": { + "notUsable": "Sie können den Befehl {command} hier nicht verwenden" + }, "done": "Wenn Sie mit Ihrer Antwort fertig sind, tippen Sie einfach \"{done}\".", "enterAmountOfMoney": "Bitte geben Sie einen Geldbetrag ein.", "enterDate": "Bitte geben Sie ein Datum ein.", @@ -32,7 +47,8 @@ "enterText": "Bitte halten Sie Ihre Antwort kurz, idealerweise in einem Satz.", "enterTime": "Bitte geben Sie eine Zeit ein. (z.B. 20:45)", "enterUrl": "Bitte geben Sie eine Webadresse ein. (z.B. https://www.beispiel.de)", - "intro": "Hallo! Ich bin der {botName} Bot. Ich kann dir eine Reihe von offenen Callouts zeigen, an denen du hier auf Telegram teilnehmen kannst. Du kannst die folgenden Befehle verwenden, um dies zu tun, wähle sie einfach im Menü aus oder schreibe sie mir direkt:\n\n{commands}", + "help": "Über den Telegram-Chat können Sie an den laufenden Umfragen von {organisationName} teilnehmen.\nSie können die Liste der offenen Umfragen einsehen und auswählen, an welchen Sie teilnehmen möchten.\n\n{commands}", + "helpContinue": "Jetzt können Sie die Liste der offenen Umfragen überprüfen und eine andere auswählen, an der Sie teilnehmen möchten.\n\n{commands}", "multipleAddressesAllowed": "Geben Sie eine oder mehrere Adressen ein.", "multipleEmailsAllowed": "Sie können eine oder mehrere E-Mail-Adressen eingeben.", "multipleNumbersAllowed": "Geben Sie eine oder mehrere Zahlen ein.", @@ -44,6 +60,11 @@ "onlyOneSelectionAllowed": "Bitte wählen Sie Ihre Wahl, indem Sie deren Nummer eingeben oder deren Knopf drücken (Sie können nur eine auswählen).", "onlyOneValueAllowed": "Bitte geben Sie einen einzigen Wert ein.", "placeholder": "Bitte antworten Sie mit etwas wie \"{placeholder}\".", + "reset": { + "cancelled": "Sie haben Vorgang bereits abgebrochen", + "successful": "Vorgang erfolgreich abgebrochen, Sie sind nun wieder am Anfang", + "unsuccessful": "Sie sind nun wieder am Anfang" + }, "uploadFileHere": "Bitte senden Sie mir eine Datei.", "uploadFilesHere": "Bitte senden Sie mir eine oder mehrere Dateien." } @@ -56,7 +77,7 @@ "yes": "Ja" }, "message": { - "selectDetailCallout": "❓ Über welchen Aufruf möchten Sie mehr Informationen erhalten? Wählen Sie eine Nummer" + "selectDetailCallout": "❓ Über welchen Callout möchten Sie mehr Informationen erhalten? Wählen Sie eine Nummer" } }, "reactions": { @@ -69,18 +90,18 @@ "render": { "callout": { "list": { - "title": "Liste aktiver Aufrufe" + "title": "Liste aktiver Callouts" } } }, "response": { "messages": { "answerWithTruthyOrFalsy": "🤔 Bitte antworten Sie mit \"{truthy}\" oder \"{falsy}\".", - "calloutNotFound": "Es tut mir leid, ich konnte diesen Aufruf nicht finden.", - "calloutStartResponse": "Möchten Sie auf diesen Aufruf reagieren?", + "calloutNotFound": "Es tut mir leid, ich konnte diesen Callout nicht finden.", + "calloutStartResponse": "Möchten Sie auf diesen Callout beantworten?", "componentNotSupported": "\"{type}\" nicht implementiert..", "componentUnknown": "Unbekannter Komponententyp \"{type}\"..", - "noActiveCallouts": "Derzeit gibt es keine offenen Aufrufe.", + "noActiveCallouts": "Derzeit gibt es keine offenen Callouts.", "notACalloutComponent": { "address": "Ihre Antwort ist keine valide Adresse. Bitte versuchen Sie es erneut.", "checkbox": "🤔 Bitte antworten Sie mit \"{truthy}\" oder \"{falsy}\".", diff --git a/telegram-bot/locales/de@informal.json b/telegram-bot/locales/de@informal.json index 578e4cb..fd49f09 100644 --- a/telegram-bot/locales/de@informal.json +++ b/telegram-bot/locales/de@informal.json @@ -1,13 +1,25 @@ { "bot": { "commands": { + "debug": { + "command": "", + "description": "Debug-Informationen anzeigen" + }, + "help": { + "command": "hilfe", + "description": "Was kann ich jetzt tun?" + }, "list": { "command": "auflisten", - "description": "Zeige offene Aufrufe" + "description": "Offene Callouts auflisten" + }, + "reset": { + "command": "abbrechen", + "description": "Springen Sie zurück an den Anfang und brechen Sie Ihren aktuellen Vorgang ab" }, "show": { "command": "zeigen", - "description": "Zeige Details über einen spezifischen Aufruf" + "description": "Zeige Details über einen spezifischen Callout" }, "start": { "command": "starten", @@ -15,15 +27,18 @@ }, "subscribe": { "command": "abonnieren", - "description": "Abonniere einen Aufruf" + "description": "Abonniere einen Callout" }, "unsubscribe": { "command": "abbestellen", - "description": "Kündige ein Abonnement für einen Aufruf" + "description": "Kündige ein Abonnement für einen Callout" } }, "info": { "messages": { + "command": { + "notUsable": "Du kannst den Befehl {command} hier nicht verwenden" + }, "done": "Wenn du fertig mit deiner Antwort bist, tippe einfach \"{done}\".", "enterAmountOfMoney": "Bitte gib einen Geldbetrag ein.", "enterDate": "Bitte gib ein Datum ein.", @@ -32,7 +47,8 @@ "enterText": "Bitte halte deine Antwort kurz, idealerweise in einem Satz.", "enterTime": "Bitte gib eine Zeit ein. (z.B. 20:45)", "enterUrl": "Bitte gib eine Webadresse ein. (z.B. https://www.beispiel.de)", - "intro": "Guten Tag! Ich bin der {botName} Bot. Ich kann Ihnen eine Reihe von offenen Callouts zeigen, an denen Sie hier auf Telegram teilnehmen können. Sie können die folgenden Befehle verwenden, um dies zu tun, wählen Sie sie einfach im Menü aus oder schreiben Sie sie mir direkt:\n\n{commands}", + "help": "Über den Telegram-Chat kannst du an den laufenden Umfragen von {organisationName} teilnehmen.\nDu kannst die Liste der offenen Umfragen einsehen und auswählen, an welchen du teilnehmen möchtest.\n\n{commands}", + "helpContinue": "Jetzt kannst du die Liste der offenen Umfragen überprüfen und eine andere auswählen, an der du teilnehmen möchten.\n\n{commands}", "multipleAddressesAllowed": "Gib eine oder mehrere Adressen ein.", "multipleEmailsAllowed": "Du kannst eine oder mehrere E-Mail-Adressen eingeben.", "multipleNumbersAllowed": "Gib eine oder mehrere Zahlen ein.", @@ -44,6 +60,11 @@ "onlyOneSelectionAllowed": "Bitte triff deine Wahl, indem du deren Nummer eintippst oder den Button drückst (du kannst nur eine auswählen).", "onlyOneValueAllowed": "Bitte gib nur einen Wert ein.", "placeholder": "Bitte antworte mit etwas wie \"{placeholder}\".", + "reset": { + "cancelled": "Der Vorgang wurde bereits abgebrochen", + "successful": "Vorgang erfolgreich abgebrochen, du bist nun wieder am Anfang", + "unsuccessful": "Du bist nun wieder am Anfang" + }, "uploadFileHere": "Bitte sende mir eine Datei.", "uploadFilesHere": "Bitte sende mir eine oder mehrere Dateien." } @@ -56,7 +77,7 @@ "yes": "Ja" }, "message": { - "selectDetailCallout": "❓ Welchen Aufruf möchtest du näher betrachten? Wähle eine Nummer" + "selectDetailCallout": "❓ Welchen Callout möchtest du näher betrachten? Wähle eine Nummer" } }, "reactions": { @@ -69,18 +90,18 @@ "render": { "callout": { "list": { - "title": "Liste aktiver Aufrufe" + "title": "Liste aktiver Callouts" } } }, "response": { "messages": { "answerWithTruthyOrFalsy": "🤔 Bitte antworte mit \"{truthy}\" oder \"{falsy}\".", - "calloutNotFound": "Tut mir leid, ich konnte diesen Aufruf nicht finden.", - "calloutStartResponse": "Möchtest du auf diesen Aufruf reagieren?", + "calloutNotFound": "Tut mir leid, ich konnte diesen Callout nicht finden.", + "calloutStartResponse": "Möchtest du auf diesen Callout reagieren?", "componentNotSupported": "\"{type}\" ist noch nicht implementiert..", "componentUnknown": "Unbekannter Komponententyp \"{type}\"..", - "noActiveCallouts": "Aktuell gibt es keine offenen Aufrufe.", + "noActiveCallouts": "Aktuell gibt es keine offenen Callouts.", "notACalloutComponent": { "address": "Dies ist keine valide Adresse. Bitte versuche es noch einmal.", "checkbox": "🤔 Bitte antworte mit \"{truthy}\" oder \"{falsy}\".", diff --git a/telegram-bot/locales/en.json b/telegram-bot/locales/en.json index a64bab2..feccf40 100644 --- a/telegram-bot/locales/en.json +++ b/telegram-bot/locales/en.json @@ -1,10 +1,22 @@ { "bot": { "commands": { + "debug": { + "command": "debug", + "description": "Display debug information" + }, + "help": { + "command": "help", + "description": "What can I do now?" + }, "list": { "command": "list", "description": "Show open callouts" }, + "reset": { + "command": "reset", + "description": "Jump back to the beginning and cancel your current process" + }, "show": { "command": "show", "description": "Show details about a specific callout" @@ -24,6 +36,9 @@ }, "info": { "messages": { + "command": { + "notUsable": "You cannot use the {command} command here" + }, "done": "When you are finished with your response, just type \"{done}\".", "enterAmountOfMoney": "Please enter an amount of money.", "enterDate": "Please enter a date.", @@ -32,7 +47,8 @@ "enterText": "Please keep your response brief, ideally in one sentence.", "enterTime": "Please enter a time. (e.g. 20:45)", "enterUrl": "Please enter a web address. (e.g. https://www.beabee.io)", - "intro": "Hi! I'm the {botName} bot. I can show you a set of open callouts that you can take part in, right here on Telegram. You can use the following commands to do this, simply select them in the menu or write them to me directly:\n\n{commands}", + "help": "From the Telegram chat you'll me able to take part in ongoing investigations run by {organisationName}.\nYou can check the list of open callouts and pick which ones you'd like to participate in.\n\n{commands}", + "helpContinue": "Now you can check the list of open callouts and choose another one you'd like to participate in.\n\n{commands}", "multipleAddressesAllowed": "Enter one or more addresses.", "multipleEmailsAllowed": "You can enter one or more e-mail addresses.", "multipleNumbersAllowed": "Enter one or more numbers.", @@ -44,6 +60,11 @@ "onlyOneSelectionAllowed": "Please pick your choice by typing its number or pressing its button (you can only pick one).", "onlyOneValueAllowed": "Please enter a single value.", "placeholder": "Please respond with something like \"{placeholder}\".", + "reset": { + "cancelled": "The process has already been cancelled", + "successful": "Process successfully reset, you are now back at the beginning", + "unsuccessful": "You are now back at the beginning" + }, "uploadFileHere": "Please send me a file.", "uploadFilesHere": "Please send me one or more files." } diff --git a/telegram-bot/locales/nl.json b/telegram-bot/locales/nl.json index cf5c247..ef5ec4c 100644 --- a/telegram-bot/locales/nl.json +++ b/telegram-bot/locales/nl.json @@ -1,10 +1,22 @@ { "bot": { "commands": { + "debug": { + "command": "", + "description": "" + }, + "help": { + "command": "", + "description": "" + }, "list": { "command": "", "description": "" }, + "reset": { + "command": "", + "description": "" + }, "show": { "command": "", "description": "" @@ -24,6 +36,9 @@ }, "info": { "messages": { + "command": { + "notUsable": "" + }, "done": "", "enterAmountOfMoney": "", "enterDate": "", @@ -32,7 +47,8 @@ "enterText": "", "enterTime": "", "enterUrl": "", - "intro": "", + "help": "", + "helpContinue": "", "multipleAddressesAllowed": "", "multipleEmailsAllowed": "", "multipleNumbersAllowed": "", @@ -44,6 +60,11 @@ "onlyOneSelectionAllowed": "", "onlyOneValueAllowed": "", "placeholder": "", + "reset": { + "cancelled": "", + "successful": "", + "unsuccessful": "" + }, "uploadFileHere": "", "uploadFilesHere": "" } diff --git a/telegram-bot/locales/pt.json b/telegram-bot/locales/pt.json index cf5c247..ef5ec4c 100644 --- a/telegram-bot/locales/pt.json +++ b/telegram-bot/locales/pt.json @@ -1,10 +1,22 @@ { "bot": { "commands": { + "debug": { + "command": "", + "description": "" + }, + "help": { + "command": "", + "description": "" + }, "list": { "command": "", "description": "" }, + "reset": { + "command": "", + "description": "" + }, "show": { "command": "", "description": "" @@ -24,6 +36,9 @@ }, "info": { "messages": { + "command": { + "notUsable": "" + }, "done": "", "enterAmountOfMoney": "", "enterDate": "", @@ -32,7 +47,8 @@ "enterText": "", "enterTime": "", "enterUrl": "", - "intro": "", + "help": "", + "helpContinue": "", "multipleAddressesAllowed": "", "multipleEmailsAllowed": "", "multipleNumbersAllowed": "", @@ -44,6 +60,11 @@ "onlyOneSelectionAllowed": "", "onlyOneValueAllowed": "", "placeholder": "", + "reset": { + "cancelled": "", + "successful": "", + "unsuccessful": "" + }, "uploadFileHere": "", "uploadFilesHere": "" } diff --git a/telegram-bot/locales/ru.json b/telegram-bot/locales/ru.json index cf5c247..ef5ec4c 100644 --- a/telegram-bot/locales/ru.json +++ b/telegram-bot/locales/ru.json @@ -1,10 +1,22 @@ { "bot": { "commands": { + "debug": { + "command": "", + "description": "" + }, + "help": { + "command": "", + "description": "" + }, "list": { "command": "", "description": "" }, + "reset": { + "command": "", + "description": "" + }, "show": { "command": "", "description": "" @@ -24,6 +36,9 @@ }, "info": { "messages": { + "command": { + "notUsable": "" + }, "done": "", "enterAmountOfMoney": "", "enterDate": "", @@ -32,7 +47,8 @@ "enterText": "", "enterTime": "", "enterUrl": "", - "intro": "", + "help": "", + "helpContinue": "", "multipleAddressesAllowed": "", "multipleEmailsAllowed": "", "multipleNumbersAllowed": "", @@ -44,6 +60,11 @@ "onlyOneSelectionAllowed": "", "onlyOneValueAllowed": "", "placeholder": "", + "reset": { + "cancelled": "", + "successful": "", + "unsuccessful": "" + }, "uploadFileHere": "", "uploadFilesHere": "" } diff --git a/telegram-bot/main.ts b/telegram-bot/main.ts index 76a55b4..47ffaf6 100644 --- a/telegram-bot/main.ts +++ b/telegram-bot/main.ts @@ -1,8 +1,7 @@ -import { load } from "std/dotenv/mod.ts"; -import { App } from "alosaur/mod.ts"; +import { App, dotenv } from "./deps/index.ts"; +await dotenv.load({ export: true }); import { CoreArea } from "./areas/core.area.ts"; - -await load({ export: true }); +import { AppService } from "./services/app.service.ts"; const port = Deno.env.get("TELEGRAM_BOT_PORT") || "3003"; const host = Deno.env.get("TELEGRAM_BOT_HOST") || "localhost"; @@ -12,6 +11,9 @@ const app = new App({ logging: false, }); +const appService = AppService.getSingleton(); +await appService.bootstrap(); + const address = `${host}:${port}`; console.debug(`Listening on ${address}`); diff --git a/telegram-bot/renderer/callout-response.renderer.ts b/telegram-bot/renderer/callout-response.renderer.ts index 61b791f..c88b817 100644 --- a/telegram-bot/renderer/callout-response.renderer.ts +++ b/telegram-bot/renderer/callout-response.renderer.ts @@ -13,7 +13,7 @@ import { isCalloutComponentOfBaseType, isCalloutComponentOfType, Singleton, -} from "../deps.ts"; +} from "../deps/index.ts"; import { calloutComponentTypeToParsedResponseType, createCalloutGroupKey, @@ -36,7 +36,7 @@ import type { Render, RenderMarkdown, } from "../types/index.ts"; -import { CalloutComponentInputSignatureSchema } from "../deps.ts"; +import { CalloutComponentInputSignatureSchema } from "../deps/index.ts"; /** * Render callout responses for Telegram in Markdown diff --git a/telegram-bot/renderer/callout.renderer.ts b/telegram-bot/renderer/callout.renderer.ts index caa47b4..fe44c80 100644 --- a/telegram-bot/renderer/callout.renderer.ts +++ b/telegram-bot/renderer/callout.renderer.ts @@ -1,6 +1,5 @@ -import { Singleton } from "alosaur/mod.ts"; +import { InputFile, InputMediaBuilder, Singleton } from "../deps/index.ts"; import { downloadImage, escapeMd } from "../utils/index.ts"; -import { InputFile, InputMediaBuilder } from "grammy/mod.ts"; import { ParsedResponseType, RenderType } from "../enums/index.ts"; import { BUTTON_CALLBACK_CALLOUT_INTRO } from "../constants/index.ts"; @@ -14,7 +13,7 @@ import type { Render, } from "../types/index.ts"; -import type { Paginated } from "../deps.ts"; +import type { Paginated } from "../deps/index.ts"; /** * Render callouts for Telegram in Markdown @@ -153,7 +152,7 @@ export class CalloutRenderer { * @param callout * @returns */ - public async callout(callout: CalloutDataExt) { + public async calloutDetails(callout: CalloutDataExt) { const imagePath = await downloadImage(callout.image); const inputFile = new InputFile(await Deno.open(imagePath), callout.title); diff --git a/telegram-bot/renderer/message.renderer.ts b/telegram-bot/renderer/message.renderer.ts index ef7d6c8..b847c58 100644 --- a/telegram-bot/renderer/message.renderer.ts +++ b/telegram-bot/renderer/message.renderer.ts @@ -1,8 +1,10 @@ -import { bold, fmt, FormattedString, italic, Singleton } from "../deps.ts"; -import { RenderType } from "../enums/index.ts"; -import { getSimpleMimeTypes } from "../utils/index.ts"; +import { bold, fmt, FormattedString, Singleton } from "../deps/index.ts"; +import { ChatState, RenderType } from "../enums/index.ts"; +import { escapeMd, getSimpleMimeTypes } from "../utils/index.ts"; import { ConditionService } from "../services/condition.service.ts"; import { I18nService } from "../services/i18n.service.ts"; +import { BotService } from "../services/bot.service.ts"; +import { BeabeeContentService } from "../services/beabee-content.service.ts"; import { CommandService } from "../services/command.service.ts"; import type { @@ -12,11 +14,11 @@ import type { RenderText, ReplayAccepted, ReplayCondition, - UserState, } from "../types/index.ts"; -import type { CalloutComponentSchema } from "../deps.ts"; +import type { BotCommand, CalloutComponentSchema } from "../deps/index.ts"; import { ReplayType } from "../enums/replay-type.ts"; import { ParsedResponseType } from "../enums/parsed-response-type.ts"; +import { AppContext } from "../types/app-context.ts"; /** * Render info messages for Telegram in Markdown @@ -27,6 +29,8 @@ export class MessageRenderer { protected readonly command: CommandService, protected readonly condition: ConditionService, protected readonly i18n: I18nService, + protected readonly bot: BotService, + protected readonly beabeeContent: BeabeeContentService, ) { console.debug(`${this.constructor.name} created`); } @@ -43,8 +47,8 @@ export class MessageRenderer { type: RenderType.MARKDOWN, markdown: WELCOME_MD, key: "welcome", - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + removeKeyboard: true, + ...this.noResponse(), }; return result; } @@ -53,47 +57,136 @@ export class MessageRenderer { * Render all available commands * @param state The current user state */ - public async commands(state: UserState): Promise { - const commands = await this.command.getByState(state); + public commands(state: ChatState): RenderMarkdown { + const commands = this.command.getForState(state); - const strings: FormattedString[] = []; + let markdown = ""; for (const command of commands) { - strings.push( - fmt`${bold("/" + command.key)}: ${italic(command.description)}\n`, - ); + markdown += `${("/" + command.command)}: _${command.description}_\n`; } - const result: RenderFormat = { + const result: RenderMarkdown = { + type: RenderType.MARKDOWN, + markdown, + key: "commands", + ...this.noResponse(), + }; + + return result; + } + + /** + * Render a message that the command is not usable + * @returns The render object + */ + public commandNotUsable(command: BotCommand, state: ChatState) { + const tKey = "bot.info.messages.command.notUsable"; + const result: Render = { + type: RenderType.TEXT, + text: this.i18n.t(tKey, { + command: "/" + command.command, + state, + }), + key: tKey, + ...this.noResponse(), + }; + + return result; + } + + public async debug(ctx: AppContext): Promise { + const strings: FormattedString[] = []; + const session = await ctx.session; + + strings.push(fmt`${bold("State: ")} ${session.state}\n`); + if (ctx.chat) { + strings.push(fmt`${bold("Chat ID: ")} ${ctx.chat?.id}\n`); + strings.push(fmt`${bold("Chat type: ")} ${ctx.chat?.type}\n`); + if (!session._data.abortController) { + strings.push(fmt`${bold("AbortController: ")} null\n`); + } else { + strings.push( + fmt`${bold("AbortController: ")} ${ + session._data.abortController.signal.aborted + ? "aborted" + : "not aborted" + }\n`, + ); + } + } + + // Add more debug info here if needed + + return { type: RenderType.FORMAT, format: strings, - key: "commands", + key: "debug", + ...this.noResponse(), + }; + } + + protected noResponse() { + return { accepted: this.condition.replayConditionNone(), parseType: ParsedResponseType.NONE, }; + } - return result; + protected async getGeneralContentPlaceholdersMarkdown() { + const content = await this.beabeeContent.get("general"); + return { + botFirstName: this.bot.botInfo.first_name, + botLastName: this.bot.botInfo.last_name || "Error: last_name not set", + botUsername: this.bot.botInfo.username, + organisationName: `[${escapeMd(content.organisationName)}](${ + escapeMd(content.siteUrl) + })`, + siteUrl: content.siteUrl, + supportEmail: content.supportEmail, + privacyLink: content.privacyLink || "Error: privacyLink not set", + termsLink: content.termsLink || "Error: termsLink not set", + impressumLink: content.impressumLink || "Error: impressumLink not set", + }; } /** - * Render the intro message + * Render the help message */ - public async intro(): Promise { - const tKey = "bot.info.messages.intro"; + public async help(state: ChatState): Promise { + const tKey = "bot.info.messages.help"; + const generalContentPlaceholders = await this + .getGeneralContentPlaceholdersMarkdown(); + const commands = this.commands(state).markdown; + const intro = this.i18n.t(tKey, { + ...generalContentPlaceholders, + commands: commands, + }, { escapeMd: true }); + + const result: RenderMarkdown = { + type: RenderType.MARKDOWN, + markdown: intro, + key: tKey, + ...this.noResponse(), + }; + return result; + } - const commands = fmt((await this.commands("start")).format); + public async continueHelp(state: ChatState): Promise { + const tKey = "bot.info.messages.helpContinue"; + const generalContentPlaceholders = await this + .getGeneralContentPlaceholdersMarkdown(); + const commands = this.commands(state).markdown; const intro = this.i18n.t(tKey, { - botName: "beabee", - commands: commands.toString(), - }); + ...generalContentPlaceholders, + commands: commands, + }, { escapeMd: true }); - const result: RenderFormat = { - type: RenderType.FORMAT, - // TODO: Get the bot name from the beabee content API - format: [intro], + const result: RenderMarkdown = { + type: RenderType.MARKDOWN, + markdown: intro, key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; return result; } @@ -104,8 +197,7 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; return result; @@ -117,8 +209,7 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; return result; @@ -132,8 +223,7 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey, { allowed: texts.join(", ") }), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; } @@ -143,8 +233,49 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), + }; + } + + /** + * Cancel successful message + * @returns + */ + public resetSuccessfulMessage(): RenderText { + const tKey = "bot.info.messages.reset.successful"; + return { + type: RenderType.TEXT, + text: this.i18n.t(tKey), + key: tKey, + ...this.noResponse(), + }; + } + + /** + * Cancel unsuccessful message + * @returns + */ + public resetUnsuccessfulMessage(): RenderText { + const tKey = "bot.info.messages.reset.unsuccessful"; + return { + type: RenderType.TEXT, + text: this.i18n.t(tKey), + key: tKey, + ...this.noResponse(), + }; + } + + /** + * Already cancelled message + * @returns + */ + public resetCancelledMessage(): RenderText { + const tKey = "bot.info.messages.reset.cancelled"; + return { + type: RenderType.TEXT, + text: this.i18n.t(tKey), + key: tKey, + ...this.noResponse(), }; } @@ -154,8 +285,7 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; } @@ -171,8 +301,7 @@ export class MessageRenderer { type: mimeTypesStr, }), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; } @@ -184,8 +313,7 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey, { type: schema.type }), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), } as RenderText; } @@ -195,8 +323,7 @@ export class MessageRenderer { type: RenderType.TEXT, text: this.i18n.t(tKey, { done: doneText }), key: tKey, - accepted: this.condition.replayConditionNone(), - parseType: ParsedResponseType.NONE, + ...this.noResponse(), }; } diff --git a/telegram-bot/scripts/i18n.ts b/telegram-bot/scripts/i18n.ts index 3010dc0..ef4296e 100644 --- a/telegram-bot/scripts/i18n.ts +++ b/telegram-bot/scripts/i18n.ts @@ -9,7 +9,7 @@ import { MarkdownIt, MarkdownRenderer, sheets_v4, -} from "../deps.ts"; +} from "../deps/index.ts"; interface LocaleData { [key: string]: LocaleEntry; diff --git a/telegram-bot/services/_template.service.ts b/telegram-bot/services/_template.service.ts new file mode 100644 index 0000000..82f2165 --- /dev/null +++ b/telegram-bot/services/_template.service.ts @@ -0,0 +1,14 @@ +import { BaseService } from "../core/index.ts"; +import { Singleton } from "../deps/index.ts"; +import { BotService } from "./bot.service.ts"; + +/** + * A template for new services, just copy and paste this file and rename it. + * Replace the BotService with the service(s) you need. + */ +@Singleton() +export class TemplateService extends BaseService { + constructor(protected readonly bot: BotService) { + super(); + } +} diff --git a/telegram-bot/services/telegram.service.ts b/telegram-bot/services/app.service.ts similarity index 77% rename from telegram-bot/services/telegram.service.ts rename to telegram-bot/services/app.service.ts index edba297..8149b77 100644 --- a/telegram-bot/services/telegram.service.ts +++ b/telegram-bot/services/app.service.ts @@ -1,4 +1,5 @@ -import { container, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { Singleton } from "../deps/index.ts"; import { I18nService } from "./i18n.service.ts"; import { BotService } from "./bot.service.ts"; import { CommandService } from "./command.service.ts"; @@ -9,13 +10,10 @@ import { readJson, waitForUrl } from "../utils/index.ts"; import type { EventManagerClass } from "../types/index.ts"; /** - * TelegramService is a Singleton service that handles the Telegram bot. - * - Initialize the bot - * - Add commands - * - Add event listeners using the EventManagers + * AppService is the main singleton service that bootstraps the Telegram bot. */ @Singleton() -export class TelegramService { +export class AppService extends BaseService { constructor( protected readonly command: CommandService, protected readonly bot: BotService, @@ -23,7 +21,8 @@ export class TelegramService { protected readonly beabeeContent: BeabeeContentService, protected readonly networkCommunicator: NetworkCommunicatorService, ) { - this.bootstrap().catch(console.error); + super(); + // this.bootstrap().catch(console.error); console.debug(`${this.constructor.name} created`); } @@ -38,15 +37,17 @@ export class TelegramService { * - Add event listeners * - Start the bot */ - protected async bootstrap() { + public async bootstrap() { await this.printInfo(); await this.waitForBeabee(); this.networkCommunicator.startServer(); - await this.command.initCommands(); + await this.command.initAllCommands(); await this.initEventManagers(); // Start the bot - this.bot.start(); + console.debug("Start the bot..."); + this.bot.start(); // Do not await + console.debug("Bot started"); } protected async waitForBeabee() { @@ -76,9 +77,11 @@ export class TelegramService { } protected async initEventManagers() { + console.debug("Init event managers..."); const EventMangers = await import("../event-managers/index.ts"); for (const EventManager of Object.values(EventMangers)) { - const eventManager = container.resolve(EventManager as EventManagerClass); // Get the Singleton instance + // TODO: Fix type + const eventManager = (EventManager as EventManagerClass).getSingleton(); // Get the Singleton instance eventManager.init(); } } diff --git a/telegram-bot/services/beabee-content.service.ts b/telegram-bot/services/beabee-content.service.ts index aed2507..5d17a31 100644 --- a/telegram-bot/services/beabee-content.service.ts +++ b/telegram-bot/services/beabee-content.service.ts @@ -1,13 +1,15 @@ -import { ContentClient, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { ContentClient, Singleton } from "../deps/index.ts"; import type { Content, ContentId } from "../types/index.ts"; @Singleton() -export class BeabeeContentService { +export class BeabeeContentService extends BaseService { public readonly client: ContentClient; public readonly baseUrl: URL; constructor() { + super(); const host = Deno.env.get("API_PROXY_URL") || Deno.env.get("BEABEE_AUDIENCE") || "http://localhost:3001"; diff --git a/telegram-bot/services/bot.service.ts b/telegram-bot/services/bot.service.ts index bf34698..ec28d2a 100644 --- a/telegram-bot/services/bot.service.ts +++ b/telegram-bot/services/bot.service.ts @@ -1,22 +1,37 @@ import { - Bot as BotService, - container, - Context, + Bot, hydrateReply, - ParseModeFlavor, -} from "../deps.ts"; -import { load } from "std/dotenv/mod.ts"; + lazySession, + NextFunction, + Singleton, +} from "../deps/index.ts"; -await load({ export: true }); -const token = Deno.env.get("TELEGRAM_TOKEN"); -if (!token) throw new Error("TELEGRAM_TOKEN is not set"); +import { StateMachineService } from "./state-machine.service.ts"; -const bot = new BotService>(token); +import type { AppContext } from "../types/index.ts"; -// Install Grammy plugins -bot.use(hydrateReply); +@Singleton() +export class BotService extends Bot { + constructor( + protected readonly stateMachine: StateMachineService, + ) { + const token = Deno.env.get("TELEGRAM_TOKEN"); + if (!token) throw new Error("TELEGRAM_TOKEN is not set"); + super(token); -// Register the bot instance for dependency injection -container.registerInstance(BotService>, bot); + // See https://grammy.dev/plugins/session + this.use(lazySession({ + initial: this.stateMachine.createSession.bind(this.stateMachine), + })); -export { BotService }; + // See https://grammy.dev/plugins/parse-mode + this.use(hydrateReply); + + // Custom middleware, see https://grammy.dev/guide/middleware#writing-custom-middleware + this.use(async (ctx: AppContext, next: NextFunction) => { + const session = await ctx.session; + session._data.ctx = ctx; + await next(); + }); + } +} diff --git a/telegram-bot/services/callout.service.ts b/telegram-bot/services/callout.service.ts index 29ea4dd..c8c2166 100644 --- a/telegram-bot/services/callout.service.ts +++ b/telegram-bot/services/callout.service.ts @@ -1,9 +1,10 @@ +import { BaseService } from "../core/index.ts"; import { CalloutClient, CalloutResponseClient, ItemStatus, Singleton, -} from "../deps.ts"; +} from "../deps/index.ts"; import { isCalloutGroupKey, splitCalloutGroupKey, @@ -22,7 +23,10 @@ import type { GetCalloutWith, } from "../types/index.ts"; -import type { CalloutComponentNestableSchema, Paginated } from "../deps.ts"; +import type { + CalloutComponentNestableSchema, + Paginated, +} from "../deps/index.ts"; const CALLOUTS_ACTIVE_QUERY: GetCalloutsQuery = { rules: { @@ -35,7 +39,7 @@ const CALLOUTS_ACTIVE_QUERY: GetCalloutsQuery = { }; @Singleton() -export class CalloutService { +export class CalloutService extends BaseService { /** * A map of short slugs to slugs for callouts as a WORKAROUND for too long callback data. */ @@ -48,6 +52,7 @@ export class CalloutService { public readonly baseUrl: URL; constructor() { + super(); const host = Deno.env.get("API_PROXY_URL") || Deno.env.get("BEABEE_AUDIENCE") || "http://localhost:3001"; diff --git a/telegram-bot/services/command.service.ts b/telegram-bot/services/command.service.ts index df47353..49d0a07 100644 --- a/telegram-bot/services/command.service.ts +++ b/telegram-bot/services/command.service.ts @@ -1,105 +1,162 @@ -import { container, Singleton } from "../deps.ts"; -import { Command } from "../core/index.ts"; +import { BaseCommand, BaseService } from "../core/index.ts"; +import { + BotCommand, + BotCommandScope, + BotCommandScopeChat, + Singleton, +} from "../deps/index.ts"; import { I18nService } from "./i18n.service.ts"; import { BotService } from "./bot.service.ts"; +import { StateMachineService } from "./state-machine.service.ts"; +import { ChatState } from "../enums/index.ts"; -import type { CommandClass, UserState } from "../types/index.ts"; +import type { CommandClass } from "../types/index.ts"; +import { AppContext } from "../types/app-context.ts"; /** * Service to manage Telegram Commands like `/start` */ @Singleton() -export class CommandService { - protected readonly _commands: { [key: string]: Command } = {}; +export class CommandService extends BaseService { + /** All registered commands */ + protected readonly _commands: { [command: string]: BaseCommand } = {}; constructor( protected readonly bot: BotService, protected readonly i18n: I18nService, + protected readonly stateMachine: StateMachineService, ) { + super(); console.debug(`${this.constructor.name} created`); } public async onLocaleChange(lang: string) { for (const command of Object.values(this._commands)) { - command.changeLocale(lang); + command.onLocaleChange(lang); } - await this.updateExistingCommands(); - } - public async initCommands() { - const Commands = await import("../commands/index.ts"); - await this.addCommands(Commands, "start"); + await this.updateCommands(); } /** - * Initialize the commands. + * Called from the SessionEventManger after a chats session state has changed. + * @param ctx * @returns */ - protected async initExistingCommands() { - const commands = Object.values(this._commands); + public async onSessionChanged(ctx: AppContext) { + const session = await ctx.session; - if (commands.length === 0) { - console.warn("No commands found"); + console.debug(`Session for chat ID "${ctx.chat?.id}" changed:`, { + ...session, + _data: "", + }); + + if (!ctx.chat?.id) { + console.warn("No chat id found"); return; } - await this.bot.api.deleteMyCommands(); - - for (const command of commands) { - this.bot.command(command.command, command.action.bind(command)); + if (session.state === ChatState.Start) { + // Cancel old process for the case there is one + this.stateMachine.resetSessionState( + session, + ); } - await this.bot.api.setMyCommands( - commands.map((command) => ({ - command: command.command, - description: command.description, - })), - ); + const scope: BotCommandScopeChat = { + type: "chat", + chat_id: ctx.chat.id, + }; + + await this.updateCommands({ + scope, + commands: this.getForState(session.state), + force: true, + }); } /** - * Update the commands. + * Initialize all possible commands. + * Called from the `AppService` on start. * @returns */ - protected async updateExistingCommands() { - const commands = Object.values(this._commands); + public async initAllCommands() { + console.debug("Init all commands..."); + const Commands = await this.getAllClasses(); - if (commands.length === 0) { + if (Commands.length === 0) { console.warn("No commands found"); return; } - await this.bot.api.setMyCommands( - commands.map((command) => ({ - command: command.command, - description: command.description, - })), - ); + for (const Command of Commands) { + this.registerCommand(Command); + } + + const initialCommands = this.getForState(ChatState.Initial); + await this.updateCommands({ commands: initialCommands }); + } + + protected registerCommand(Command: CommandClass) { + const command = Command.getSingleton(); + this.bot.command(command.command, command.action.bind(command)); + this._commands[command.command] = command; } /** - * Register new commands to the bot. - * To define a new command, create a new class in the commands folder: - * - The class must implement the Command interface. - * - The class must be decorated with the @Singleton() decorator. - * @param Commands + * Update the commands. + * @param options The options + * @returns */ - protected async addCommands( - Commands: { [key: string]: CommandClass }, - forState: UserState, - ) { - for (const Command of Object.values(Commands)) { - const command = container.resolve(Command); // Get the Singleton instance - // Add this command to the list of commands only if it's visible for the current user state - if (command.visibleOnStates.includes(forState)) { - this._commands[command.command] = command; + protected async updateCommands(options: { + /** The commands to update, if no commands are provided, all currently commands are updated */ + commands?: BotCommand[]; + /** The scope of the commands, if no scope is provided, the commands are global for all chats */ + scope?: BotCommandScope; + /** Force the update */ + force?: boolean; + } = {}) { + console.debug("Update commands...", { + scope: options.scope, + force: options.force, + commands: options.commands?.map((c) => c.command), + }); + // Set updates + const commands = options.commands?.map((command) => ({ + command: command.command, + description: command.description, + })) || (await this.bot.api.getMyCommands({ + scope: options.scope, + })).map((command) => { + const botCommand = this._commands[command.command]; + return { + command: botCommand.command, + description: botCommand.description, + }; + }).filter((c) => c.command); + + if (commands.length === 0) { + console.warn("No commands found"); + return; + } + + if (options.force) { + if (options.scope) { + await this.bot.api.deleteMyCommands({ + scope: options.scope, + }); } + // Also remove global commands + await this.bot.api.deleteMyCommands(); } - await this.initExistingCommands(); + console.debug("Set commands", commands); + await this.bot.api.setMyCommands(commands, { + scope: options.scope, + }); } - public getActive(): Command[] { + public getActive(): BaseCommand[] { return Object.values(this._commands); } @@ -108,15 +165,22 @@ export class CommandService { return Object.values(Commands); } - public async getAll(): Promise { - const Commands = await this.getAllClasses(); - return Commands.map((Command) => container.resolve(Command)); + public getAllRegistered(): BaseCommand[] { + return Object.values(this._commands); } - public async getByState(state: UserState): Promise { - const commands = await this.getAll(); - return commands.filter((command) => - command.visibleOnStates.includes(state) - ); + public getForState(state: ChatState): BaseCommand[] { + const commands = this.getAllRegistered(); + return commands.filter((command) => { + // If the command has no visible states, it is visible in all states + // Otherwise, check if the state is included in the visible states + if ( + command.visibleOnStates.length === 0 || + command.visibleOnStates.includes(state) + ) { + return true; + } + return false; + }); } } diff --git a/telegram-bot/services/communication.service.ts b/telegram-bot/services/communication.service.ts index 7618c53..697e973 100644 --- a/telegram-bot/services/communication.service.ts +++ b/telegram-bot/services/communication.service.ts @@ -1,4 +1,11 @@ -import { Context, fmt, Message, ParseModeFlavor, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { + Context, + fmt, + Message, + ParseModeFlavor, + Singleton, +} from "../deps/index.ts"; import { ParsedResponseType, RenderType, ReplayType } from "../enums/index.ts"; import { EventService } from "./event.service.ts"; import { TransformService } from "./transform.service.ts"; @@ -8,19 +15,19 @@ import { getIdentifier } from "../utils/index.ts"; import { MessageRenderer } from "../renderer/message.renderer.ts"; import type { + AppContext, Render, RenderResponse, RenderResponseParsed, ReplayAccepted, } from "../types/index.ts"; -import type { } from "grammy/context.ts"; /** * Service to handle the communication with the telegram bot and the telegram user. * This service waits for a response until a response is received that fulfils a basic condition (if there is a condition). */ @Singleton() -export class CommunicationService { +export class CommunicationService extends BaseService { constructor( protected readonly event: EventService, protected readonly messageRenderer: MessageRenderer, @@ -28,6 +35,7 @@ export class CommunicationService { protected readonly condition: ConditionService, protected readonly validation: ValidationService, ) { + super(); console.debug(`${this.constructor.name} created`); } @@ -39,37 +47,40 @@ export class CommunicationService { * @param ctx * @param res */ - public async send(ctx: Context, render: Render) { + public async send(ctx: AppContext, render: Render) { + const markup = render.keyboard || + (render.removeKeyboard ? { remove_keyboard: true as true } : undefined); + + console.debug("Render markup: ", markup); + switch (render.type) { case RenderType.PHOTO: - await ctx.replyWithMediaGroup([render.photo]); + await ctx.replyWithMediaGroup([render.photo], {}); if (render.keyboard) { - await ctx.reply("", { - reply_markup: render.keyboard, - }); + // TODO: Send keyboard } break; case RenderType.MARKDOWN: await ctx.reply(render.markdown, { parse_mode: "MarkdownV2", - reply_markup: render.keyboard, + reply_markup: markup, }); break; case RenderType.HTML: await ctx.reply(render.html, { parse_mode: "HTML", - reply_markup: render.keyboard, + reply_markup: markup, }); break; case RenderType.TEXT: await ctx.reply(render.text, { - reply_markup: render.keyboard, + reply_markup: markup, }); break; // See https://grammy.dev/plugins/parse-mode case RenderType.FORMAT: await (ctx as ParseModeFlavor).replyFmt(fmt(render.format), { - reply_markup: render.keyboard, + reply_markup: markup, }); break; case RenderType.EMPTY: @@ -86,7 +97,7 @@ export class CommunicationService { * @param ctx * @returns */ - public async receiveMessage(ctx: Context) { + public async receiveMessage(ctx: AppContext) { const data = await this.event.onceUserMessageAsync(getIdentifier(ctx)); return data; } @@ -97,7 +108,10 @@ export class CommunicationService { * @param render * @returns */ - protected async acceptedUntilSpecificMessage(ctx: Context, render: Render) { + protected async acceptedUntilSpecificMessage( + ctx: AppContext, + render: Render, + ) { let context: Context; let message: Message | undefined; const replays: ReplayAccepted[] = []; @@ -155,7 +169,7 @@ export class CommunicationService { * @returns */ public async receive( - ctx: Context, + ctx: AppContext, render: Render, ): Promise> { // Do not wait for a specific message @@ -200,7 +214,7 @@ export class CommunicationService { * @param render * @returns */ - public async sendAndReceive(ctx: Context, render: Render) { + public async sendAndReceive(ctx: AppContext, render: Render) { await this.send(ctx, render); const responses = await this.receive(ctx, render); @@ -216,9 +230,16 @@ export class CommunicationService { * @param ctx * @param renders */ - public async sendAndReceiveAll(ctx: Context, renders: Render[]) { + public async sendAndReceiveAll( + ctx: AppContext, + renders: Render[], + signal: AbortSignal | null, + ) { const responses: RenderResponse[] = []; for (const render of renders) { + if (signal?.aborted) { + return signal; + } const response = await this.sendAndReceive(ctx, render); if (response) { responses.push(response); @@ -237,7 +258,7 @@ export class CommunicationService { * * **Official reference:** https://core.telegram.org/bots/api#answercallbackquery */ - public async answerCallbackQuery(ctx: Context, text?: string) { + public async answerCallbackQuery(ctx: AppContext, text?: string) { try { await ctx.answerCallbackQuery({ text, diff --git a/telegram-bot/services/condition.service.ts b/telegram-bot/services/condition.service.ts index a5b5a2a..605559e 100644 --- a/telegram-bot/services/condition.service.ts +++ b/telegram-bot/services/condition.service.ts @@ -1,4 +1,5 @@ -import { CalloutComponentSchema, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { CalloutComponentSchema, Singleton } from "../deps/index.ts"; import { ReplayType } from "../enums/index.ts"; import { filterMimeTypesByPatterns } from "../utils/index.ts"; @@ -15,8 +16,9 @@ import type { * Define conditions for a replay. */ @Singleton() -export class ConditionService { +export class ConditionService extends BaseService { constructor() { + super(); console.debug(`${this.constructor.name} created`); } diff --git a/telegram-bot/services/database.service.ts b/telegram-bot/services/database.service.ts index 99e78ca..ccbf14c 100644 --- a/telegram-bot/services/database.service.ts +++ b/telegram-bot/services/database.service.ts @@ -1,9 +1,10 @@ -import { DataSource, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { container, DataSource, Singleton } from "../deps/index.ts"; import { SubscriberModel } from "../models/index.ts"; import { nodeSqlite3 } from "../utils/node-sqlite3/index.ts"; @Singleton() -export class DatabaseService extends DataSource { +export class DatabaseService extends DataSource implements BaseService { constructor() { const dbPath = Deno.env.get("TELEGRAM_BOT_DB_PATH") || "./data/database.sqlite"; @@ -30,4 +31,16 @@ export class DatabaseService extends DataSource { console.debug(`${this.constructor.name} created`); } + + /** + * Get a singleton instance of the service. + * This method makes use of the [dependency injection](https://alosaur.com/docs/basics/DI#custom-di-container) container to resolve the service. + * @param this + * @returns {T} An instance of the DatabaseService or its subclass. + */ + static getSingleton( + this: new () => T, + ): T { + return container.resolve(this); + } } diff --git a/telegram-bot/services/event.service.ts b/telegram-bot/services/event.service.ts index d96e727..40f4ec1 100644 --- a/telegram-bot/services/event.service.ts +++ b/telegram-bot/services/event.service.ts @@ -1,62 +1,21 @@ -import { Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { Context, Singleton } from "../deps/index.ts"; +import { EventDispatcher } from "../utils/index.ts"; import type { + AppContext, EventTelegramBot, EventTelegramBotListener, - Listener, } from "../types/index.ts"; -import type { Context } from "grammy/mod.ts"; - -// deno-lint-ignore no-explicit-any -class EventDispatcher { - private listeners: { [event: string]: Listener[] } = {}; - - /** Register a listener for a specific event */ - public on(event: string, callback: Listener): void { - if (!this.listeners[event]) { - this.listeners[event] = []; - } - this.listeners[event].push(callback); - } - - /** Remove a listener for a specific event */ - public off(event: string, callback: Listener): void { - if (!this.listeners[event]) { - return; - } - this.listeners[event] = this.listeners[event].filter((listener) => - listener !== callback - ); - } - - /** Dispatch an event to all registered listeners */ - public dispatch(event: string, data: T): void { - if (!this.listeners[event]) { - return; - } - this.listeners[event].forEach((listener) => listener(data)); - // Automatically remove listeners registered with `once` - this.listeners[event] = this.listeners[event].filter((listener) => - !listener.once - ); - } - - /** Register a listener that will be removed after its first invocation */ - public once(event: string, callback: Listener): void { - const onceWrapper = ((data: T) => { - callback(data); - onceWrapper.once = true; // Mark for removal - }) as Listener; - this.on(event, onceWrapper); - } -} /** * Handle Telegram bot events * TODO: We need also a way to unsubscribe all callout response related event listeners when a user stops a callout response and for other cases. */ @Singleton() -export class EventService extends EventDispatcher { +export class EventService extends BaseService { + protected _events = new EventDispatcher(); + constructor() { super(); console.debug(`${this.constructor.name} created`); @@ -84,7 +43,7 @@ export class EventService extends EventDispatcher { * @fires message * @fires message:user-123456789 */ - public emitDetailedEvents(eventName: string, ctx: Context) { + public emitDetailedEvents(eventName: string, ctx: AppContext) { const eventNameParts = eventName.split(":"); const emittedEvents: { res: void; eventName: string }[] = []; let specificEventName = ""; @@ -119,7 +78,7 @@ export class EventService extends EventDispatcher { * @param ctx */ public emit(eventName: string, detail: T) { - return this.dispatch(eventName, detail); + return this._events.dispatch(eventName, detail); } /** @@ -127,11 +86,11 @@ export class EventService extends EventDispatcher { * @param eventName The event name to listen for, e.g. "message" * @param callback The callback function to call when the event is emitted */ - public on( + public on( eventName: string, callback: EventTelegramBotListener, ) { - return super.on(eventName, callback); + return this._events.on(eventName, callback); } /** @@ -140,11 +99,11 @@ export class EventService extends EventDispatcher { * @param callback The callback function to call when the event is emitted * @returns */ - public once( + public once( eventName: string, callback: EventTelegramBotListener, ) { - return super.once(eventName, callback); + return this._events.once(eventName, callback); } /** @@ -152,7 +111,7 @@ export class EventService extends EventDispatcher { * @param eventName * @returns */ - public onceAsync( + public onceAsync( eventName: string, ): Promise> { return new Promise((resolve) => { @@ -171,7 +130,7 @@ export class EventService extends EventDispatcher { eventName: string, callback: EventTelegramBotListener, ) { - return super.off( + return this._events.off( eventName, callback, ); diff --git a/telegram-bot/services/i18n.service.ts b/telegram-bot/services/i18n.service.ts index a75fb4c..db9c073 100644 --- a/telegram-bot/services/i18n.service.ts +++ b/telegram-bot/services/i18n.service.ts @@ -1,4 +1,5 @@ -import { dirname, fromFileUrl, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { dirname, fromFileUrl, Singleton } from "../deps/index.ts"; import { escapeMd, readJson, @@ -20,7 +21,7 @@ interface Translations { * - Translate strings */ @Singleton() -export class I18nService { +export class I18nService extends BaseService { protected translations: { [lang: string]: Translations } = {}; protected _activeLang = "en"; protected _ready = false; @@ -35,6 +36,7 @@ export class I18nService { } constructor(protected readonly event: EventService) { + super(); this.setActiveLangSync(this._activeLang); this._ready = true; console.debug(`${this.constructor.name} created`); @@ -115,9 +117,14 @@ export class I18nService { protected translate( path: string, placeholders: { [key: string]: string } = {}, - lang: string = this._activeLang, + options: { + lang?: string; + escapeMd?: boolean; + } = {}, ): string { - const translation = this.getTranslation( + const lang = options.lang || this._activeLang; + const doEscapeMd = options.escapeMd ?? false; + let translation = this.getTranslation( path, lang, this.translations[lang], @@ -131,10 +138,14 @@ export class I18nService { console.warn( `Translation not found for '${path}' in language '${lang}', falling back to English`, ); - return this.translate(path, placeholders, "en"); + return this.translate(path, placeholders, { ...options, lang: "en" }); } - return this.replacePlaceholders(translation, placeholders); + if (doEscapeMd) { + translation = escapeMd(translation); + } + + return this.replacePlaceholders(translation, placeholders, options); } /** @@ -172,11 +183,17 @@ export class I18nService { protected replacePlaceholders( translation: string, placeholders: { [key: string]: string }, + options: { + escapeMd?: boolean; + } = {}, ): string { return Object.keys(placeholders).reduce((acc, key) => { // Allow whitespace in placeholders between curly braces - const regex = new RegExp(`\\{\\s*${key}\\s*\\}`, "g"); - return acc.replaceAll(regex, placeholders[key]); + const regexStr = options.escapeMd + ? `\\\\{\\s*${key}\\s*\\\\}` + : `\\{\\s*${key}\\s*\\}`; + const regex = new RegExp(regexStr, "g"); + return acc.replaceAll(regex, placeholders[key].toString()); }, translation); } } diff --git a/telegram-bot/services/keyboard.service.ts b/telegram-bot/services/keyboard.service.ts index 6c08b6e..1d4a9fb 100644 --- a/telegram-bot/services/keyboard.service.ts +++ b/telegram-bot/services/keyboard.service.ts @@ -1,15 +1,17 @@ -import { Context, InlineKeyboard, Keyboard, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { InlineKeyboard, Keyboard, Singleton } from "../deps/index.ts"; import { BUTTON_CALLBACK_SHOW_CALLOUT } from "../constants/index.ts"; import { I18nService } from "./i18n.service.ts"; -import type { CalloutDataExt } from "../types/index.ts"; +import type { AppContext, CalloutDataExt } from "../types/index.ts"; /** * Service to create Telegram keyboard buttons */ @Singleton() -export class KeyboardService { +export class KeyboardService extends BaseService { constructor(protected readonly i18n: I18nService) { + super(); console.debug(`${this.constructor.name} created`); } @@ -110,13 +112,14 @@ export class KeyboardService { * Create a keyboard with Continue and Cancel buttons. */ public continueCancel() { - const keyboard = new Keyboard(); - keyboard.text( - this.i18n.t("bot.keyboard.label.continue"), - ).row() + const keyboard = new Keyboard() + .text( + this.i18n.t("bot.keyboard.label.continue"), + ) + .row() .text( this.i18n.t("bot.keyboard.label.cancel"), - ); + ).oneTime(); return keyboard; } @@ -125,15 +128,15 @@ export class KeyboardService { * Remove an existing inline keyboard * @param ctx */ - public async removeInlineKeyboard(ctx: Context, withMessage = false) { + public async removeInlineKeyboard(ctx: AppContext, withMessage = false) { + // Do not delete keyboard message? if (!withMessage) { const inlineKeyboard = new InlineKeyboard(); - await ctx.editMessageReplyMarkup({ + return await ctx.editMessageReplyMarkup({ reply_markup: inlineKeyboard, }); - // TODO: Add message with clicked selection? - } else { - await ctx.deleteMessage(); } + + return await ctx.deleteMessage(); } } diff --git a/telegram-bot/services/network-communicator.service.test.ts b/telegram-bot/services/network-communicator.service.test.ts index cac6be9..5260237 100644 --- a/telegram-bot/services/network-communicator.service.test.ts +++ b/telegram-bot/services/network-communicator.service.test.ts @@ -1,4 +1,4 @@ -import { djwt } from "../deps.ts"; +import { djwt } from "../deps/index.ts"; import { createSecretKeyFromSecret } from "../utils/auth.ts"; import jsonwebtoken from "npm:jsonwebtoken"; import { assertEquals } from "std/assert/mod.ts"; diff --git a/telegram-bot/services/network-communicator.service.ts b/telegram-bot/services/network-communicator.service.ts index c2dd26a..5b6a0c4 100644 --- a/telegram-bot/services/network-communicator.service.ts +++ b/telegram-bot/services/network-communicator.service.ts @@ -1,4 +1,5 @@ -import { djwt, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { djwt, Singleton } from "../deps/index.ts"; import { createSecretKeyFromSecret, extractToken } from "../utils/index.ts"; import { EventService } from "./event.service.ts"; import { NetworkCommunicatorEvents } from "../enums/index.ts"; @@ -13,7 +14,7 @@ import type { * @see https://github.com/beabee-communityrm/beabee/blob/1140fb602a978b01fd518ebd772452b8240b7880/src/core/services/NetworkCommunicatorService.ts */ @Singleton() -export class NetworkCommunicatorService { +export class NetworkCommunicatorService extends BaseService { private server?: Deno.HttpServer; private secretKey?: CryptoKey; @@ -23,6 +24,7 @@ export class NetworkCommunicatorService { }; constructor(protected readonly event: EventService) { + super(); const secret = Deno.env.get("BEABEE_SERVICE_SECRET"); if (!secret) { throw new Error("No service secret found"); @@ -92,6 +94,7 @@ export class NetworkCommunicatorService { * Start the internal server */ public startServer() { + console.debug("Start server..."); const reloadRoute = new URLPattern({ pathname: "/books/:id" }); this.server = Deno.serve({ port: 4000 }, (req: Request) => { const method = req.method; diff --git a/telegram-bot/services/state-machine.service.ts b/telegram-bot/services/state-machine.service.ts new file mode 100644 index 0000000..e20b860 --- /dev/null +++ b/telegram-bot/services/state-machine.service.ts @@ -0,0 +1,175 @@ +import { BaseService } from "../core/index.ts"; +import { + proxy, + ref, + Singleton, + snapshot, + subscribe, + watch, +} from "../deps/index.ts"; +import { EventService } from "./event.service.ts"; +import { KeyboardService } from "./keyboard.service.ts"; +import { ChatState, SessionEvent } from "../enums/index.ts"; + +import type { SessionState } from "../types/index.ts"; + +/** + * State machine service + * * More or less just a injectable wrapper for [valtio](https://github.com/pmndrs/valtio) (vanillla-version) + * * Can be enriched with its own functionalities if needed + * * Used to create a proxy state object using {@link StateMachineService.create} for each chat session (sessions are handled using [Grammy's session plugin](https://grammy.dev/plugins/session)) + * * Changes on the state (or any sub property) can be subscribed using {@link StateMachineService.subscribe}. + */ +@Singleton() +export class StateMachineService extends BaseService { + constructor( + protected readonly event: EventService, + protected readonly keyboard: KeyboardService, + ) { + super(); + } + + /** + * Create a new proxy state object. + * The [proxy](https://valtio.pmnd.rs/docs/api/basic/proxy) tracks changes to the original object and all nested objects, notifying listeners when an object is modified. + * @param baseObject + * @returns + */ + public create(baseObject?: T): T { + return proxy(baseObject); + } + + /** + * Create a new session state object for a chat session handled by Grammy's session plugin. + * @returns + */ + public createSession() { + const sessionProxy = this.create({ + state: ChatState.Initial, + _data: this.ref({ + ctx: null, + abortController: null, + }), + }); + + // Auto-subscribe to session changes and forward them as events + this.subscribe(sessionProxy, (_ops) => { + console.debug("Session updated", sessionProxy.state, _ops); + const ctx = sessionProxy._data.ctx; + if (!ctx) return; + this.event.emit(SessionEvent.SESSION_CHANGED, ctx); + }); + + return sessionProxy; + } + + /** + * Set the state of a session + * @param session The session to set the state for + * @param newState The new state + * @param cancellable If the state change should be cancellable + * @returns The abort signal if the state change is cancellable, otherwise null + */ + public setSessionState( + session: SessionState, + newState: ChatState, + cancellable: boolean, + ) { + session.state = newState; + session._data.abortController = cancellable ? new AbortController() : null; + return session._data.abortController?.signal ?? null; + } + + /** + * Reset the state of a session to `ChatState.Start` + * @param session The session to reset the state for + * @returns True if the state was cancelled, false otherwise + */ + public resetSessionState(session: SessionState) { + session.state = ChatState.Start; + if ( + session._data.abortController && + !session._data.abortController.signal.aborted + ) { + session._data.abortController.abort(); + return true; + } + return false; + } + + /** + * [Subscribe](https://valtio.pmnd.rs/docs/api/advanced/subscribe) to changes in the state. + * @example + * ```ts + * const state = stateMachine.create({ count: 0 }); + * stateMachine.subscribe(state, (value) => console.log(value.count)); + * state.count++; + * ``` + * + * You can also subscribe to a portion of state. + * @example + * ```ts + * const state = stateMachine.create(({ obj: { foo: 'bar' }, arr: ['hello'] }); + * stateMachine.subscribe(.obj, () => console.log('state.obj has changed to', state.obj)); + * state.count++; + * ``` + * @param proxy + * @param callback + * @returns + */ + public subscribe = subscribe; + + /** + * [snapshot](https://valtio.pmnd.rs/docs/api/advanced/snapshot) takes a proxy and returns an immutable object, unwrapped from the proxy. + * Immutability is achieved by *efficiently* deep copying & freezing the object. + * + * @example + * ```ts + * const store = stateMachine.create({ name: 'Mika' }) + * const snap1 = stateMachine.snapshot(store) // an efficient copy of the current store values, unproxied + * const snap2 = stateMachine.snapshot(store) + * console.log(snap1 === snap2) // true, no need to re-render + * + * store.name = 'Hanna' + * const snap3 = stateMachine.snapshot(store) + * console.log(snap1 === snap3) // false, should re-render + + * ``` + */ + public snapshot = snapshot; + + /** + * A [ref](https://valtio.pmnd.rs/docs/api/advanced/ref) allows unproxied state in a proxy state. + * A `ref` is useful in the rare instances you to nest an object in a `proxy` that is not wrapped in an inner proxy and, therefore, is not tracked. + * @example + * ```ts + * const store = stateMachine.create({ + * users: [ + * { id: 1, name: 'Juho', uploads: ref([]) }, + * ] + * }) + * }) + * ``` + * Once an object is wrapped in a ref, it should be mutated without resetting the object or rewrapping in a new ref. + * @example + * ```ts + * // do mutate + * store.users[0].uploads.push({ id: 1, name: 'Juho' }) + * // do reset + * store.users[0].uploads.splice(0) + * + * // don't + * store.users[0].uploads = [] + * ``` + * A ref should also not be used as the only state in a proxy, making the proxy usage pointless. + */ + public ref = ref; + + /** + * subscription via a getter. + * [watch](https://valtio.pmnd.rs/docs/api/utils/watch) supports subscribing to multiple proxy objects (unlike `subscribe` which listens to only a single proxy). Proxy objects are subscribed with a `get` function passed to the callback. + * + * Any changes to the proxy object (or its child proxies) will rerun the callback. + */ + public watch = watch; +} diff --git a/telegram-bot/services/subscriber.service.ts b/telegram-bot/services/subscriber.service.ts index a8b344d..8309d4d 100644 --- a/telegram-bot/services/subscriber.service.ts +++ b/telegram-bot/services/subscriber.service.ts @@ -1,16 +1,22 @@ -import { Context, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { Singleton } from "../deps/index.ts"; import { SubscriberModel } from "../models/index.ts"; import { DatabaseService } from "./database.service.ts"; import { getIdentifier } from "../utils/index.ts"; -import type { Subscriber } from "../types/index.ts"; +import type { AppContext, Subscriber } from "../types/index.ts"; +/** + * Handle subscriptions to a Callout. + * Just a prove of concept and not fully implemented for now and shows Shows how TypeORM can be used with Deno. + */ @Singleton() // See https://github.com/alosaur/alosaur/tree/master/src/injection -export class SubscriberService { +export class SubscriberService extends BaseService { /** - * @param _ DatabaseService injected to make sure the database is initialized + * @param db DatabaseService injected to make sure the database is initialized */ constructor(private readonly db: DatabaseService) { + super(); console.debug(`${this.constructor.name} created`); } @@ -19,7 +25,7 @@ export class SubscriberService { * @param ctx * @returns */ - private transformAnonymous(ctx: Context) { + private transformAnonymous(ctx: AppContext) { const id = getIdentifier(ctx); const subscriber = new SubscriberModel(); subscriber.id = id; @@ -33,7 +39,7 @@ export class SubscriberService { * @param forceAnonymous * @returns */ - private transform(ctx: Context, forceAnonymous = false) { + private transform(ctx: AppContext, forceAnonymous = false) { if (!ctx.from || forceAnonymous) return this.transformAnonymous(ctx); const id = getIdentifier(ctx); @@ -65,7 +71,7 @@ export class SubscriberService { * @returns */ public async create( - ctx: Context, + ctx: AppContext, ): Promise<(SubscriberModel & Subscriber) | null> { const id = getIdentifier(ctx); if (await this.exists(id)) { @@ -84,7 +90,7 @@ export class SubscriberService { * @param ctx * @returns */ - public async update(ctx: Context) { + public async update(ctx: AppContext) { const data = this.transform(ctx); const result = await this.db.manager.update(SubscriberModel, data.id, data); return result; @@ -95,7 +101,7 @@ export class SubscriberService { * @param ctx * @returns */ - public async delete(ctx: Context) { + public async delete(ctx: AppContext) { const id = getIdentifier(ctx); const result = await this.db.manager.delete(SubscriberModel, id); return result; diff --git a/telegram-bot/services/transform.service.ts b/telegram-bot/services/transform.service.ts index 747ea78..5d57ff3 100644 --- a/telegram-bot/services/transform.service.ts +++ b/telegram-bot/services/transform.service.ts @@ -1,4 +1,9 @@ -import { CalloutResponseAnswerAddress, Context, Singleton } from "../deps.ts"; +import { BaseService } from "../core/index.ts"; +import { + CalloutResponseAnswerAddress, + Context, + Singleton, +} from "../deps/index.ts"; import { ParsedResponseType, ReplayType } from "../enums/index.ts"; import { I18nService } from "../services/i18n.service.ts"; @@ -28,16 +33,16 @@ import type { import type { CalloutResponseAnswer, CalloutResponseAnswersSlide, -} from "../deps.ts"; +} from "../deps/index.ts"; import { ReplayAcceptedCalloutComponentSchema } from "../types/index.ts"; -import {} from "../../beabee-client/src/deps.ts"; /** * Service to transform message responses */ @Singleton() -export class TransformService { +export class TransformService extends BaseService { constructor(readonly i18n: I18nService) { + super(); console.debug(`${this.constructor.name} created`); } diff --git a/telegram-bot/services/validation.service.ts b/telegram-bot/services/validation.service.ts index d015843..f0ed27d 100644 --- a/telegram-bot/services/validation.service.ts +++ b/telegram-bot/services/validation.service.ts @@ -1,10 +1,11 @@ +import { BaseService } from "../core/index.ts"; import { CalloutComponentType, calloutComponentValidator, Context, Message, Singleton, -} from "../deps.ts"; +} from "../deps/index.ts"; import { RelayAcceptedFileType, ReplayType } from "../enums/index.ts"; import { extractNumbers, @@ -35,8 +36,9 @@ import { ReplayAcceptedCalloutComponentSchema } from "../types/replay-accepted-c * This class checks replays messages for conditions which can also be used for other things than Callouts. */ @Singleton() -export class ValidationService { +export class ValidationService extends BaseService { constructor(protected readonly transform: TransformService) { + super(); console.debug(`${this.constructor.name} created`); } diff --git a/telegram-bot/types/app-context.ts b/telegram-bot/types/app-context.ts new file mode 100644 index 0000000..8e0cc35 --- /dev/null +++ b/telegram-bot/types/app-context.ts @@ -0,0 +1,14 @@ +import type { + Context, + LazySessionFlavor, + ParseModeFlavor, +} from "../deps/index.ts"; +import type { SessionState } from "./index.ts"; + +/** + * Extended Grammy {@link Context} for plugins and custom data used in this bot + */ +export type AppContext = + & Context + & ParseModeFlavor + & LazySessionFlavor; diff --git a/telegram-bot/types/beabee-client.ts b/telegram-bot/types/beabee-client.ts index 891be9e..62c535a 100644 --- a/telegram-bot/types/beabee-client.ts +++ b/telegram-bot/types/beabee-client.ts @@ -7,4 +7,4 @@ export type { GetCalloutDataWith, GetCalloutsQuery, GetCalloutWith, -} from "../deps.ts"; +} from "../deps/index.ts"; diff --git a/telegram-bot/types/callout-data-ext.ts b/telegram-bot/types/callout-data-ext.ts index 04eaaab..fa09b09 100644 --- a/telegram-bot/types/callout-data-ext.ts +++ b/telegram-bot/types/callout-data-ext.ts @@ -1,4 +1,4 @@ -import { CalloutData } from "../deps.ts"; +import { CalloutData } from "../deps/index.ts"; export interface CalloutDataExt extends CalloutData { url: string | null; diff --git a/telegram-bot/types/chat-state.ts b/telegram-bot/types/chat-state.ts new file mode 100644 index 0000000..89bae45 --- /dev/null +++ b/telegram-bot/types/chat-state.ts @@ -0,0 +1,19 @@ +/** + * The current state of the user's session + * `initial` - The user has not yet seen the welcome message + * `start` - The user has just started the bot and seen the welcome message + * `callout:list` - The user has listed the callouts + * `callout:details` - The user is currently viewing the details of a callout + * `callout:answer` - The user is currently answering a callout + * `callout:answered` - The user has answered a callout + * ... add more if needed + * + * @todo Use this for the State Manager + */ +export type _ChatState = + | "initial" + | "start" + | "callout:list" + | "callout:details" + | "callout:answer" + | "callout:answered"; diff --git a/telegram-bot/types/command-class.ts b/telegram-bot/types/command-class.ts index 9f4c1b1..27e283d 100644 --- a/telegram-bot/types/command-class.ts +++ b/telegram-bot/types/command-class.ts @@ -1,7 +1,9 @@ -import type { Command } from "../core/command.ts"; +import type { BaseCommand } from "../core/base.command.ts"; /** * A command class is a class that implements the Command interface. */ -// deno-lint-ignore no-explicit-any -export type CommandClass = new (...args: any[]) => Command; +export type CommandClass = + & { getSingleton(): BaseCommand } + // deno-lint-ignore no-explicit-any + & (new (...args: any[]) => BaseCommand); diff --git a/telegram-bot/types/event-manager-class.ts b/telegram-bot/types/event-manager-class.ts index 7240d68..9ee274f 100644 --- a/telegram-bot/types/event-manager-class.ts +++ b/telegram-bot/types/event-manager-class.ts @@ -1,7 +1,9 @@ -import type { EventManager } from "../core/event-manager.ts"; +import type { BaseEventManager } from "../core/base.events.ts"; /** * A command class is a class that implements the Command interface. */ // deno-lint-ignore no-explicit-any -export type EventManagerClass = new (...args: any[]) => EventManager; +export type EventManagerClass = (new (...args: any[]) => BaseEventManager) & { + getSingleton: () => BaseEventManager; +}; diff --git a/telegram-bot/types/event-telegram-bot-listener.ts b/telegram-bot/types/event-telegram-bot-listener.ts index 5fb1790..51f7afa 100644 --- a/telegram-bot/types/event-telegram-bot-listener.ts +++ b/telegram-bot/types/event-telegram-bot-listener.ts @@ -1,6 +1,5 @@ -import type { Context } from "grammy/mod.ts"; -import type { EventTelegramBot } from "./index.ts"; +import type { AppContext, EventTelegramBot } from "./index.ts"; -export interface EventTelegramBotListener { +export interface EventTelegramBotListener { (evt: EventTelegramBot): void | Promise; } diff --git a/telegram-bot/types/event-telegram-bot.ts b/telegram-bot/types/event-telegram-bot.ts index 40ebb61..1e5d6a0 100644 --- a/telegram-bot/types/event-telegram-bot.ts +++ b/telegram-bot/types/event-telegram-bot.ts @@ -1,3 +1,3 @@ -import type { Context } from "grammy/mod.ts"; +import type { AppContext } from "./index.ts"; -export type EventTelegramBot = T; +export type EventTelegramBot = T; diff --git a/telegram-bot/types/get-callout-data-with-ext.ts b/telegram-bot/types/get-callout-data-with-ext.ts index c7d6fef..5a228fc 100644 --- a/telegram-bot/types/get-callout-data-with-ext.ts +++ b/telegram-bot/types/get-callout-data-with-ext.ts @@ -1,4 +1,4 @@ -import type { GetCalloutDataWith, GetCalloutWith } from "../deps.ts"; +import type { GetCalloutDataWith, GetCalloutWith } from "../deps/index.ts"; import type { GetCalloutDataExt } from "./get-callout-data-ext.ts"; export type GetCalloutDataWithExt = diff --git a/telegram-bot/types/index.ts b/telegram-bot/types/index.ts index 1629f14..157b915 100644 --- a/telegram-bot/types/index.ts +++ b/telegram-bot/types/index.ts @@ -1,5 +1,7 @@ +export * from "./app-context.ts"; export * from "./beabee-client.ts"; export * from "./callout-data-ext.ts"; +export * from "./chat-state.ts"; export * from "./command-class.ts"; export * from "./event-manager-class.ts"; export * from "./event-telegram-bot-listener.ts"; @@ -47,5 +49,5 @@ export * from "./replay-condition-selection.ts"; export * from "./replay-condition-text.ts"; export * from "./replay-condition.ts"; export * from "./replay.ts"; +export * from "./session-state.ts"; export * from "./subscriber.ts"; -export * from "./user-state.ts"; diff --git a/telegram-bot/types/render-base.ts b/telegram-bot/types/render-base.ts index 6011a8c..1d59114 100644 --- a/telegram-bot/types/render-base.ts +++ b/telegram-bot/types/render-base.ts @@ -1,6 +1,6 @@ import type { ParsedResponseType, RenderType } from "../enums/index.ts"; import type { ReplayCondition } from "./index.ts"; -import type { InlineKeyboard, Keyboard } from "../deps.ts"; +import type { InlineKeyboard, Keyboard } from "../deps/index.ts"; export interface RenderBase { /** @@ -23,6 +23,11 @@ export interface RenderBase { */ keyboard?: InlineKeyboard | Keyboard; + /** + * Remove the custom keyboard after the user has replied. + */ + removeKeyboard?: boolean; + /** * Define the types of the replay you are accepting. */ diff --git a/telegram-bot/types/render-format.ts b/telegram-bot/types/render-format.ts index 99187d0..a1888b9 100644 --- a/telegram-bot/types/render-format.ts +++ b/telegram-bot/types/render-format.ts @@ -1,6 +1,6 @@ import type { RenderBase } from "./index.ts"; import { RenderType } from "../enums/index.ts"; -import { Stringable } from "../deps.ts"; +import { Stringable } from "../deps/index.ts"; /** Used for Grammy [parse mode plugin](https://grammy.dev/plugins/parse-mode) */ export interface RenderFormat extends RenderBase { diff --git a/telegram-bot/types/render-photo.ts b/telegram-bot/types/render-photo.ts index 6e5ff47..77f0f1f 100644 --- a/telegram-bot/types/render-photo.ts +++ b/telegram-bot/types/render-photo.ts @@ -1,5 +1,5 @@ import { RenderType } from "../enums/index.ts"; -import type { InputMediaPhoto } from "grammy/types.deno.ts"; +import type { InputMediaPhoto } from "../deps/index.ts"; import type { RenderBase } from "./index.ts"; export interface RenderPhoto extends RenderBase { diff --git a/telegram-bot/types/render-response-parsed-callout-component.ts b/telegram-bot/types/render-response-parsed-callout-component.ts index 2c36eb4..d5029a6 100644 --- a/telegram-bot/types/render-response-parsed-callout-component.ts +++ b/telegram-bot/types/render-response-parsed-callout-component.ts @@ -1,5 +1,5 @@ import type { RenderResponseParsedBase } from "./index.ts"; -import { CalloutResponseAnswer } from "../deps.ts"; +import { CalloutResponseAnswer } from "../deps/index.ts"; import type { ParsedResponseType } from "../enums/index.ts"; export interface RenderResponseParsedCalloutComponent< diff --git a/telegram-bot/types/render-response-parsed-file.ts b/telegram-bot/types/render-response-parsed-file.ts index cee37c2..f316264 100644 --- a/telegram-bot/types/render-response-parsed-file.ts +++ b/telegram-bot/types/render-response-parsed-file.ts @@ -1,5 +1,5 @@ import type { RenderResponseParsedBase } from "./index.ts"; -import { CalloutResponseAnswerFileUpload } from "../deps.ts"; +import { CalloutResponseAnswerFileUpload } from "../deps/index.ts"; import type { ParsedResponseType } from "../enums/index.ts"; export interface RenderResponseParsedFile diff --git a/telegram-bot/types/replay-accepted-base.ts b/telegram-bot/types/replay-accepted-base.ts index 9b7a705..9ad89b2 100644 --- a/telegram-bot/types/replay-accepted-base.ts +++ b/telegram-bot/types/replay-accepted-base.ts @@ -1,5 +1,5 @@ import type { ReplayType } from "../enums/index.ts"; -import type { Context } from "../deps.ts"; +import type { Context } from "../deps/index.ts"; export interface ReplayAcceptedBase { /** The type of the replay. */ diff --git a/telegram-bot/types/replay-accepted-callout-component-schema.ts b/telegram-bot/types/replay-accepted-callout-component-schema.ts index 3c20a50..1ff00b6 100644 --- a/telegram-bot/types/replay-accepted-callout-component-schema.ts +++ b/telegram-bot/types/replay-accepted-callout-component-schema.ts @@ -1,6 +1,6 @@ import type { ReplayAcceptedBase } from "./index.ts"; import type { ReplayType } from "../enums/index.ts"; -import type { CalloutResponseAnswer } from "../deps.ts"; +import type { CalloutResponseAnswer } from "../deps/index.ts"; export interface ReplayAcceptedCalloutComponentSchema extends ReplayAcceptedBase { diff --git a/telegram-bot/types/replay-condition-callout-component-schema.ts b/telegram-bot/types/replay-condition-callout-component-schema.ts index 01a0de2..9a30e1c 100644 --- a/telegram-bot/types/replay-condition-callout-component-schema.ts +++ b/telegram-bot/types/replay-condition-callout-component-schema.ts @@ -1,6 +1,6 @@ import { ReplayConditionBase } from "./index.ts"; import type { ReplayType } from "../enums/index.ts"; -import type { CalloutComponentSchema } from "../deps.ts"; +import type { CalloutComponentSchema } from "../deps/index.ts"; /** * Accept or wait for a callout answer replay. diff --git a/telegram-bot/types/replay.ts b/telegram-bot/types/replay.ts index c32bf96..d641065 100644 --- a/telegram-bot/types/replay.ts +++ b/telegram-bot/types/replay.ts @@ -1,4 +1,4 @@ -import type { Context } from "../deps.ts"; +import type { Context } from "../deps/index.ts"; /** * Type to collect replies. A reply can be a message or a file. diff --git a/telegram-bot/types/session-state.ts b/telegram-bot/types/session-state.ts new file mode 100644 index 0000000..011e1b4 --- /dev/null +++ b/telegram-bot/types/session-state.ts @@ -0,0 +1,14 @@ +import type { ChatState } from "../enums/index.ts"; +import type { AppContext } from "./index.ts"; + +export interface SessionState { + state: ChatState; + + /** Additional untracked data, this data should not be stored in a database */ + _data: { + /** Reverse reference to the context */ + ctx: AppContext | null; + + abortController: AbortController | null; + }; +} diff --git a/telegram-bot/types/subscriber.ts b/telegram-bot/types/subscriber.ts index f56a877..3ab6b26 100644 --- a/telegram-bot/types/subscriber.ts +++ b/telegram-bot/types/subscriber.ts @@ -1,5 +1,5 @@ import type { NullableProperties } from "./index.ts"; -import type { User } from "grammy_types/mod.ts"; +import type { User } from "../deps/index.ts"; /** * A subscriber is a telegram user who has subscribed to a channel. diff --git a/telegram-bot/types/user-state.ts b/telegram-bot/types/user-state.ts deleted file mode 100644 index 34fc674..0000000 --- a/telegram-bot/types/user-state.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * The current state of the user - * `start` - The user has just started the bot - * `callout` - The user is currently in a callout - * ... add more here - * - * @todo Use this for the State Manager - */ -export type UserState = "start" | "callout"; diff --git a/telegram-bot/utils/callouts.ts b/telegram-bot/utils/callouts.ts index f39474c..1c5444b 100644 --- a/telegram-bot/utils/callouts.ts +++ b/telegram-bot/utils/callouts.ts @@ -3,9 +3,9 @@ import { CALLOUT_RESPONSE_GROUP_KEY_SEPARATOR } from "../constants/index.ts"; import { ParsedResponseType } from "../enums/index.ts"; -import { CalloutComponentType } from "../deps.ts"; +import { CalloutComponentType } from "../deps/index.ts"; -import type { CalloutComponentSchema } from "../deps.ts"; +import type { CalloutComponentSchema } from "../deps/index.ts"; export const createCalloutGroupKey = (key: string, prefix: string) => { return prefix + CALLOUT_RESPONSE_GROUP_KEY_SEPARATOR + key; diff --git a/telegram-bot/utils/event-dispatcher.ts b/telegram-bot/utils/event-dispatcher.ts new file mode 100644 index 0000000..e8e2b50 --- /dev/null +++ b/telegram-bot/utils/event-dispatcher.ts @@ -0,0 +1,45 @@ +import type { Listener } from "../types/index.ts"; + +// deno-lint-ignore no-explicit-any +export class EventDispatcher { + private listeners: { [event: string]: Listener[] } = {}; + + /** Register a listener for a specific event */ + public on(event: string, callback: Listener): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + } + + /** Remove a listener for a specific event */ + public off(event: string, callback: Listener): void { + if (!this.listeners[event]) { + return; + } + this.listeners[event] = this.listeners[event].filter((listener) => + listener !== callback + ); + } + + /** Dispatch an event to all registered listeners */ + public dispatch(event: string, data: T): void { + if (!this.listeners[event]) { + return; + } + this.listeners[event].forEach((listener) => listener(data)); + // Automatically remove listeners registered with `once` + this.listeners[event] = this.listeners[event].filter((listener) => + !listener.once + ); + } + + /** Register a listener that will be removed after its first invocation */ + public once(event: string, callback: Listener): void { + const onceWrapper = ((data: T) => { + callback(data); + onceWrapper.once = true; // Mark for removal + }) as Listener; + this.on(event, onceWrapper); + } +} diff --git a/telegram-bot/utils/file.ts b/telegram-bot/utils/file.ts index 5ec51b5..765be50 100644 --- a/telegram-bot/utils/file.ts +++ b/telegram-bot/utils/file.ts @@ -1,5 +1,5 @@ import { getFilenameFromUrl } from "./index.ts"; -import { mediaTypes, parseJsonc } from "../deps.ts"; +import { mediaTypes, parseJsonc } from "../deps/index.ts"; export const mimeTypeNames = Object.keys(mediaTypes.db); diff --git a/telegram-bot/utils/html.ts b/telegram-bot/utils/html.ts index 5646864..35b8bc6 100644 --- a/telegram-bot/utils/html.ts +++ b/telegram-bot/utils/html.ts @@ -1,4 +1,8 @@ -import { AmmoniaBuilder, ammoniaCleanText, ammoniaInit } from "../deps.ts"; +import { + AmmoniaBuilder, + ammoniaCleanText, + ammoniaInit, +} from "../deps/index.ts"; import { ALLOWED_TAGS } from "../constants/index.ts"; const initAmmonia = async () => { diff --git a/telegram-bot/utils/index.ts b/telegram-bot/utils/index.ts index 621c3e5..c78ad82 100644 --- a/telegram-bot/utils/index.ts +++ b/telegram-bot/utils/index.ts @@ -1,5 +1,6 @@ export * from "./auth.ts"; export * from "./callouts.ts"; +export * from "./event-dispatcher.ts"; export * from "./file.test.ts"; export * from "./file.ts"; export * from "./html.test.ts"; diff --git a/telegram-bot/utils/telegram.ts b/telegram-bot/utils/telegram.ts index 0245365..f4cd708 100644 --- a/telegram-bot/utils/telegram.ts +++ b/telegram-bot/utils/telegram.ts @@ -1,9 +1,10 @@ -import type { Context, Message } from "../deps.ts"; +import type { Message } from "../deps/index.ts"; +import type { AppContext } from "../types/index.ts"; /** * Extract the chat id from a context */ -export const getIdentifier = (ctx: Context) => { +export const getIdentifier = (ctx: AppContext) => { const id = ctx.chat?.id || ctx.from?.id; if (!id) { throw new Error("No id found on context");