Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jonnywilliamson/adaab6eae574b0d7318b96f40dc3a9e5 to your computer and use it in GitHub Desktop.

Select an option

Save jonnywilliamson/adaab6eae574b0d7318b96f40dc3a9e5 to your computer and use it in GitHub Desktop.
Laravel Nutgram multi-bot reference implementation

Laravel Nutgram Multi-Bot Reference

This is a cleaned-up reference implementation for running multiple Nutgram bots inside one Laravel application.

It is not a package. It is a small set of app-level files showing one approach that has worked in production for a Laravel app with several Telegram bots.

What This Solves

  • Configure multiple bot tokens in config/nutgram.php.
  • Resolve bots by name through NutgramManager.
  • Share Nutgram package configuration across all bot instances.
  • Use one webhook route: /api/nutgram/{bot}.
  • Set or remove webhooks per bot with an Artisan command.
  • Support safe-mode webhook secret validation.
  • Keep test mode fake-friendly.
  • Optionally register command menus for every configured bot.

Versions

This reference was extracted from an app using:

  • nutgram/nutgram 4.x
  • nutgram/laravel 1.x
  • Laravel 11+ with the Illuminate\Support\defer helper
  • PHP 8.1+ for the core files

The core multi-bot manager should still be useful as a starting point for Nutgram 5, but the optional command-menu registrar depends on Nutgram 4's extendable SergiX44\Nutgram\Handlers\Type\Command pattern.

Files

  • config/nutgram.php Sanitized multi-bot config.

  • app/Telegram/Manager/NutgramManager.php The central resolver/factory for named bot instances.

  • routes/api.php A single webhook endpoint using {bot} as the bot selector.

  • routes/telegram.php Centralized handler registration loaded for each named bot.

  • app/Console/Commands/NutgramWebhookCommand.php Sets or removes webhooks for one named bot.

  • app/Telegram/Support/HandleNutgramWebhookFailure.php Logs webhook bootstrap failures and can notify an admin without resolving Nutgram.

  • optional-v4/RegisterBotCommandsCommand.php Optional Nutgram 4 command-menu registrar.

Example Config

NUTGRAM_DEFAULT_BOT=main

TELEGRAM_BOT_MAIN_TOKEN=123:abc
TELEGRAM_BOT_MAIN_BOTNAME=ExampleMainBot

TELEGRAM_BOT_ADMIN_TOKEN=456:def
TELEGRAM_BOT_ADMIN_BOTNAME=ExampleAdminBot

TELEGRAM_ADMIN_CHAT_ID=123456789
NUTGRAM_SAFE_MODE=true
NUTGRAM_WEBHOOK_SECRET=change-me-to-a-long-random-token

You can also configure per-bot webhook secrets, such as TELEGRAM_BOT_MAIN_WEBHOOK_SECRET. If no explicit secret is configured, the manager derives a stable bot-specific secret from APP_KEY. Rotating APP_KEY will change derived webhook secrets, so re-run the webhook command after an app key rotation.

Example Usage

app(NutgramManager::class)->bot('main')->sendMessage(
    chat_id: 123456789,
    text: 'Hello from the main bot',
);

app(NutgramManager::class)->bot('admin')->sendMessage(
    chat_id: 123456789,
    text: 'Hello from the admin bot',
);

Set webhooks:

php artisan nutgram:webhook main
php artisan nutgram:webhook admin

Or provide an explicit URL during local tunneling:

php artisan nutgram:webhook main --url=https://example.ngrok-free.app/api/nutgram/main

If you include the optional Nutgram 4 command-menu registrar:

php artisan nutgram:register-bot-commands
php artisan nutgram:register-bot-commands main

Handler Registration

In the app this was extracted from, handler registration is centralized in routes/telegram.php and loaded once per bot instance:

// routes/telegram.php

use SergiX44\Nutgram\Nutgram;

/** @var Nutgram $bot */

// Replace these placeholders with your own app handlers.

$bot->onCommand('start', StartCommand::class)
    ->description('Start the bot')
    ->insensitive();

$bot->onText('Cancel', CancelHandler::class);

The manager injects $bot before requiring that file, so the same route file is applied to each named bot.

The webhook route in this gist uses Laravel's throttle middleware. Tune the limit for your traffic and hosting setup.

The namespaces are intentionally mixed: nutgram/laravel exposes Nutgram\Laravel\..., while the core package exposes SergiX44\Nutgram\....

Notes For Nutgram 5

Nutgram 5 changes the command-registration model. If you use this with Nutgram 5, prefer registering app command classes via onCommand(...) and keep command metadata in your own app-level structure instead of extending Nutgram's internal command class.

The multi-bot parts of this reference are separate from that command-class change:

  • named bot resolution
  • config cloning
  • webhook routing
  • webhook management
  • fake/testing behavior

Those ideas should still transfer.

<?php
use App\Telegram\Manager\NutgramManager;
use App\Telegram\Support\HandleNutgramWebhookFailure;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use function Illuminate\Support\defer;
Route::prefix('nutgram')->group(function (): void {
Route::name('nutgram.webhook')->post('{bot}', function (
string $bot,
NutgramManager $manager,
HandleNutgramWebhookFailure $onFailure,
) {
if (! $manager->has($bot)) {
Log::error('Webhook received for unconfigured Nutgram bot.', [
'bot' => $bot,
'configured_bots' => $manager->configured(),
'ip' => request()->ip(),
]);
return response()->noContent();
}
defer(function () use ($bot, $manager, $onFailure): void {
try {
$manager->bot($bot)->run();
} catch (Throwable $exception) {
$onFailure($bot, $exception);
}
});
return response()->noContent();
})->middleware('throttle:60,1');
});
<?php
namespace App\Telegram\Support;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
class HandleNutgramWebhookFailure
{
public function __invoke(string $bot, Throwable $exception): void
{
Log::error('Nutgram webhook bootstrap or dispatch failed.', [
'bot' => $bot,
'exception_class' => get_class($exception),
'message' => $exception->getMessage(),
]);
$token = config("nutgram.bots.{$bot}.token");
$adminId = config('nutgram.admin_id');
if (! $token || ! $adminId) {
return;
}
$message = Str::of($exception->getMessage())
->replaceMatches('/[[:cntrl:]]+/', ' ')
->limit(500)
->toString();
Http::timeout(5)->post("https://api.telegram.org/bot{$token}/sendMessage", [
'chat_id' => $adminId,
'text' => "Nutgram bot [{$bot}] failed during webhook dispatch: {$message}",
]);
}
}
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Bot Token
|--------------------------------------------------------------------------
|
| The Nutgram Laravel service provider still creates a default singleton.
| NutgramManager clones that singleton's configuration for named bots.
|
*/
'token' => env('TELEGRAM_BOT_MAIN_TOKEN'),
/*
|--------------------------------------------------------------------------
| Default Bot Name
|--------------------------------------------------------------------------
*/
'default' => env('NUTGRAM_DEFAULT_BOT', 'main'),
/*
|--------------------------------------------------------------------------
| Admin Notifications
|--------------------------------------------------------------------------
|
| Used by HandleNutgramWebhookFailure to send a bot-free notification if
| Nutgram bootstrap or handler registration fails.
|
*/
'admin_id' => env('TELEGRAM_ADMIN_CHAT_ID'),
/*
|--------------------------------------------------------------------------
| Bot Configurations
|--------------------------------------------------------------------------
*/
'bots' => [
'main' => [
'token' => env('TELEGRAM_BOT_MAIN_TOKEN'),
'bot_name' => env('TELEGRAM_BOT_MAIN_BOTNAME'),
'webhook_secret' => env('TELEGRAM_BOT_MAIN_WEBHOOK_SECRET'),
],
'admin' => [
'token' => env('TELEGRAM_BOT_ADMIN_TOKEN'),
'bot_name' => env('TELEGRAM_BOT_ADMIN_BOTNAME'),
'webhook_secret' => env('TELEGRAM_BOT_ADMIN_WEBHOOK_SECRET'),
],
],
/*
|--------------------------------------------------------------------------
| Safe Mode
|--------------------------------------------------------------------------
|
| When enabled, incoming webhook requests are validated using Telegram's
| X-Telegram-Bot-Api-Secret-Token header. Prefer per-bot secrets above.
| If no explicit secret is configured, NutgramManager derives one from the
| app key and bot name.
|
*/
'safe_mode' => env('NUTGRAM_SAFE_MODE', env('APP_ENV') === 'production'),
'webhook_secret' => env('NUTGRAM_WEBHOOK_SECRET'),
/*
|--------------------------------------------------------------------------
| Package Settings
|--------------------------------------------------------------------------
|
| Disable auto-loading if you want NutgramManager to register handlers for
| each named bot instance.
|
*/
'routes' => false,
'mixins' => false,
'namespace' => app_path('Telegram/NutgramCommands'),
/*
|--------------------------------------------------------------------------
| Advanced Nutgram Configuration
|--------------------------------------------------------------------------
*/
'config' => [
'timeout' => 60,
],
];
<?php
namespace App\Telegram\Manager;
use Illuminate\Contracts\Foundation\Application;
use InvalidArgumentException;
use Nutgram\Laravel\RunningMode\LaravelWebhook;
use SergiX44\Nutgram\Configuration;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Testing\FakeNutgram;
class NutgramManager
{
/** @var array<string, Nutgram> */
protected array $bots = [];
protected bool $recording = false;
protected bool $allowReal = false;
public function __construct(
protected Application $app,
) {}
public function bot(?string $name = null): Nutgram
{
$name ??= config('nutgram.default');
if (! isset($this->bots[$name])) {
$this->bots[$name] = $this->createBot($name);
}
return $this->bots[$name];
}
protected function createBot(string $name): Nutgram
{
$token = config("nutgram.bots.{$name}.token");
$shouldFake = ($this->recording || $this->app->runningUnitTests()) && ! $this->allowReal;
if (! $token && ! $shouldFake) {
throw new InvalidArgumentException("Bot [{$name}] is not configured or missing token.");
}
// Test fakes do not need a token, which lets tests exercise named bots without real credentials.
$configuration = $this->cloneConfiguration($name);
$bot = $shouldFake
? Nutgram::fake(config: $configuration)
: new Nutgram($token, $configuration);
if (! $this->app->runningInConsole() && ! $this->recording) {
$this->configureWebhookMode($bot, $name);
}
$this->registerHandlers($bot);
return $bot;
}
protected function cloneConfiguration(string $name): Configuration
{
/** @var Nutgram $defaultBot */
$defaultBot = $this->app->make(Nutgram::class);
$config = $defaultBot->getConfig()->toArray();
if ($defaultBot instanceof FakeNutgram) {
$config['api_url'] = config('nutgram.config.api_url', Configuration::DEFAULT_API_URL);
if (is_array($config['client'] ?? null)) {
// FakeNutgram injects a mock handler and base URI; real bot instances must use normal HTTP options.
unset($config['client']['handler'], $config['client']['base_uri']);
}
}
$botName = config("nutgram.bots.{$name}.bot_name");
if ($botName) {
$config['bot_name'] = $botName;
}
return Configuration::fromArray($config);
}
protected function configureWebhookMode(Nutgram $bot, string $name): void
{
$webhook = LaravelWebhook::class;
if (config('nutgram.safe_mode', false)) {
$webhook = new LaravelWebhook(
getToken: fn () => request()?->header('X-Telegram-Bot-Api-Secret-Token'),
secretToken: $this->webhookSecret($name),
);
$webhook->setSafeMode(true);
}
$bot->setRunningMode($webhook);
}
public function webhookSecret(string $name): string
{
$secret = config("nutgram.bots.{$name}.webhook_secret") ?: config('nutgram.webhook_secret');
if ($secret) {
return (string) $secret;
}
return hash_hmac('sha256', "nutgram:webhook:{$name}", (string) config('app.key'));
}
protected function registerHandlers(Nutgram $bot): void
{
require base_path('routes/telegram.php');
}
/**
* @return array<int, string>
*/
public function configured(): array
{
return array_keys(config('nutgram.bots', []));
}
public function has(string $name): bool
{
return in_array($name, $this->configured(), true);
}
public function flush(): void
{
$this->bots = [];
}
public function fake(): self
{
$this->recording = true;
$this->bots = [];
return $this;
}
public function allowRealRequests(): self
{
$this->allowReal = true;
$this->bots = [];
return $this;
}
}
<?php
namespace App\Console\Commands;
use App\Telegram\Manager\NutgramManager;
use Illuminate\Console\Command;
class NutgramWebhookCommand extends Command
{
protected $signature = 'nutgram:webhook
{bot : The configured bot name}
{--url= : Custom webhook URL}
{--remove : Remove the webhook instead of setting it}';
protected $description = 'Set or remove the Telegram webhook for a configured Nutgram bot';
public function handle(NutgramManager $manager): int
{
$botName = (string) $this->argument('bot');
if (! $manager->has($botName)) {
$this->error("Bot [{$botName}] is not configured.");
$this->line('Available bots: '.implode(', ', $manager->configured()));
return self::FAILURE;
}
$bot = $manager->bot($botName);
if ($this->option('remove')) {
$bot->deleteWebhook();
$this->info("Webhook removed for bot [{$botName}].");
return self::SUCCESS;
}
$url = $this->option('url') ?: $this->webhookUrl($botName);
$secretToken = config('nutgram.safe_mode', false) ? $manager->webhookSecret($botName) : null;
$bot->setWebhook(
url: $url,
secret_token: $secretToken,
);
$this->info("Webhook set for bot [{$botName}].");
$this->line("URL: {$url}");
if ($secretToken) {
$this->line('Safe mode is enabled; Telegram will send the configured secret token.');
} else {
$this->warn('Safe mode is disabled; no webhook secret token was set.');
}
return self::SUCCESS;
}
protected function webhookUrl(string $botName): string
{
return rtrim(config('app.url'), '/')."/api/nutgram/{$botName}";
}
}
<?php
namespace App\Console\Commands;
use App\Telegram\Manager\NutgramManager;
use Illuminate\Console\Command;
use SergiX44\Nutgram\Telegram\Exceptions\TelegramException;
class RegisterBotCommandsCommand extends Command
{
protected $signature = 'nutgram:register-bot-commands
{bot? : Optional bot name}';
protected $description = 'Register Telegram command menus for configured Nutgram bots';
public function handle(NutgramManager $manager): int
{
$requestedBot = $this->argument('bot');
$botNames = $requestedBot ? [(string) $requestedBot] : $manager->configured();
foreach ($botNames as $botName) {
if (! $manager->has($botName)) {
$this->error("Bot [{$botName}] is not configured.");
$this->line('Available bots: '.implode(', ', $manager->configured()));
return self::FAILURE;
}
try {
$manager->bot($botName)->registerMyCommands();
} catch (TelegramException $exception) {
$this->error("Telegram rejected commands for bot [{$botName}]: {$exception->getMessage()}");
return self::FAILURE;
}
$this->info("Bot [{$botName}]: commands registered.");
}
return self::SUCCESS;
}
}
<?php
use App\Telegram\Commands\StartCommand;
use App\Telegram\Handlers\CancelHandler;
use SergiX44\Nutgram\Nutgram;
/** @var Nutgram $bot */
// Replace these placeholder handlers with your own app command and text handler classes.
$bot->onCommand('start', StartCommand::class)
->description('Start the bot')
->insensitive();
$bot->onText('Cancel', CancelHandler::class);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment