A webhook driven handler for Telegram Bots
Requirements
Steps
git clone https://github.com/ismailian/bot-web-handler my-botcd my-botcomposer installcp .env.sample .env
Configurations
- domain url
APP_DOMAIN - bot token
BOT_TOKEN - webhook secret
TG_WEBHOOK_SIGNATURE(Optional) - Telegram source IP
TG_WEBHOOK_SOURCE_IP(Optional)- I don't recommend setting this, because the Telegram IP will definitely change.
- routes - routes to accept requests from (Optional)
- whitelist - list of allowed user ids (Optional)
- blacklist - list of disallowed user ids (Optional)
| Command | Description |
|---|---|
php cli update:check |
check for available updates |
php cli update:apply |
apply available updates |
php cli webhook:set [uri] |
set bot webhook (URI is optional) |
php cli webhook:unset |
unset bot webhook |
php cli migrate <table> |
migrate tables (users, events, sessions) |
php cli queue:init |
create queue table + jobs directory |
php cli queue:work |
run queue |
/**
* handle all incoming photos
*
* @param IncomingPhoto $photo
* @return void
*/
#[Photo]
public function photos(IncomingPhoto $photo): void
{
// access 3 sizes of the photo (small, medium, large)
$smallPhotoFileId = $photo->small->fileId;
// or iterate through each one:
$photo->each(function (PhotoSize $photoSize) {
$photoSize->save(directory: 'tmp/images');
});
}/**
* handle all incoming videos
*
* @param IncomingVideo $video
* @return void
*/
#[Video]
public function videos(IncomingVideo $video): void
{
echo '[+] File ID: ' . $video->fileId;
// to download video
$video->save(
filename: 'video.mp4', // optional
directory: '/path/to/save/video' // optional
);
}/**
* handle start command
*
* @return void
*/
#[Command('start')]
public function onStart(IncomingCommand $command): void
{
$this->telegram->sendMessage('welcome!');
}/**
* handle incoming callback query
*
* @param IncomingCallbackQuery $query
* @return void
*/
#[CallbackQuery('game:type')]
public function callbacks(IncomingCallbackQuery $query): void
{
echo '[+] response: ' . $query->data['game:type'];
}/**
* handle incoming text from private chats
*
* @return void
*/
#[Text]
#[Chat(Chat::PRIVATE)]
public function text(IncomingMessage $message): void
{
echo '[+] user sent: ' . $message->text;
}/**
* handle incoming text from specific user/users
*
* @return void
*/
#[Text]
#[Only(userId: '<id>', userIds: [...'<id>'])]
public function text(IncomingMessage $message): void
{
echo '[+] user sent: ' . $message->text;
}- we would set the
inputvalue toagein order to be able to intercept it later - remove the
inputproperty from the session once you've used it, otherwise any text message containing a number will be captured by the handler. new NumberValidatoris used to ensure that the handler only intercepts numeric text messages
/**
* handle incoming age command
*
* @return void
*/
#[Command('age')]
public function age(): void
{
session()->set('input', 'age');
$this->telegram->sendMessage('Please type in your age:');
}
/**
* handle incoming user input
*
* @return void
*/
#[Awaits('input', 'age')]
#[Text(Validator: new NumberValidator())]
public function setAge(IncomingMessage $message): void
{
$age = $message->text;
session()->unset('input');
}- Create invoice and send it to users
- Answer pre-checkout query by confirming
the productis available - Process successful payments
/**
* handle incoming purchase command
*
* @return void
*/
#[Command('purchase')]
public function purchase(): void
{
$this->telegram->sendInvoice(
title: 'Product title',
description: 'Product description',
payload: 'data for your internal processing',
prices: [
['label' => 'Product Name', 'amount' => 100]
],
currency: 'USD',
providerToken: 'Token assigned to you after linking your stripe account with telegram'
);
}
/**
* handle an incoming pre-checkout query
*
* @param IncomingPreCheckoutQuery $preCheckoutQuery
* @return void
*/
#[PreCheckoutQuery]
public function preCheckout(IncomingPreCheckoutQuery $preCheckoutQuery): void
{
$this->telegram->answerPreCheckoutQuery(
queryId: $preCheckoutQuery->id,
ok: true, // true if ok, otherwise false
errorMessage: 'if you have any errors'
);
}
/**
* handle incoming successful payment
*
* @return void
*/
#[SuccessfulPayment]
public function paid(IncomingSuccessfulPayment $successfulPayment): void
{
// save payment info and send a thank you message
}Currently, the queue only uses database to manage jobs, in the future, other methods will be integrated.
- run migration:
php cli queue:init - run queue worker:
php cli queue:work - create job:
typically, you would create the job in the
App\Jobsdirectory where your jobs will live. Job classes must implement theIJobinterface.
use TeleBot\System\Core\Queuable;
use TeleBot\System\Interfaces\IJob;
readonly class UrlParserJob implements IJob
{
use Queuable;
/**
* @inheritDoc
*/
public function __construct(protected int $id, protected array $data) {}
/**
* @inheritDoc
*/
public function process(): void
{
// process your data
}
}/**
* handle incoming urls
*
* @return void
*/
#[Url]
public function urls(IncomingUrl $url): void
{
// dispatch job using:
UrlParserJob::dispatch(['url' => $url]);
// or:
queue()->dispatch(UrlParserJob::class, [
'url' => $url
]);
$this->telegram->sendMessage('Your url is being processed!');
}In config.php, you can configure your routes to handle other requests.
/**
* @var array $routes allowed routes
*/
'routes' => [
'web' => [
// simple route
'GET /' => 'Home::index',
// grouped routes
'/api' => [
'GET /health-check' => 'HealthCheck::index',
'POST /whitelist' => 'Whitelist::update',
],
// routes with params
'/posts/{postId}/comments' => 'Posts::viewComments',
]
],Middlewares are meant to intercept incoming requests before hitting the final handler.
To create a middleware, simply add a new class to the App\Middlewares directory and implement the IMiddleware interface.
This example demonstrates how to verify that the http request is coming from the admin.
Example Web Handler:
use TeleBot\App\Middlewares\IsAdmin;
/**
* list all users
*
* @return void
*/
#[Middleware(IsAdmin::class)]
public function users(): void
{
// some logic to fetch users
$users = [];
response()->send(['users' => $users], true);
}IsAdmin middleware:
/**
* check if request is coming from the admin
*
* @return void
*/
public function __invoke(): void
{
$apiKey = response()->headers('X-Admin-Api-Key');
if (empty($apiKey)) {
response()->setStatusCode(401)->end();
}
$secret = env('ADMIN_API_KEY');
if (!hash_equals($secret, $apiKey)) {
response()->setStatusCode(401)->end();
}
}Use Cache or cache() to access the cache interface. Data can be stored globally or per user.
Globally:
if (($weatherData = cache()->get('weather_data'))) {
response()->json($weatherData);
}
$weatherData = []; // get data from API
cache()->remember('weather_data', $weatherData);
response()->json($weatherData);Per User:
$cacheKey = request()->fingerprint();
if (($weatherData = cache()->get($cacheKey))) {
response()->json($weatherData);
}
$weatherData = []; // get data from API
cache()->remember($cacheKey, $weatherData);
response()->json($weatherData);Cache with expiration date (example: 1 hour):
$cacheKey = request()->fingerprint();
if (($weatherData = cache()->get($cacheKey))) {
response()->json($weatherData);
}
$weatherData = []; // get data from API
cache()->remember($cacheKey, $weatherData, 'PT1H');
response()->json($weatherData);You can configure maintenance mode by setting MAINTENANCE_MODE in the env file to DOWN
and set a handler in config.php like this example:
/**
* @var string|callable $maintenance handler to trigger when maintenance mode is enabled
*/
'maintenance' => 'Maintenance::handle',You can configure Cors in config.php for acceptable domains like this example:
/**
* @var array $cors CORS configurations
*/
'cors' => [
'example1.com' => [
'methods' => ['GET', 'POST', 'OPTIONS'],
'headers' => ['Accept', 'Authorization'],
'allow_credentials' => true,
],
'example2.net' => [
'methods' => '*',
'headers' => '*',
],
],| VAR | Description |
|---|---|
THROTTLE_DRIVER |
can be (filesystem, database, redis) |
THROTTLE_DIR |
directory to store rate limit data (only for Filesystem) |
THROTTLE_MAX_REQS |
maximum number of requests. Default: 60 |
THROTTLE_WINDOW |
window in seconds. Default: 60 |
# throttle
THROTTLE_DRIVER=filesystem
THROTTLE_DIR=path/to/folder
THROTTLE_MAX_REQS=60
THROTTLE_WINDOW=60/**
* @var array $throttle rate limit rules
*/
'throttle' => [
'rules' => [
[
'window' => 60, // remove to use the default value: 60
'max_requests' => 3, // remove to use the default value: 60
'route' => 'GET /api/users', // remove to have this rule applied globally
],
],
'handler' => fn(RateLimitResult $result) => response()
->setStatusCode(429)
->json([
'status' => 429,
'error' => 'Too many requests.',
])
],/**
* @var array $routes allowed routes
*/
'routes' => [
'web' => [
'/api' => [
'POST /auth' => throttled('Auth::login', requests: 5, window: 60),
'GET /users' => throttled('Users::index', requests: 5, window: 1),
],
],
],