diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 6fdf0e3d..4c3214ff 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -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, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index f94112dc..25337147 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -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, }; } diff --git a/app/Exceptions/Social/TelegramPublishException.php b/app/Exceptions/Social/TelegramPublishException.php new file mode 100644 index 00000000..26943e1a --- /dev/null +++ b/app/Exceptions/Social/TelegramPublishException.php @@ -0,0 +1,68 @@ +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'; + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index c040f6f0..e163a90d 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -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), }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index a49801c4..d1fed6b6 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -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, }; }, diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index d473f65f..c17e9469 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -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, + ], }; } } diff --git a/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php index f5f73e50..79a3ba0b 100644 --- a/app/Services/Social/ContentSanitizer.php +++ b/app/Services/Social/ContentSanitizer.php @@ -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

/
/

  • ). + $content = preg_replace('/]*>/i', '', $content); + $content = str_replace('

    ', "\n", $content); + $content = preg_replace('//i', "\n", $content); + $content = preg_replace('/]*>/i', '- ', $content); + $content = str_replace('
  • ', "\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+);)/', '&', $content); + + $content = preg_replace("/\n{3,}/", "\n\n", $content); + + return trim($content); + } + private function stripHtml(string $content): string { // Convert

    tags to newlines diff --git a/app/Services/Social/TelegramPublisher.php b/app/Services/Social/TelegramPublisher.php new file mode 100644 index 00000000..24e79e24 --- /dev/null +++ b/app/Services/Social/TelegramPublisher.php @@ -0,0 +1,186 @@ +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 $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//. + $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); + } +} diff --git a/config/trypost.php b/config/trypost.php index 2ebdf592..e69f375a 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -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'), + ], ], ]; diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index c98f924f..e18e27fc 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -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) => [ diff --git a/tests/Feature/Services/Social/ContentSanitizerTest.php b/tests/Feature/Services/Social/ContentSanitizerTest.php index dd508444..4bd97ae4 100644 --- a/tests/Feature/Services/Social/ContentSanitizerTest.php +++ b/tests/Feature/Services/Social/ContentSanitizerTest.php @@ -76,3 +76,21 @@ $result = $sanitizer->sanitize('

    Check this

    ', Platform::Mastodon); expect($result)->toContain('this'); }); + +test('it keeps telegram-allowed html and converts strong/em', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('

    Hello world and you

    ', Platform::Telegram); + expect($result)->toBe('Hello world and you'); +}); + +test('it strips disallowed tags but keeps links for telegram', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('
    see link
    ', Platform::Telegram); + expect($result)->toBe('see linkx'); +}); + +test('it escapes bare ampersands for telegram', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Tom & Jerry & friends', Platform::Telegram); + expect($result)->toBe('Tom & Jerry & friends'); +}); diff --git a/tests/Feature/Services/Social/TelegramPublisherTest.php b/tests/Feature/Services/Social/TelegramPublisherTest.php new file mode 100644 index 00000000..a1f6094a --- /dev/null +++ b/tests/Feature/Services/Social/TelegramPublisherTest.php @@ -0,0 +1,171 @@ + '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'); +});