Sentinel started sending alerts to Telegram this week. It took an afternoon, and most of that was the connect flow, not the messages. The messages were nearly free, because the channel reuses the Slack representation every notification already had. Here's the shape of it, with the code, so you can do the same in your own Laravel app.

The problem

Sentinel has about a dozen notification classes: a monitor went down, a certificate is expiring, a heartbeat went quiet, a keyword check failed. Each one already implements toMail(), toSlack(), toArray() and a few more. Adding a channel the obvious way means a toTelegram() on every one of them, and then keeping thirteen copies of the same wording in sync forever.

The conventional route is the community package from Laravel Notification Channels, which adds a Telegram channel and expects a toTelegram() on each notification. It works, and it's the right pick if Telegram is your only chat channel. The framework itself ships only mail, database, broadcast and Slack; toSlack() is the method Laravel's own Slack channel calls, which is why every notification here already had one.

I'd already avoided the per-channel method once. Discord in Sentinel doesn't have a toDiscord() anywhere. The Discord channel takes the notification's toSlack() result, a SlackMessage with a headline, attachments, fields and a colour, and turns it into Discord embeds. Telegram does the same thing with HTML.

One channel, no new methods

Register the channel in a service provider:

Notification::extend('telegram', fn ($app) => new TelegramChannel);

The channel asks the notifiable where to send, asks the notification for its Slack message, renders it, and posts it to the Bot API:

class TelegramChannel
{
    public function send(mixed $notifiable, Notification $notification): void
    {
        $config = $notifiable->routeNotificationFor('telegram', $notification);

        if (! is_array($config) || empty($config['chat_id'])) {
            return;
        }

        if (! method_exists($notification, 'toSlack')) {
            return;
        }

        $message = $notification->toSlack($notifiable);

        if (! $message instanceof SlackMessage) {
            return;
        }

        $this->deliver($config, $this->slackMessageToHtml($message));
    }

    public function deliver(array $config, string $text): array
    {
        $response = Http::timeout(10)->asJson()->post(
            'https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage',
            [
                'chat_id' => $config['chat_id'],
                'text' => $text,
                'parse_mode' => 'HTML',
                'disable_web_page_preview' => true,
            ],
        );

        $succeeded = $response->successful() && $response->json('ok') === true;

        if (! empty($config['telegram_chat_id'])) {
            TelegramChat::find($config['telegram_chat_id'])?->recordOutcome($succeeded, $response->status());
        }

        return ['succeeded' => $succeeded, 'status' => $response->status(), 'error' => $response->json('description')];
    }
}

deliver() is public on purpose. The integrations page has a Test button, and it wants the answer synchronously so a chat the bot was kicked out of shows up as an error right there rather than during an outage.

The SlackMessage here is the classic Illuminate\Notifications\Messages\SlackMessage, the one with ->attachment() and ->fields(). If you're on the newer block-kit builder, the translation step is different, but the idea holds.

Rendering it safely

Telegram's HTML mode is strict. It accepts a handful of tags and rejects the whole message if anything else appears, and a monitor's friendly name is user input. So the converter escapes everything first and only then adds the tags it means to add:

protected function convertMarkdown(string $text): string
{
    $text = $this->emoji(trim($text));

    $links = [];
    $text = preg_replace_callback('/<(https?:[^|>]+)\|([^>]+)>/', function ($m) use (&$links) {
        $links[] = '<a href="'.$this->escape($m[1]).'">'.$this->escape($m[2]).'</a>';

        return "\0".(count($links) - 1)."\0";
    }, $text);

    $text = $this->escape($text);
    $text = preg_replace('/(?<!\*)\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)/', '<b>$1</b>', $text);

    return preg_replace_callback("/\0(\d+)\0/", fn ($m) => $links[(int) $m[1]], $text);
}

Slack's <url|label> links get pulled out into placeholders before escaping and put back after, so the anchor survives and the label doesn't. Slack's *bold* becomes <b>. And Slack emoji shortcodes like :x: and :warning: get mapped to the actual characters, because Telegram shows shortcodes as literal text. The first message I sent started with :x: OFFLINE, which is how I found that one.

A test pins the escaping:

$message = (new SlackMessage)->attachment(function ($attachment) {
    $attachment->title('<script>alert(1)</script> Storefront', 'https://sentinel.test/m/1');
});

expect((new TelegramChannel)->slackMessageToHtml($message))
    ->toContain('&lt;script&gt;')
    ->not->toContain('<script>');

Routing without a user

Sentinel's Telegram chats belong to a team, not a person. There's no User::routeNotificationForTelegram(). Instead the fan-out routes to an anonymous notifiable per chat:

foreach (TelegramChat::forSeverity($monitor->team_id, $severity) as $chat) {
    Notification::route('telegram', $chat->deliveryConfig())
        ->notify(new MonitorStatusChanged($monitor, 'online', 'offline', $reason, $region, false, ['telegram']));
}

The last argument narrows via() to one channel, so this notification instance doesn't also email anyone. Inside via(), the mapping needs a case that admits anonymous notifiables for this channel:

case 'telegram':
    if ($isAnonymous && $notifiable->routeNotificationFor('telegram', $this)) {
        $laravelChannels[] = 'telegram';
    }
    break;

Without that case, an anonymous route is silently dropped, which is the kind of bug that produces no error and no message.

One bot for every tenant

This was the part that took the afternoon. The easy design is to make each customer create their own bot with BotFather and paste the token. It's also the design that loses most of them at the first step. So Sentinel runs one bot, and a customer connects a chat to it with a link.

When someone adds a chat on the integrations page, Sentinel creates a row with a one-time token and no chat ID, and shows a link:

public function connectUrl(): ?string
{
    return 'https://t.me/'.config('services.telegram.bot_username').'?start='.$this->connect_token;
}

Opening that link and tapping Start makes Telegram send the bot a message reading /start <token>. Telegram delivers it to a webhook, which finds the pending row and binds the chat:

if (! preg_match('/^\/(?:start|connect)(?:@\w+)?\s+([a-z0-9]{16,64})\s*$/i', $text, $m)) {
    return response()->json(['ok' => true]);
}

$pending = TelegramChat::where('connect_token', strtolower($m[1]))->first();
$pending?->bindChat($message['chat']);

Groups work the same way with /connect <token> typed in the group, and the regex tolerates the @BotName suffix Telegram adds in groups. Each token binds one chat once and is cleared on use.

Two details make this safe to expose. Telegram lets you register a secret alongside the webhook URL and sends it back in a header on every update, so the controller checks X-Telegram-Bot-Api-Secret-Token with hash_equals before it reads anything. And the webhook subscribes to my_chat_member updates, so when someone removes the bot from a group, the row is deactivated instead of failing on every alert until the end of time:

if (in_array($member['new_chat_member']['status'] ?? null, ['left', 'kicked'], true)) {
    TelegramChat::where('chat_id', $member['chat']['id'])->update(['is_active' => false]);
}

Registering the webhook is a one-line artisan command that calls setWebhook with the URL, the secret, and the three update types the app cares about. Run it once after the token lands in the environment.

Testing it

Http::fake covers the Bot API, and the connect flow tests are plain HTTP tests that post a fake Telegram update to the webhook with the secret header. The one thing that cost me twenty minutes: the notification class sets $this->afterCommit = true, so inside a test transaction it never actually dispatches. Asserting an HTTP call after running the fan-out job finds nothing. The PagerDuty tests already knew this and I'd forgotten. So the fan-out test fakes notifications and asserts which chats were selected, and a separate test routes one notification directly and asserts the request body:

Notification::route('telegram', $chat->deliveryConfig())
    ->notify(new MonitorStatusChanged($monitor, 'online', 'offline', 'HTTP 503', 'ash', false, ['telegram']));

Http::assertSent(fn ($request) => $request['parse_mode'] === 'HTML'
    && str_starts_with($request['text'], '❌ <b>OFFLINE</b>'));

What to steal

  • Render a new chat channel from toSlack() instead of adding a method to every notification. Discord and Telegram both work this way in Sentinel.
  • Escape first, then add tags. Telegram rejects the whole message on a stray bracket.
  • Route team-level channels anonymously and admit them explicitly in via().
  • Run one bot. Bind chats with a one-time token through the /start deep link, verify the webhook secret, and listen for the bot being removed.
  • Remember afterCommit in tests.

The result is on the Telegram integration page if you want to see it from the customer's side, and the setup guide is in the docs.