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
/
/
]*>/i', '', $content); + $content = str_replace('
', "\n", $content); + $content = preg_replace('/ 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 Check this Hello world and you