Chatterbox: A Comprehensive Framework for Building Dialogs with Telegram Chatbots

Danny Vien
Dev Artel
Published in
5 min readJan 18, 2024

Intro

Chatbots are becoming increasingly popular because users can interact with them within the app they are already using. That’s why building a user-facing product as a chatbot is a good idea for product validation.

Developing a chatbot requires less time to test your idea since there is no need to create a UI. Instead, you can focus on implementing a dialog flow.

We created Chatterbox to simplify complex dialog flows and maintain context.

Problem

The most straightforward method of communicating with the Telegram Bot API is by utilizing their REST API. However, this can be challenging for the average developer and may impede the development process. To accelerate development, API wrappers such as Televerse or Teledart can be used. These wrappers do not, however, offer high-level abstractions for intricate dialog flows.

Solution

Check this article on how to set up a Telegram bot and connect it to a local machine with ngrok.

Let’s start with concept and components of Chatterbox. The base building block of a Chatterbox app is a Flow. A Flow is a conversation branch that can be triggered by one of the following events:

  • Command (starts with ‘/’)
  • Arbitrary user input
  • Redirect from another Flow

A Flow consists of Steps. For example, in an onboarding Flow, a chatbot may gather a user's email, name, and age. This Flow consists of three Steps, which can be triggered by user input or by another Step, and produce a chatbot Reaction.

Reaction types:

  • None – no reaction
  • Response – send a message to a user
  • Redirect – invoke another Step or Flow
  • Invoice – issue an invoice for a user
  • Composed – combines several reactions

Steps are identified and can be linked by URIs. URI is inferred from Step name (for that reason every step in your program must use unique name) and might include arguments. Telegram enforces limit of 64 bytes for such data. To produce a URI use extension method toStepUri:

(MyStep).toStepUri(["param1", "param2"])

Notice, that all arguments are strings. They can be received in MyStep handle method:

Future<Reaction> handle(MessageContext messageContext, [List<String>? args])

Example

Game concept

To illustrate key concepts, let’s develop a simple yet fun and enjoyable game. The game rules are very simple:

In the game of 21 Sticks, two players take turns removing 1 to 3 sticks from a total of 21. The player who takes the last stick loses.

The game can be implemented as a single Chatterbox Flow:

Game Flow Diagram

Detailed description

Chatterbox is initialized by following params:

  • botToken— see Telegram documentation to get one
  • flows— an array of flows, we’ll create one below
  • store — a way to keep conversation context between invocations. For this example we’ll use InMemoryStore(), kindly provided by the library
final flows = <Flow>[ CountdownGameFlow()];
Chatterbox(botToken: botToken, flows: flows, store: InMemoryStore()).invokeFromWebhook(json);

Flow is simply an array of steps

class TwentyOneSticksGameFlow extends Flow {
@override
List<StepFactory> get steps => [
//todo
];
}

Now we’ll be adding those steps one by one:

  • The flow begins with StartStep, which greets the user and redirects to a User step. To greet the user and begin waiting for their turn, we combine two reactions using ReactionComposed. ReactionRedirect then redirects the execution flow to a provided Step.
class StartStep extends FlowStep {
@override
Future<Reaction> handle(MessageContext messageContext, [List<String>? args]) async {
return ReactionComposed(responses: [
ReactionResponse(
text:
"In the game of 21 Sticks, two players take turns removing 1 to 3 sticks from a total of 21. The player forced to take the last stick loses.",
),
ReactionRedirect(stepUri: (_UserTurnStep).toStepUri())
]);
}
}
  • The next Step is to wait for user keyboard input. An inline keyboard displays buttons under the message text. The InlineButton receives a nextStepUri parameter that defines the Step to be invoked on a button click. Also, note the array of parameters that are passed to the next Step via a URI.
class _UserTurnStep extends FlowStep {
@override
Future<Reaction> handle(MessageContext messageContext, [List<String>? args]) async {
final number = (args?.firstOrNull ?? '21');

return ReactionResponse(
text: 'There are $number sticks.\\n\\nHow many sticks do you want to take?',
buttons: [
InlineButton(title: 'One', nextStepUri: (_GameProcessingStep).toStepUri([_Player.user.name, number, '1'])),
InlineButton(title: 'Two', nextStepUri: (_GameProcessingStep).toStepUri([_Player.user.name, number, '2'])),
InlineButton(title: 'Three', nextStepUri: (_GameProcessingStep).toStepUri([_Player.user.name, number, '3'])),
].sublist(0, min(3, int.parse(number))));
}
}
  • Once a user submits input, the _GameProcessingStep is triggered. First, we retrieve the passed parameters and determine if the game has finished or can be continued. It is important to note that we use ReactionComposed again, but this time to edit a previous message with buttons that display the user's choice in the same message, rather than submitting to new messages. We then pass control flow to the next step. If a winner is determined, the _OnPlayerWonStep is invoked by passing the winner as a parameter. Otherwise, the game continues.
class _GameProcessingStep extends FlowStep {
@override
Future<Reaction> handle(MessageContext messageContext, [List<String>? args]) async {
final actor = _Player.values.byName(args!.first);
final originalNumber = int.parse(args.elementAtOrNull(1)!);
final playerChoice = int.parse(args.elementAtOrNull(2)!);

final sticksLeft = originalNumber - playerChoice;

final reaction = switch (sticksLeft) {
1 => ReactionRedirect(stepUri: (_OnPlayerWonStep).toStepUri([actor.name])),
0 => ReactionRedirect(stepUri: (_OnPlayerWonStep).toStepUri([actor.getOpponent.name])),
_ => ReactionRedirect(stepUri: (_GameLooperStep).toStepUri([actor.name, '$sticksLeft'])),
};

return ReactionComposed(responses: [
if (actor == _Player.user)
ReactionResponse(
text: 'There are $originalNumber sticks.\\n\\nYou took out $playerChoice sticks.',
editMessageId: messageContext.editMessageId,
),
reaction,
]);
}
}
  • The purpose of GameLooperStep is to determine whose turn it is:
class _GameLooperStep extends FlowStep {
@override
Future<Reaction> handle(MessageContext messageContext, [List<String>? args]) async {
final actor = _Player.values.byName(args!.first);
final sticksLeft = int.parse(args.elementAtOrNull(1)!);

return switch (actor) {
_Player.user => ReactionRedirect(stepUri: (_BotTurnStep).toStepUri(['$sticksLeft'])),
_Player.bot => ReactionRedirect(stepUri: (_UserTurnStep).toStepUri(['$sticksLeft'])),
};
}
}
  • BotTurnStep implements the logic for the bot to draw a random number of sticks. To prevent the bot from drawing more sticks than are available, the min() function is used.
class _BotTurnStep extends FlowStep {
@override
Future<Reaction> handle(MessageContext messageContext, [List<String>? args]) async {
final sticksLeft = int.parse(args!.first);

final botTurn = Random().nextInt(min(3, sticksLeft)) + 1;

await Future.delayed(Duration(milliseconds: 300));

return ReactionComposed(responses: [
ReactionResponse(
text: 'There are $sticksLeft sticks.\\n\\nBot takes out $botTurn sticks.',
),
ReactionRedirect(
stepUri: (_GameProcessingStep).toStepUri(['bot', sticksLeft.toString(), botTurn.toString()]),
),
]);
}
}
  • Finally, the last Step is to congratulate the winner or mock the loser.
class _OnPlayerWonStep extends FlowStep {
@override
Future<Reaction> handle(MessageContext messageContext, [List<String>? args]) async {
final actor = _Player.values.byName(args!.first);

final text = switch (actor) {
_Player.user => 'Congratulations, you won! 🎉✨',
_Player.bot => 'Oh, no, you are a looser! 💩😱',
};

return ReactionComposed(responses: [
ReactionResponse(
text: text,
),
]);
}
}

Outro

Investigate the libraries code or run the example yourself.

Or jump directly to Telegram to play amazing game of sticks with AmazingGameOfSticksBot!

Thanks for your attention. Let us know if you need the library in some other language.

--

--

Danny Vien
Dev Artel

Entrepreneur, software developer, crypto enthusiast and sarcasm evangelist