How I Built A Telegram Quiz Chatbot With BotMan and Laravel
Hey everyone. In this article, I want to show you how I built the Chatbot
@laraquizprobot on telegram with Laravel and BotMan. If you haven't tried it yet, it is a good idea to check it out before reading this article. This will help to understand what I am talking about.
The chatbot provides a quiz with all kinds of questions from four different tracks including Laravel framework, Django, React JS and CSS. Every question comes with up to three possible answers. You need to pick the right one to collect points and make it to the highscore. But besides the ranks, the quiz is about having a good time. Enjoy the questions and see what you know about your favorite framework. Have fun!
Note: All the code can be found in the public repository.
Step 1: Install a fresh laravel application and Botman packages
You can easily integrate Botman into an existing laravel application, but for the sake of this tutorial, let’s install a new laravel application. We can use composer or the laravel installer.
Just run the command below to install it. Since I used Laravel 8 for this, let’s install the same
composer create-project --prefer-dist laravel/laravel quizbot 8.*
Next, let’s install Botman.
composer require botman/botman
Finally, let’s install BotMan Telegram Driver.
composer require botman/driver-telegram
Now we’ve installed everything we need to get started. So next, let’s understand the project we want to create. If you’ve seen the bot in action, then you’ll get it.
We need three models — Track model, Question model, Answer model. We will create others as we need them.
Step 2: Create the Models and migrations.
First, Use the Code below to create the 5 models we need for our application, it will create their migration files along side:
php artisan make:model Track -m
php artisan make:model Question -m
php artisan make:model Answer -m
php artisan make:model Highscore -m
php artisan make:model Played -m
Next, open their Migrations respective and add the codes below to create the tables we need.
// TRACK Migration
Schema::create('tracks', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
// Questions Migration
Schema::create('questions', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->integer('points')->unsigned();
$table->integer('track_id');
$table->timestamps();
});
// Answer Migration
Schema::create('answers', function (Blueprint $table) {
$table->id();
$table->integer('question_id');
$table->text('text');
$table->boolean('correct_one');
$table->timestamps();
});
// Highscore Migration
Schema::create('highscores', function (Blueprint $table) {
$table->id();
$table->integer('chat_id');
$table->string('name');
$table->integer('points')->default(0);
$table->integer('correct_answers')->default(0);
$table->integer('tries')->default(0);
$table->timestamps();
});
// Played Migration
Schema::create('playeds', function (Blueprint $table) {
$table->id();
$table->string('chat_id');
$table->integer('points')->default(0);
$table->timestamps();
});
Step 3: Add Database Seeder and run migrations
I decided to use a Laravel Seeder class to do so. This way I can keep all questions and answers inside the repository and use an artisan command to fill the database.
You can create a seeder separately for this, but I used the default for this. In the database/seeders/DatabaseSeeder.php
,
In the run
method of the seeder, I truncate the tracks table, create the tracks with the addTracks() method, then I loop through each track and add questions and to the questions, I add the answers.
For the actual data, I use a separate getData()
method to keep it cleaner. In this method, I return a big collection with all the questions and answers. For better reading, I only show three of them here. As you can see, the code is quite straightforward. It is just a big array with all the data for questions and answers. Don’t forget to import the namespaces for the Track, Answer
and Question
classes.
Note: You’ll get the entire questions in the public repo.
Now all we need to do is to run migration. Make sure your database details is properly add in the .env
file. Then run the command below to create and seed the tables at once.
php artisan migrate --seed
We now have our tracks, question and answers in our database.
Step 4: Create the BotManController
Now, let’s create a controller that we’ll use to bind our Conversations to our route.
Run the command below in your terminal:
php artisan make:controller BotManController
Let’s open the controller, create a method called handle() and add the following code
<?php
namespace App\Http\Controllers;
use BotMan\BotMan\BotMan;
use BotMan\BotMan\BotManFactory;
use BotMan\BotMan\Cache\LaravelCache;
use BotMan\BotMan\Drivers\DriverManager;
class BotManController extends Controller
{
/**
* Place your BotMan logic here.
*/
public function handle()
{
DriverManager::loadDriver(\BotMan\Drivers\Telegram\TelegramDriver::class);
$config = [
'user_cache_time' => 720,
'config' => [
'conversation_cache_time' => 720,
],
"telegram" => [
"token" => env('TELEGRAM_TOKEN'),
]
];
// // Create BotMan instance
$botman = BotManFactory::create($config, new LaravelCache());
$botman->hears('hello', function (BotMan $bot) {
$bot->reply('Hello yourself.');
});
$botman->listen();
}
}
Before we go further, I want to check if we are set to go. What’s happening in this controller is that I initiated a Botman instance and then set it to listen for ‘hello’, if it does hear ‘hello’, it should respond with ‘hello yourself’.
We have to connect our bot to telegram, and to do this, we need to expose our localhost. When I built this bot, I used BeyondCode’s Expose to share my local site via secure tunnel. you can use laravel valet or services like nginx.
You have to learn how to create a Telegram bot. When you create this, you get a token. Add that token to your .env file as
TELEGRAM_TOKEN=your-telegram-token-here
If you look at our controller, you’ll see that in the $config, we are calling the telegram token from our .env via env(‘TELEGRAM_TOKEN’).
Create your route
Let us quickly create our route. It serves as the starting point of our communication with the bot. inside the routes/web.php
add:
use App\Http\Controllers\BotManController;
Route::match(['get', 'post'], 'botman', [BotManController::class, 'handle']);
Good. We have an endpoint the points to the handle()
method in our BotManController.
Let’s go on to test our setup so far. Make sure you have exposeed your local server through a secure tunnel using valet, nginx or expose, or anyother similar service.
Register your webhook
Then run the artisan command to help you register your webhook:
php artisan botman:telegram:register
Then put in the secure link provided you and add the endpoint we created ‘/botman’ to it. Just like ‘https://your-secure-link.com/botman’. if the response is okay, it means your webhook have been registered to telegram successfully.
Wait!
You need to add the endpoint to the exception in the app/Http/Middleware/VerifyCsrfToken.php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'botman'
];
Now go to telegram, to the bot you created, mine is @laraquizprobot, and type ‘hello’. If you get a reply ‘Hello there’, then you’re good to go.
If you do not get any reply, check the steps to make sure you didn’t miss anything, you can also check the repo to catch up.
Great! Let’s move to the Conversations.
Step 5: Create the Quiz Conversation.
Now that we can communicate with our bot on telegram, let’s create conversations. The first conversation we’ll create will be the Quiz Conversation. All our conversations will live in the app/Conversations
, so create a new file called QuizConversation.php there with this code.
app/Conversations/QuizConversation.php
<?php
namespace App\Conversations;
use BotMan\BotMan\Messages\Conversations\Conversation;
class QuizConversation extends Conversation
{
/**
* Start the conversation.
*
* @return mixed
*/
public function run()
{
}
}
The questions are stored in the database. We want to grab them and ask the user each of them. First off, the user has to select a track, then the bot will introduce the quiz and then start asking questions from that track and show the answers if they are wrong or right. So let’s modify our QuizConversation.
Note: The run() method is the method that runs when the conversation is called.
<?php
namespace App\Conversations;
use App\Models\Track;
use BotMan\BotMan\Messages\Outgoing\Actions\Button;
use BotMan\BotMan\Messages\Conversations\Conversation;
use BotMan\BotMan\Messages\Incoming\Answer as BotManAnswer;
use BotMan\BotMan\Messages\Outgoing\Question as BotManQuestion;
class QuizConversation extends Conversation
{
/** @var Track */
protected $quizTracks;
/** @var Question */
protected $quizQuestions;
/** @var integer */
protected $userPoints = 0;
/** @var integer */
protected $userCorrectAnswers = 0;
/** @var integer */
protected $questionCount;
/** @var integer */
protected $currentQuestion = 1;
/**
* Start the conversation.
*
* @return mixed
*/
public function run()
{
$this->quizTracks = Track::all();
$this->selectTrack();
}
private function selectTrack()
{
$this->say(
"We have " . $this->quizTracks->count() . " tracks. \n You have to choose one to continue.",
['parse_mode' => 'Markdown']
);
$this->bot->typesAndWaits(1);
return $this->ask($this->chooseTrack(), function (BotManAnswer $answer) {
$selectedTrack = Track::find($answer->getValue());
if (!$selectedTrack) {
$this->say('Sorry, I did not get that. Please use the buttons.');
return $this->selectTrack();
}
return $this->setTrackQuestions($selectedTrack);
}, [
'parse_mode' => 'Markdown'
]);
}
}
The first thing the conversation does is to set $this->quizTracks, and then run the selectTrack() method. This method asks the user to choose a track, and displays the number of tracks and the options available. It takes the users answer and based on it, it called the next function $this->setTrackQuestions($selectedTrack) to set the question for the selected track and then move to the next method.
Because of the actions performed here, I will explains some of the key functions. You can grab the entire code of this conversation here
We shuffle the questions and switch the collection’s key to the id. This will make it easier for us to remove items from the collection. Before showing a question, we check if there are any left. After every question we will remove it. When there are no more questions left, we show the user the quiz result.
Here is the askQuestion
method. It always gets the first item of the questions collections and uses the BotMan ask
method to show the text to the user, as well as the answers. We loop over the question's answers, to add a button for each of them to the template. We are also checking if the user’s reply is from a button click. If not, I will repeat this question. This is possible because the buttons have the answer’s id as the value. We use it to find the answer.
When a user completes a quiz, the next conversation is the High-score conversation.
Highscore
The Highscore model will include a few things. First, we define the fillable
property to whitelist what values we can store later. Also, the table name needs to be defined here because it differs from the traditional names.
protected $fillable = ['chat_id', 'name', 'points', 'correct_answers', 'tries'];
protected $table = 'highscore';
Then we add a method for storing highscore entries. I use the updateOrCreate
method to prevent duplicate entries. Every user should only have one entry. With every new try, the highscore will get updated. The unique field for these entries is the chat_id
and not, like normally, the id
field. The updateOrCreate
method gets this info as the first parameter. Also note how I increase the tries
field if the entry is not a new one. For this, the wasRecentlyCreated
method comes in handy.
public static function saveUser(UserInterface $botUser, int $userPoints, int $userCorrectAnswers)
{
$user = static::updateOrCreate(['chat_id' => $botUser->getId()], [
'chat_id' => $botUser->getId(),
'name' => $botUser->getFirstName().' '.$botUser->getLastName(),
'points' => $userPoints,
'correct_answers' => $userCorrectAnswers,
]);
$user->increment('tries'); $user->save(); return $user;
}
At the end of the quiz, we show the user his rank. This is what the next method is for. We count how many users have more points than the current one. We are only interested in the unique values because users with the same count will share a rank.
public function getRank()
{
return static::query()->where('points', '>', $this->points)->pluck('points')->unique()->count() + 1;
}
For the highscore, only the top 10 users will be shown.
public static function topUsers($size = 10)
{
return static::query()->orderByDesc('points')->take($size)->get();
}
We fetch the users with the most points and add a rank field to every entry.
Here’s our final Highscore Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use BotMan\BotMan\Interfaces\UserInterface;
class Highscore extends Model
{
use HasFactory;
protected $fillable = ['chat_id', 'name', 'points', 'correct_answers', 'tries'];
public static function saveUser(UserInterface $botUser, int $userPoints, int $userCorrectAnswers)
{
$user = static::updateOrCreate(['chat_id' => $botUser->getId()], [
'chat_id' => $botUser->getId(),
'name' => $botUser->getFirstName().' '.$botUser->getLastName(),
'points' => $userPoints,
'correct_answers' => $userCorrectAnswers,
]);
$user->increment('tries');
$user->save();
return $user;
}
public function getRankAttribute()
{
return static::query()->where('points', '>', $this->points)->pluck('points')->unique()->count() + 1;
}
public static function topUsers($size = 15)
{
return static::query()->orderByDesc('points')->take($size)->get();
}
public static function deleteUser(string $chatId)
{
Highscore::where('chat_id', $chatId)->delete();
}
}
Showing the highscore
To show our highscore, we create a conversation for it at app/Conversations/HighscoreConversation.php
<?php
namespace App\Conversations;
use App\Models\Highscore;
use BotMan\BotMan\Messages\Outgoing\Actions\Button;
use BotMan\BotMan\Messages\Conversations\Conversation;
use BotMan\BotMan\Messages\Incoming\Answer as BotManAnswer;
use BotMan\BotMan\Messages\Outgoing\Question as BotManQuestion;
class HighscoreConversation extends Conversation
{
/**
* Start the conversation.
*
* @return mixed
*/
public function run()
{
$this->showHighscore();
}
private function showHighscore()
{
$topUsers = Highscore::topUsers();
if (! $topUsers->count()) {
return $this->say('The highscore is still empty. Be the first one! 👍');
}
$topUsers->transform(function ($user) {
return "_{$user->rank} - {$user->name}_ *{$user->points} points*";
});
$this->say('Here is the current highscore showing the top 15 results.');
$this->bot->typesAndWaits(1);
$this->say('🏆 HIGHSCORE 🏆');
$this->bot->typesAndWaits(1);
$this->say($topUsers->implode("\n"), ['parse_mode' => 'Markdown']);
$this->bot->typesAndWaits(2);
$this->say("If you want to play another round click: /start \nOne of the ways to improve what you know about Laravel is by going hrough their documentation at https://laravel.com/docs/.");
}
}
How to initiate a conversation
To trigger this and other conversations, we need listeners. Besides the start
keyword I also add /start
which will become a Telegram command later. I’ll do same for highscore
.
// appHttp/Controllers/BotManController.php
$botman->hears('start|/start', function (BotMan $bot) {
$bot->startConversation(new QuizConversation());
})->stopsConversation();
$botman->hears('/highscore|highscore', function (BotMan $bot) {
$bot->startConversation(new HighscoreConversation());
})->stopsConversation();
To test, you can type one of the keywords to the bot to see what happens. It will tell you that the highscore is still empty.
Delete User Data
Since the GDPR it is essential to give the user the possibility to delete his stored data. I want to handle that in a new app/Conversations/PrivacyConversation.php
class. First we make sure we have stored this current user. If so, we ask him if he really wants to delete his highscore entry.
<?phpnamespace App\Conversations;use App\Highscore;
use BotMan\BotMan\Messages\Incoming\Answer;
use BotMan\BotMan\Messages\Outgoing\Question;
use BotMan\BotMan\Messages\Outgoing\Actions\Button;
use BotMan\BotMan\Messages\Conversations\Conversation;class PrivacyConversation extends Conversation
{
/**
* Start the conversation.
*
* @return mixed
*/
public function run()
{
$this->askAboutDataDeletion();
} private function askAboutDataDeletion()
{
$user = Highscore::where('chat_id', $this->bot->getUser()->getId())->first();
if (! $user) {
return $this->say('We have not stored any data of you.');
}
$this->say('We have stored your name and chat ID for showing you in the highscore.');
$question = Question::create('Do you want to get deleted?')->addButtons([
Button::create('Yes please')->value('yes'),
Button::create('Not now')->value('no'),
]);
$this->ask($question, function (Answer $answer) {
switch ($answer->getValue()) {
case 'yes':
Highscore::deleteUser($this->bot->getUser()->getId());
return $this->say('Done! Your data has been deleted.');
case 'no':
return $this->say('Great to keep you 👍');
default:
return $this->repeat('Sorry, I did not get that. Please use the buttons.');
}
});
}
}
The deleteUser
is a new Highscore model method you have to add.
public static function deleteUser(string $chatId)
{
Highscore::where('chat_id', $chatId)->delete();
}
About
If someone wants to know more about this bot, I will give him the possibility by adding some about info. Since I only return one little text message, I will handle it inside the listener.
$botman->hears('/about|about', function (BotMan $bot) {
$bot->reply('This is a BotMan and Laravel 8 project by Ejimadu Prevail.');
})->stopsConversation();
Our Controller will now look like this :
<?php
namespace App\Http\Controllers;
use BotMan\BotMan\BotMan;
use App\Conversations\QuizConversation;
use App\Conversations\PrivacyConversation;
use App\Conversations\HighscoreConversation;
use App\Http\Middleware\PreventDoubleClicks;
use BotMan\BotMan\BotManFactory;
use BotMan\BotMan\Cache\LaravelCache;
use BotMan\BotMan\Drivers\DriverManager;
class BotManController extends Controller
{
/**
* Place your BotMan logic here.
*/
public function handle()
{
DriverManager::loadDriver(\BotMan\Drivers\Telegram\TelegramDriver::class);
$config = [
'user_cache_time' => 720,
'config' => [
'conversation_cache_time' => 720,
],
"telegram" => [
"token" => env('TELEGRAM_TOKEN'),
]
];
// // Create BotMan instance
$botman = BotManFactory::create($config, new LaravelCache());
$botman->middleware->captured(new PreventDoubleClicks);
$botman->hears('start|/start', function (BotMan $bot) {
$bot->startConversation(new QuizConversation());
})->stopsConversation();
$botman->hears('/highscore|highscore', function (BotMan $bot) {
$bot->startConversation(new HighscoreConversation());
})->stopsConversation();
$botman->hears('/about|about', function (BotMan $bot) {
$bot->reply('This is a BotMan and Laravel 8 project by Ejimadu Prevail.');
})->stopsConversation();
$botman->hears('/deletedata|deletedata', function (BotMan $bot) {
$bot->startConversation(new PrivacyConversation());
})->stopsConversation();
$botman->listen();
}
}
Conclusion
This article got longer than I expected, but there’s still a lot to cover on how to build dynamic chatbots using PHP. This article will help you to build your own chatbot as it covered most of the concepts in BotMan 2.0. You can always check out the repo, and try out different things as much as possible.
Stay tuned!!! I will be back with some more cool Laravel tutorials in the next article. I hope you liked the article. Don’t forget to follow me 😇 and give some clap 👏. And if you have any questions feel free to comment.
Thank you.
Thanks a lot for reading till end. Follow or contact me via:
Email: prevailexcellent@gmail.com
Github: https://github.com/PrevailExcel
LinkedIn: https://www.linkedin.com/in/chimeremeze-prevail-ejimadu-3a3535219
Twitter: https://twitter.com/EjimaduPrevail
BuyMeCoffee: https://www.buymeacoffee.com/prevail