Skip to content

Commit 1909927

Browse files
committed
feat: Implement Markdown to HTML conversion for Telegram messages and adjust message chunking to a safe length.
1 parent 39dba21 commit 1909927

File tree

3 files changed

+124
-12
lines changed

3 files changed

+124
-12
lines changed

app/Services/Telegram/TelegramMessageService.php

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ final class TelegramMessageService
1313
{
1414
public const int MAX_MESSAGE_LENGTH = 4096;
1515

16+
private const int SAFE_MESSAGE_LENGTH = 3800;
17+
1618
private const int CHUNK_DELAY_MS = 1000;
1719

1820
private const string QUEUE_NAME = 'telegram';
@@ -28,6 +30,11 @@ public static function getMaxMessageLength(): int
2830
return self::MAX_MESSAGE_LENGTH;
2931
}
3032

33+
public static function getSafeMessageLength(): int
34+
{
35+
return self::SAFE_MESSAGE_LENGTH;
36+
}
37+
3138
public function sendLongMessage(TelegraphChat $chat, string $message, bool $markdown = true): void
3239
{
3340
$chunks = $this->splitMessage($message);
@@ -48,7 +55,7 @@ public function splitMessage(string $message): array
4855
{
4956
$message = mb_trim($message);
5057

51-
if (mb_strlen($message) <= self::MAX_MESSAGE_LENGTH) {
58+
if (mb_strlen($message) <= self::SAFE_MESSAGE_LENGTH) {
5259
return [$message];
5360
}
5461

@@ -69,7 +76,7 @@ private function chunkMessage(string $message): array
6976
$remaining = $message;
7077

7178
while (mb_strlen($remaining) > 0) {
72-
if (mb_strlen($remaining) <= self::MAX_MESSAGE_LENGTH) {
79+
if (mb_strlen($remaining) <= self::SAFE_MESSAGE_LENGTH) {
7380
$chunks[] = mb_trim($remaining);
7481
break;
7582
}
@@ -84,7 +91,7 @@ private function chunkMessage(string $message): array
8491

8592
private function extractChunk(string $text): string
8693
{
87-
$maxLength = self::MAX_MESSAGE_LENGTH;
94+
$maxLength = self::SAFE_MESSAGE_LENGTH;
8895
$searchText = mb_substr($text, 0, $maxLength);
8996
$threshold = (int) ($maxLength * self::MIN_SPLIT_THRESHOLD);
9097

@@ -137,6 +144,43 @@ private function findSentenceEndSplit(string $text, int $threshold): ?string
137144

138145
private function dispatchMessage(TelegraphChat $chat, string $chunk, bool $markdown): void
139146
{
147+
if ($markdown) {
148+
$html = $this->convertMarkdownToHtml($chunk);
149+
$chat->html($html)->send();
150+
151+
return;
152+
}
153+
140154
$chat->message($chunk)->send();
141155
}
156+
157+
private function convertMarkdownToHtml(string $markdown): string
158+
{
159+
$converter = new GithubFlavoredMarkdownConverter([
160+
'html_input' => 'strip',
161+
'allow_unsafe_links' => false,
162+
]);
163+
164+
$html = $converter->convert($markdown)->getContent();
165+
166+
// Handle lists - convert <li> to bullets
167+
$html = str_replace('<li>', '', $html);
168+
$html = str_replace('</li>', "\n", $html);
169+
$html = str_replace(['<ul>', '<ol>', '</ul>', '</ol>'], ["\n", "\n", "\n", "\n"], $html);
170+
171+
// Convert <p> tags to double newlines
172+
$html = str_replace(
173+
['<p>', '</p>'],
174+
['', "\n\n"],
175+
$html
176+
);
177+
178+
// Convert <br> to newline
179+
$html = str_replace(['<br>', '<br />'], "\n", $html);
180+
181+
// Strip unsupported tags
182+
// Telegram supports: <b>, <strong>, <i>, <em>, <u>, <ins>, <s>, <strike>, <del>,
183+
// <span class="tg-spoiler">, <a>, <code>, <pre>, <blockquote>
184+
return trim(strip_tags($html, '<b><strong><i><em><u><ins><s><strike><del><a><code><pre><blockquote>'));
185+
}
142186
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use App\Services\Telegram\TelegramMessageService;
6+
use DefStudio\Telegraph\Facades\Telegraph;
7+
use DefStudio\Telegraph\Models\TelegraphBot;
8+
use DefStudio\Telegraph\Models\TelegraphChat;
9+
10+
beforeEach(function (): void {
11+
Telegraph::fake();
12+
});
13+
14+
test('converts basic markdown to telegram compatible html', function (): void {
15+
$service = new TelegramMessageService();
16+
// Use reflection to access private method
17+
$reflection = new ReflectionClass(TelegramMessageService::class);
18+
$method = $reflection->getMethod('convertMarkdownToHtml');
19+
20+
$markdown = "**Bold** and *Italic* and `code`";
21+
$html = $method->invoke($service, $markdown);
22+
23+
// CommonMark uses <strong> for bold and <em> for italic
24+
// Our converter strips <p>
25+
expect($html)->toContain('<strong>Bold</strong>')
26+
->toContain('<em>Italic</em>')
27+
->toContain('<code>code</code>');
28+
});
29+
30+
test('converts lists to telegram compatible format', function (): void {
31+
$service = new TelegramMessageService();
32+
$reflection = new ReflectionClass(TelegramMessageService::class);
33+
$method = $reflection->getMethod('convertMarkdownToHtml');
34+
35+
$markdown = "- Item 1\n- Item 2";
36+
$html = $method->invoke($service, $markdown);
37+
38+
expect($html)->toContain('• Item 1')
39+
->toContain('• Item 2');
40+
});
41+
42+
test('safe message length is respected', function (): void {
43+
$reflection = new ReflectionClass(TelegramMessageService::class);
44+
$constant = $reflection->getReflectionConstant('SAFE_MESSAGE_LENGTH');
45+
$safeLength = $constant->getValue();
46+
47+
expect($safeLength)->toBe(3800);
48+
expect($safeLength)->toBeLessThan(TelegramMessageService::MAX_MESSAGE_LENGTH);
49+
});
50+
51+
test('sends html message', function (): void {
52+
Telegraph::fake([
53+
DefStudio\Telegraph\Telegraph::ENDPOINT_MESSAGE => ['ok' => true, 'result' => []],
54+
]);
55+
56+
$bot = TelegraphBot::factory()->create();
57+
$chat = TelegraphChat::factory()->for($bot, 'bot')->create();
58+
$service = new TelegramMessageService();
59+
60+
$service->sendLongMessage($chat, '**Bold**', true);
61+
62+
Telegraph::assertSentData(DefStudio\Telegraph\Telegraph::ENDPOINT_MESSAGE, [
63+
'text' => '<strong>Bold</strong>',
64+
'parse_mode' => 'html',
65+
]);
66+
});

tests/Unit/Services/Telegram/TelegramMessageServiceTest.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323

2424
it('returns single chunk for message at max length', function (): void {
2525
$service = new TelegramMessageService();
26-
$message = str_repeat('a', TelegramMessageService::getMaxMessageLength());
26+
$message = str_repeat('a', TelegramMessageService::getSafeMessageLength());
2727

2828
$chunks = $service->splitMessage($message);
2929

3030
expect($chunks)->toHaveCount(1)
31-
->and(mb_strlen($chunks[0]))->toBe(TelegramMessageService::getMaxMessageLength());
31+
->and(mb_strlen($chunks[0]))->toBe(TelegramMessageService::getSafeMessageLength());
3232
});
3333

3434
it('splits at paragraph boundary when available', function (): void {
@@ -63,15 +63,16 @@
6363
$service = new TelegramMessageService();
6464

6565
// Create a message that's just over max length without line breaks
66-
$sentence1 = str_repeat('A ', 2000); // 4000 chars
66+
// Safe length is 3800. We need sentence1 to be < 3800.
67+
$sentence1 = str_repeat('A ', 1800); // 3600 chars
6768
$sentence2 = str_repeat('B ', 500); // 1000 chars
6869
$message = $sentence1.'. '.$sentence2;
6970

7071
$chunks = $service->splitMessage($message);
7172

7273
expect($chunks)->toHaveCount(2)
7374
->and($chunks[0])->toEndWith('.')
74-
->and(mb_strlen($chunks[0]))->toBeLessThanOrEqual(TelegramMessageService::getMaxMessageLength());
75+
->and(mb_strlen($chunks[0]))->toBeLessThanOrEqual(TelegramMessageService::getSafeMessageLength());
7576
});
7677

7778
it('splits at word boundary as fallback', function (): void {
@@ -84,7 +85,7 @@
8485

8586
expect($chunks)->toHaveCount(2);
8687
foreach ($chunks as $chunk) {
87-
expect(mb_strlen($chunk))->toBeLessThanOrEqual(TelegramMessageService::getMaxMessageLength());
88+
expect(mb_strlen($chunk))->toBeLessThanOrEqual(TelegramMessageService::getSafeMessageLength());
8889
}
8990
});
9091

@@ -97,8 +98,8 @@
9798
$chunks = $service->splitMessage($message);
9899

99100
expect($chunks)->toHaveCount(2)
100-
->and(mb_strlen($chunks[0]))->toBe(TelegramMessageService::getMaxMessageLength())
101-
->and(mb_strlen($chunks[1]))->toBe(904); // 5000 - 4096
101+
->and(mb_strlen($chunks[0]))->toBe(TelegramMessageService::getSafeMessageLength())
102+
->and(mb_strlen($chunks[1]))->toBe(5000 - TelegramMessageService::getSafeMessageLength());
102103
});
103104

104105
it('handles empty message', function (): void {
@@ -133,7 +134,7 @@
133134

134135
// Each chunk should be under limit
135136
foreach ($chunks as $chunk) {
136-
expect(mb_strlen($chunk))->toBeLessThanOrEqual(TelegramMessageService::getMaxMessageLength());
137+
expect(mb_strlen($chunk))->toBeLessThanOrEqual(TelegramMessageService::getSafeMessageLength());
137138
}
138139

139140
// All content should be preserved
@@ -169,7 +170,8 @@
169170
$service->sendLongMessage($chat, '**Bold text**');
170171

171172
Telegraph::assertSentData(DefStudio\Telegraph\Telegraph::ENDPOINT_MESSAGE, [
172-
'text' => "<p><strong>Bold text</strong></p>\n",
173+
'text' => '<strong>Bold text</strong>',
174+
'parse_mode' => 'html',
173175
]);
174176
});
175177

0 commit comments

Comments
 (0)