Add Telegram publishing (backend foundation)

Register Telegram as a platform: Platform/ContentType enum cases, a
platforms.telegram config block (shared bot token via env), TelegramPublisher
(sendMessage / sendPhoto|Video|Document / sendMediaGroup over the Bot API, HTML
parse mode, 4096 limit with long text split off a 1024 caption), wired into the
publisher dispatch. Add a Telegram ContentSanitizer branch (Telegram-allowed
HTML + ampersand escaping), MediaOptimizer/profile-url/factory support, and the
TelegramPublishException. Tests cover text, single media, album, long-text split,
overflow, API errors, private-channel URLs, and sanitization.
This commit is contained in:
Paulo Castellano 2026-06-13 21:25:31 -03:00
parent 986e2661f9
commit 9634e88e5d
12 changed files with 526 additions and 1 deletions

View file

@ -50,6 +50,9 @@ enum ContentType: string
// Mastodon
case MastodonPost = 'mastodon_post';
// Telegram
case TelegramPost = 'telegram_post';
/**
* AI generation format for an Instagram carousel. Not a content type —
* carousel posts are persisted as InstagramFeed.
@ -77,6 +80,7 @@ public function label(): string
self::PinterestCarousel => 'Carousel',
self::BlueskyPost => 'Post',
self::MastodonPost => 'Post',
self::TelegramPost => 'Post',
};
}
@ -99,6 +103,7 @@ public function platform(): SocialPlatform
self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest,
self::BlueskyPost => SocialPlatform::Bluesky,
self::MastodonPost => SocialPlatform::Mastodon,
self::TelegramPost => SocialPlatform::Telegram,
};
}
@ -167,6 +172,7 @@ public function maxMediaCount(): int
self::PinterestCarousel => 5,
self::BlueskyPost => 4,
self::MastodonPost => 4,
self::TelegramPost => 10,
};
}
@ -186,6 +192,7 @@ public function supportsVideo(): bool
self::PinterestPin, self::PinterestCarousel => false,
self::BlueskyPost => true,
self::MastodonPost => true,
self::TelegramPost => true,
};
}
@ -222,6 +229,7 @@ public function requiresMedia(): bool
self::ThreadsPost => false,
self::BlueskyPost => false,
self::MastodonPost => false,
self::TelegramPost => false,
self::FacebookPost => false,
self::InstagramFeed => false,
default => true,
@ -304,6 +312,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::Pinterest => self::PinterestPin,
SocialPlatform::Bluesky => self::BlueskyPost,
SocialPlatform::Mastodon => self::MastodonPost,
SocialPlatform::Telegram => self::TelegramPost,
};
}
}

View file

@ -20,6 +20,7 @@ enum Platform: string
case Pinterest = 'pinterest';
case Bluesky = 'bluesky';
case Mastodon = 'mastodon';
case Telegram = 'telegram';
public function label(): string
{
@ -36,6 +37,7 @@ public function label(): string
self::Pinterest => 'Pinterest',
self::Bluesky => 'Bluesky',
self::Mastodon => 'Mastodon',
self::Telegram => 'Telegram',
};
}
@ -53,6 +55,7 @@ public function color(): string
self::Pinterest => '#E60023',
self::Bluesky => '#0085FF',
self::Mastodon => '#6364FF',
self::Telegram => '#26A5E4',
};
}
@ -69,6 +72,7 @@ public function allowedMediaTypes(): array
self::Pinterest => [MediaType::Image, MediaType::Video],
self::Bluesky => [MediaType::Image, MediaType::Video],
self::Mastodon => [MediaType::Image, MediaType::Video],
self::Telegram => [MediaType::Image, MediaType::Video],
};
}
@ -85,6 +89,7 @@ public function maxImages(): int
self::Pinterest => 5,
self::Bluesky => 4,
self::Mastodon => 4,
self::Telegram => 10,
};
}
@ -107,6 +112,8 @@ public function maxImages(): int
* - Pinterest pin description: 800 (title is 100, not modeled here)
* - Bluesky: 300 graphemes
* - Mastodon: 500 default; instances may be higher (we stay conservative)
* - Telegram: 4096 for a text message (media captions are capped at 1024,
* handled in the publisher by sending long text as its own message)
*/
public function maxContentLength(): int
{
@ -121,6 +128,7 @@ public function maxContentLength(): int
self::Pinterest => 800,
self::Bluesky => 300,
self::Mastodon => 500,
self::Telegram => 4096,
};
}
@ -162,6 +170,8 @@ public function recommendedAiContentLength(): int
// YouTube Shorts — fits within the 100-char title (with " #Shorts"
// suffix taking 8 chars) so the same string works as title + desc
self::YouTube => 80,
// Telegram channel posts — short announcements read best
self::Telegram => 400,
};
}
@ -183,6 +193,7 @@ public function requiredPublishScopes(): array
self::Pinterest => ['pins:write'],
self::Bluesky => [],
self::Mastodon => ['write:statuses'],
self::Telegram => [],
};
}
@ -199,6 +210,7 @@ public function supportsTextOnly(): bool
self::Pinterest => false,
self::Bluesky => true,
self::Mastodon => true,
self::Telegram => true,
};
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use Illuminate\Http\Client\Response;
class TelegramPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->status();
$rawResponse = $response->body();
$description = (string) data_get($response->json(), 'description', 'An unknown Telegram error occurred.');
// 403: the bot was removed or isn't an admin of the channel anymore.
if ($status === 403) {
return new static(
userMessage: 'The bot is not an admin of this channel. Re-add it as an administrator and try again.',
category: ErrorCategory::Permission,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
// 401: the configured bot token is invalid (operator-level misconfiguration).
if ($status === 401) {
return new static(
userMessage: 'Telegram rejected the bot token. Check the TELEGRAM_BOT_TOKEN configuration.',
category: ErrorCategory::Permission,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
if ($status === 429) {
return new static(
userMessage: 'Telegram rate limit reached. Please try again shortly.',
category: ErrorCategory::RateLimit,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
if ($status >= 500) {
return new static(
userMessage: 'Telegram is temporarily unavailable. Please try again later.',
category: ErrorCategory::ServerError,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
return new static(
userMessage: $description,
category: ErrorCategory::ContentPolicy,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
public function platform(): string
{
return 'telegram';
}
}

View file

@ -25,6 +25,7 @@
use App\Services\Social\LinkedInPublisher;
use App\Services\Social\MastodonPublisher;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\TelegramPublisher;
use App\Services\Social\ThreadsPublisher;
use App\Services\Social\TikTokPublisher;
use App\Services\Social\XPublisher;
@ -221,7 +222,7 @@ private function broadcastStatus(): void
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
}
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher
{
return match ($this->postPlatform->platform) {
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
@ -235,6 +236,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis
SocialPlatform::Pinterest => app(PinterestPublisher::class),
SocialPlatform::Bluesky => app(BlueskyPublisher::class),
SocialPlatform::Mastodon => app(MastodonPublisher::class),
SocialPlatform::Telegram => app(TelegramPublisher::class),
};
}

View file

@ -125,6 +125,7 @@ protected function profileUrl(): Attribute
SocialPlatform::Mastodon => ($username && data_get($this->meta, 'instance'))
? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}"
: null,
SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null,
default => null,
};
},

View file

@ -196,6 +196,12 @@ private function getImageConfig(Platform $platform): array
'format' => 'image/jpeg',
'quality' => 100,
],
Platform::Telegram => [
'max_width' => 2048,
'max_size' => 10 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 100,
],
};
}
}

View file

@ -13,10 +13,36 @@ public function sanitize(string $content, Platform $platform): string
return match ($platform) {
Platform::LinkedIn, Platform::LinkedInPage => $this->convertBoldAndStrip($content),
Platform::Mastodon => $this->stripUnsafeHtml($content),
Platform::Telegram => $this->toTelegramHtml($content),
default => $this->stripHtml($content),
};
}
/**
* Telegram's `parse_mode=HTML` accepts a small tag allowlist and rejects
* the rest; bare ampersands must be escaped or the parser errors.
*/
private function toTelegramHtml(string $content): string
{
// Block elements → newlines (Telegram HTML has no <p>/<br>/<li>).
$content = preg_replace('/<p[^>]*>/i', '', $content);
$content = str_replace('</p>', "\n", $content);
$content = preg_replace('/<br\s*\/?>/i', "\n", $content);
$content = preg_replace('/<li[^>]*>/i', '- ', $content);
$content = str_replace('</li>', "\n", $content);
// Normalize to Telegram's tag names, then keep only its allowlist.
$content = preg_replace(['/<(\/?)strong>/i', '/<(\/?)em>/i'], ['<$1b>', '<$1i>'], $content);
$content = strip_tags($content, ['b', 'i', 'u', 's', 'a', 'code', 'pre']);
// Escape bare ampersands while leaving existing entities intact.
$content = preg_replace('/&(?!(?:amp|lt|gt|quot|#\d+);)/', '&amp;', $content);
$content = preg_replace("/\n{3,}/", "\n\n", $content);
return trim($content);
}
private function stripHtml(string $content): string
{
// Convert <p> tags to newlines

View file

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Exceptions\Social\TelegramPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
class TelegramPublisher
{
use HasSocialHttpClient;
/**
* Telegram caps media captions at 1024 chars (a text-only message allows
* 4096). When the post is longer than a caption, the media is sent first and
* the full text follows as its own message.
*/
private const CAPTION_LIMIT = 1024;
private const ALBUM_CHUNK = 10;
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
$chatId = (string) data_get($account->meta, 'chat_id');
$content = $postPlatform->post->content
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
: '';
$media = $postPlatform->post->mediaItems->take(self::ALBUM_CHUNK);
$messageId = $media->isEmpty()
? $this->sendText($chatId, $content)
: $this->sendWithMedia($chatId, $content, $media);
return [
'id' => (string) $messageId,
'url' => $this->buildPostUrl($account, $messageId),
];
}
private function sendText(string $chatId, string $text): int
{
$response = $this->call('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
]);
return (int) data_get($response->json(), 'result.message_id');
}
private function sendWithMedia(string $chatId, string $content, Collection $media): int
{
$fitsCaption = mb_strlen($content) <= self::CAPTION_LIMIT;
$caption = $fitsCaption ? $content : '';
$items = $media->map(fn (MediaItem $item) => $this->telegramMedia($item))->values()->all();
$messageId = count($items) === 1
? $this->sendSingleMedia($chatId, $items[0], $caption)
: $this->sendMediaGroup($chatId, $items, $caption);
// Long text can't ride along as a caption — send it as a follow-up message.
if (! $fitsCaption) {
$this->sendText($chatId, $content);
}
return $messageId;
}
/**
* @param array{type: string, url: string} $item
*/
private function sendSingleMedia(string $chatId, array $item, string $caption): int
{
$method = match ($item['type']) {
'photo' => 'sendPhoto',
'video' => 'sendVideo',
default => 'sendDocument',
};
$response = $this->call($method, [
'chat_id' => $chatId,
$item['type'] => $item['url'],
'caption' => $caption,
'parse_mode' => 'HTML',
]);
return (int) data_get($response->json(), 'result.message_id');
}
/**
* @param array<int, array{type: string, url: string}> $items
*/
private function sendMediaGroup(string $chatId, array $items, string $caption): int
{
$firstMessageId = 0;
foreach (array_chunk($items, self::ALBUM_CHUNK) as $chunkIndex => $chunk) {
$group = [];
foreach ($chunk as $itemIndex => $item) {
$entry = [
// Documents can't be mixed into an album; send them as photos/videos only.
'type' => $item['type'] === 'document' ? 'document' : $item['type'],
'media' => $item['url'],
];
if ($chunkIndex === 0 && $itemIndex === 0 && $caption !== '') {
$entry['caption'] = $caption;
$entry['parse_mode'] = 'HTML';
}
$group[] = $entry;
}
$response = $this->call('sendMediaGroup', [
'chat_id' => $chatId,
'media' => json_encode($group),
]);
if ($chunkIndex === 0) {
$firstMessageId = (int) data_get($response->json(), 'result.0.message_id');
}
}
return $firstMessageId;
}
/**
* @return array{type: string, url: string}
*/
private function telegramMedia(MediaItem $media): array
{
$type = match (true) {
$media->isImage() => 'photo',
$media->isVideo() => 'video',
default => 'document',
};
return ['type' => $type, 'url' => $media->url];
}
private function call(string $method, array $payload): Response
{
$token = (string) config('trypost.platforms.telegram.bot_token');
$api = rtrim((string) config('trypost.platforms.telegram.api'), '/');
$response = $this->socialHttp()->post("{$api}/bot{$token}/{$method}", $payload);
if ($response->failed() || data_get($response->json(), 'ok') !== true) {
$this->handleApiError($response);
}
return $response;
}
private function buildPostUrl(SocialAccount $account, int $messageId): string
{
$username = (string) data_get($account->meta, 'username');
if ($username !== '') {
return "https://t.me/{$username}/{$messageId}";
}
// Private channels: t.me/c/<id without the -100 prefix>/<message_id>.
$internalId = preg_replace('/^-100/', '', (string) data_get($account->meta, 'chat_id'));
return "https://t.me/c/{$internalId}/{$messageId}";
}
private function handleApiError(Response $response): never
{
throw TelegramPublishException::fromApiResponse($response);
}
}

View file

@ -153,6 +153,15 @@
// Default instance used when the account has no `meta.instance` override.
'default_instance' => env('MASTODON_DEFAULT_INSTANCE', 'https://mastodon.social'),
],
'telegram' => [
'enabled' => env('TELEGRAM_ENABLED', true),
// Single shared bot (BotFather). Users add it as admin to their channel.
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
'bot_username' => env('TELEGRAM_BOT_USERNAME'),
'api' => env('TELEGRAM_API', 'https://api.telegram.org'),
// Secret-token header Telegram echoes on every webhook call.
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
],
],
];

View file

@ -137,6 +137,23 @@ public function mastodon(): static
]);
}
public function telegram(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Telegram,
'scopes' => Platform::Telegram->requiredPublishScopes(),
'token_expires_at' => null, // the shared bot token never expires
'access_token' => '',
'refresh_token' => '',
'username' => 'mychannel',
'meta' => [
'chat_id' => '-1001234567890',
'username' => 'mychannel',
'type' => 'channel',
],
]);
}
public function disconnected(): static
{
return $this->state(fn (array $attributes) => [

View file

@ -76,3 +76,21 @@
$result = $sanitizer->sanitize('<p>Check <a href="https://example.com">this</a></p>', Platform::Mastodon);
expect($result)->toContain('<a href="https://example.com">this</a>');
});
test('it keeps telegram-allowed html and converts strong/em', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>Hello <strong>world</strong> and <em>you</em></p>', Platform::Telegram);
expect($result)->toBe('Hello <b>world</b> and <i>you</i>');
});
test('it strips disallowed tags but keeps links for telegram', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<div>see <a href="https://example.com">link</a></div><script>x</script>', Platform::Telegram);
expect($result)->toBe('see <a href="https://example.com">link</a>x');
});
test('it escapes bare ampersands for telegram', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('Tom &amp; Jerry & friends', Platform::Telegram);
expect($result)->toBe('Tom &amp; Jerry &amp; friends');
});

View file

@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\TelegramPublishException;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\TelegramPublisher;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello world',
]);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Telegram,
'content_type' => ContentType::TelegramPost,
]);
$this->publisher = new TelegramPublisher;
});
function telegramOk(array $result): array
{
return ['ok' => true, 'result' => $result];
}
test('telegram publisher sends a text-only message', function () {
Http::fake([
'*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 42]), 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('42');
expect($result['url'])->toBe('https://t.me/mychannel/42');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/sendMessage')
&& $request['chat_id'] === '-1001234567890'
&& $request['text'] === 'Hello world'
&& $request['parse_mode'] === 'HTML';
});
});
test('telegram publisher sends a single image with caption', function () {
$this->post->update([
'content' => 'A photo',
'media' => [[
'id' => 'm1',
'path' => 'media/2026-01/pic.jpg',
'url' => 'https://cdn.test/pic.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'pic.jpg',
]],
]);
Http::fake([
'*/botTESTTOKEN/sendPhoto' => Http::response(telegramOk(['message_id' => 7]), 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/sendPhoto')
&& str_contains($request['photo'], 'pic.jpg')
&& $request['caption'] === 'A photo'
&& $request['parse_mode'] === 'HTML';
});
});
test('telegram publisher sends multiple media as an album', function () {
$this->post->update([
'content' => 'Album',
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://cdn.test/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://cdn.test/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'],
],
]);
Http::fake([
'*/botTESTTOKEN/sendMediaGroup' => Http::response(telegramOk([['message_id' => 11], ['message_id' => 12]]), 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('11');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/sendMediaGroup')) {
return false;
}
$media = json_decode($request['media'], true);
return count($media) === 2
&& $media[0]['type'] === 'photo'
&& $media[0]['caption'] === 'Album'
&& ! isset($media[1]['caption']);
});
});
test('telegram publisher sends long text as its own message after media', function () {
$longText = str_repeat('x', 1500); // over the 1024 caption limit
$this->post->update([
'content' => $longText,
'media' => [[
'id' => 'm1', 'path' => 'media/p.jpg', 'url' => 'https://cdn.test/p.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'p.jpg',
]],
]);
Http::fake([
'*/botTESTTOKEN/sendPhoto' => Http::response(telegramOk(['message_id' => 5]), 200),
'*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 6]), 200),
]);
$this->publisher->publish($this->postPlatform);
// Photo carries no caption (too long); the text follows as a separate message.
Http::assertSent(fn ($request) => str_contains($request->url(), '/sendPhoto') && $request['caption'] === '');
Http::assertSent(fn ($request) => str_contains($request->url(), '/sendMessage') && $request['text'] === $longText);
});
test('telegram publisher rejects content over the 4096 limit', function () {
$this->post->update(['content' => str_repeat('x', 4097)]);
Http::fake();
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(Exception::class);
Http::assertNothingSent();
});
test('telegram publisher throws on a non-ok response', function () {
Http::fake([
'*/botTESTTOKEN/sendMessage' => Http::response(['ok' => false, 'error_code' => 403, 'description' => 'Forbidden: bot is not a member of the channel chat'], 403),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(TelegramPublishException::class);
});
test('telegram publisher builds a private-channel url when there is no username', function () {
$this->socialAccount->update(['username' => null, 'meta' => ['chat_id' => '-1009876543210', 'type' => 'channel']]);
Http::fake([
'*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 99]), 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['url'])->toBe('https://t.me/c/9876543210/99');
});