Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: State Machine #26

Merged
merged 18 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions telegram-bot/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions telegram-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion telegram-bot/areas/core.area.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 40 additions & 0 deletions telegram-bot/commands/debug.command.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
41 changes: 41 additions & 0 deletions telegram-bot/commands/help.command.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions telegram-bot/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
41 changes: 26 additions & 15 deletions telegram-bot/commands/list.command.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
72 changes: 72 additions & 0 deletions telegram-bot/commands/reset.command.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
49 changes: 31 additions & 18 deletions telegram-bot/commands/show.command.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
}
}
Loading