From 9634e88e5d5d2e0bac878a2318379482cab10596 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 21:25:31 -0300 Subject: [PATCH 01/35] 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. --- app/Enums/PostPlatform/ContentType.php | 9 + app/Enums/SocialAccount/Platform.php | 12 ++ .../Social/TelegramPublishException.php | 68 +++++++ app/Jobs/PublishToSocialPlatform.php | 4 +- app/Models/SocialAccount.php | 1 + app/Services/Media/MediaOptimizer.php | 6 + app/Services/Social/ContentSanitizer.php | 26 +++ app/Services/Social/TelegramPublisher.php | 186 ++++++++++++++++++ config/trypost.php | 9 + database/factories/SocialAccountFactory.php | 17 ++ .../Services/Social/ContentSanitizerTest.php | 18 ++ .../Services/Social/TelegramPublisherTest.php | 171 ++++++++++++++++ 12 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/Social/TelegramPublishException.php create mode 100644 app/Services/Social/TelegramPublisher.php create mode 100644 tests/Feature/Services/Social/TelegramPublisherTest.php 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'); +}); From a4cf8aa4cef7eb1260ddfb27bf6e6ab9a0df3898 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 21:39:03 -0300 Subject: [PATCH 02/35] Add Telegram connection flow (controller + webhook) Connect a channel by issuing a one-time code the user posts as /connect in their channel. A secret-token-guarded webhook matches the code, links the channel as a SocialAccount (chat_id in meta), and records it on the request so the connect endpoint can poll for completion. Adds the TelegramConnectRequest model + migration, the connect/status endpoints, the public webhook route (CSRF exempt), a ConnectionVerifier branch (getChat liveness), and a telegram:set-webhook command. Tests cover the code issue, webhook link, secret rejection, expired/ unknown codes, status polling, and the command. --- app/Console/Commands/Telegram/SetWebhook.php | 46 ++++++ .../Controllers/Auth/TelegramController.php | 68 +++++++++ .../Webhooks/TelegramWebhookController.php | 79 ++++++++++ app/Models/TelegramConnectRequest.php | 40 +++++ app/Services/Social/ConnectionVerifier.php | 14 ++ bootstrap/app.php | 1 + ...create_telegram_connect_requests_table.php | 29 ++++ routes/app.php | 4 + routes/web.php | 5 + .../Feature/Social/TelegramConnectionTest.php | 143 ++++++++++++++++++ 10 files changed, 429 insertions(+) create mode 100644 app/Console/Commands/Telegram/SetWebhook.php create mode 100644 app/Http/Controllers/Auth/TelegramController.php create mode 100644 app/Http/Controllers/Webhooks/TelegramWebhookController.php create mode 100644 app/Models/TelegramConnectRequest.php create mode 100644 database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php create mode 100644 tests/Feature/Social/TelegramConnectionTest.php diff --git a/app/Console/Commands/Telegram/SetWebhook.php b/app/Console/Commands/Telegram/SetWebhook.php new file mode 100644 index 00000000..33a77dea --- /dev/null +++ b/app/Console/Commands/Telegram/SetWebhook.php @@ -0,0 +1,46 @@ +error('TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET must both be set.'); + + return self::FAILURE; + } + + $url = route('telegram.webhook'); + + $response = Http::post("{$api}/bot{$token}/setWebhook", [ + 'url' => $url, + 'secret_token' => $secret, + 'allowed_updates' => ['message', 'channel_post'], + ]); + + if (! $response->successful() || data_get($response->json(), 'ok') !== true) { + $this->error('Failed to set webhook: '.$response->body()); + + return self::FAILURE; + } + + $this->info("Telegram webhook registered at {$url}"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php new file mode 100644 index 00000000..fb146860 --- /dev/null +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -0,0 +1,68 @@ +`) so the webhook can tie the channel to this workspace. + */ + public function connect(Request $request): JsonResponse + { + $this->ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.'); + + $this->authorize('manageAccounts', $workspace); + $this->ensureSocialAccountLimit($workspace); + + $connectRequest = TelegramConnectRequest::create([ + 'workspace_id' => $workspace->id, + 'user_id' => $request->user()->id, + 'code' => Str::lower(Str::random(12)), + 'expires_at' => now()->addMinutes(15), + ]); + + return response()->json([ + 'code' => $connectRequest->code, + 'bot_username' => config('trypost.platforms.telegram.bot_username'), + 'expires_at' => $connectRequest->expires_at->toIso8601String(), + ]); + } + + /** + * Poll whether the channel has been linked yet. + */ + public function status(Request $request): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.'); + + $connectRequest = TelegramConnectRequest::query() + ->where('workspace_id', $workspace->id) + ->where('code', (string) $request->query('code')) + ->first(); + + $status = match (true) { + $connectRequest === null => 'unknown', + $connectRequest->social_account_id !== null => 'connected', + $connectRequest->isExpired() => 'expired', + default => 'pending', + }; + + return response()->json(['status' => $status]); + } +} diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php new file mode 100644 index 00000000..c394dade --- /dev/null +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -0,0 +1,79 @@ +` + * message/channel_post: it ties the originating channel to the workspace that + * generated the code. Everything else is acknowledged and ignored. + */ + public function handle(Request $request): Response + { + $secret = (string) config('trypost.platforms.telegram.webhook_secret'); + + abort_if( + $secret === '' || ! hash_equals($secret, (string) $request->header('X-Telegram-Bot-Api-Secret-Token')), + SymfonyResponse::HTTP_FORBIDDEN, + ); + + $update = $request->all(); + $chat = data_get($update, 'message.chat') ?? data_get($update, 'channel_post.chat'); + $text = data_get($update, 'message.text') ?? data_get($update, 'channel_post.text'); + + if (! is_array($chat) || ! is_string($text) || ! preg_match('/^\/connect(?:@\S+)?\s+(\S+)/', $text, $matches)) { + return response()->noContent(); + } + + $connectRequest = TelegramConnectRequest::query() + ->whereNull('social_account_id') + ->where('code', $matches[1]) + ->where('expires_at', '>', now()) + ->first(); + + if ($connectRequest === null) { + return response()->noContent(); + } + + $chatId = (string) data_get($chat, 'id'); + $username = data_get($chat, 'username'); + + $account = $connectRequest->workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => SocialPlatform::Telegram->value, + 'platform_user_id' => $chatId, + ], + [ + 'username' => $username, + 'display_name' => data_get($chat, 'title') ?? $username, + 'access_token' => '', + 'refresh_token' => '', + 'token_expires_at' => null, + 'scopes' => [], + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'chat_id' => $chatId, + 'username' => $username, + 'type' => data_get($chat, 'type'), + ], + ], + ); + + $connectRequest->update(['social_account_id' => $account->id]); + + return response()->noContent(); + } +} diff --git a/app/Models/TelegramConnectRequest.php b/app/Models/TelegramConnectRequest.php new file mode 100644 index 00000000..5f44a283 --- /dev/null +++ b/app/Models/TelegramConnectRequest.php @@ -0,0 +1,40 @@ + 'datetime', + ]; + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 74cd90e8..4fb3da56 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -66,6 +66,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Pinterest => $this->verifyPinterest($account), Platform::Bluesky => $this->verifyBluesky($account), Platform::Mastodon => $this->verifyMastodon($account), + Platform::Telegram => $this->verifyTelegram($account), }; } @@ -504,6 +505,19 @@ private function verifyBluesky(SocialAccount $account): bool return $response->successful(); } + private function verifyTelegram(SocialAccount $account): bool + { + $token = (string) config('trypost.platforms.telegram.bot_token'); + $api = rtrim((string) config('trypost.platforms.telegram.api'), '/'); + + // getChat succeeds only while the bot can still reach the chat. + $response = Http::get("{$api}/bot{$token}/getChat", [ + 'chat_id' => data_get($account->meta, 'chat_id'), + ]); + + return $response->successful() && data_get($response->json(), 'ok') === true; + } + private function verifyMastodon(SocialAccount $account): bool { $instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance'); diff --git a/bootstrap/app.php b/bootstrap/app.php index 996308de..abff5dd7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -40,6 +40,7 @@ $middleware->preventRequestForgery(except: [ 'stripe/*', + 'telegram/webhook', ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php b/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php new file mode 100644 index 00000000..ed34310a --- /dev/null +++ b/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php @@ -0,0 +1,29 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('code')->unique(); + // Set by the webhook once the channel is linked; null while pending. + $table->foreignUuid('social_account_id')->nullable()->constrained('social_accounts')->nullOnDelete(); + $table->timestamp('expires_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('telegram_connect_requests'); + } +}; diff --git a/routes/app.php b/routes/app.php index fe17fe82..f20c6a33 100644 --- a/routes/app.php +++ b/routes/app.php @@ -37,6 +37,7 @@ use App\Http\Controllers\Auth\MastodonController; use App\Http\Controllers\Auth\PinterestController; use App\Http\Controllers\Auth\SocialController; +use App\Http\Controllers\Auth\TelegramController; use App\Http\Controllers\Auth\ThreadsController; use App\Http\Controllers\Auth\TikTokController; use App\Http\Controllers\Auth\XController; @@ -117,6 +118,9 @@ Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('app.social.mastodon.connect'); Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize'); Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('app.social.mastodon.callback'); + + Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect'); + Route::get('connect/telegram/status', [TelegramController::class, 'status'])->name('app.social.telegram.status'); }); // Routes that require active subscription and completed onboarding diff --git a/routes/web.php b/routes/web.php index b2f144b6..77575c38 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,5 +2,10 @@ declare(strict_types=1); +use App\Http\Controllers\Webhooks\TelegramWebhookController; +use Illuminate\Support\Facades\Route; + +Route::post('telegram/webhook', [TelegramWebhookController::class, 'handle'])->name('telegram.webhook'); + require __DIR__.'/auth.php'; require __DIR__.'/app.php'; diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php new file mode 100644 index 00000000..489d6b76 --- /dev/null +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -0,0 +1,143 @@ + 'TESTTOKEN', + 'trypost.platforms.telegram.bot_username' => 'TryPostBot', + 'trypost.platforms.telegram.webhook_secret' => 'shh-secret', + ]); + + $this->workspace = Workspace::factory()->create(); + $this->user = User::factory()->create([ + 'current_workspace_id' => $this->workspace->id, + 'account_id' => $this->workspace->account_id, + ]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->refresh(); +}); + +function telegramUpdate(string $code, array $chat = []): array +{ + return [ + 'channel_post' => [ + 'message_id' => 5, + 'chat' => array_merge([ + 'id' => -1001234567890, + 'title' => 'My Channel', + 'username' => 'mychannel', + 'type' => 'channel', + ], $chat), + 'text' => "/connect {$code}", + ], + ]; +} + +it('issues a connect code', function () { + $response = $this->actingAs($this->user) + ->postJson(route('app.social.telegram.connect')) + ->assertOk() + ->assertJsonStructure(['code', 'bot_username', 'expires_at']); + + expect($response->json('bot_username'))->toBe('TryPostBot'); + + $this->assertDatabaseHas('telegram_connect_requests', [ + 'workspace_id' => $this->workspace->id, + 'code' => $response->json('code'), + 'social_account_id' => null, + ]); +}); + +it('links the channel when the webhook receives a matching /connect', function () { + $request = TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'abc123code', + 'expires_at' => now()->addMinutes(15), + ]); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('abc123code')) + ->assertNoContent(); + + $account = SocialAccount::where('workspace_id', $this->workspace->id) + ->where('platform', Platform::Telegram) + ->first(); + + expect($account)->not->toBeNull(); + expect($account->platform_user_id)->toBe('-1001234567890'); + expect($account->display_name)->toBe('My Channel'); + expect($account->username)->toBe('mychannel'); + expect(data_get($account->meta, 'chat_id'))->toBe('-1001234567890'); + + expect($request->fresh()->social_account_id)->toBe($account->id); +}); + +it('rejects the webhook without the secret token', function () { + $this->postJson(route('telegram.webhook'), telegramUpdate('whatever')) + ->assertForbidden(); +}); + +it('ignores the webhook for an unknown or expired code', function () { + TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'expiredcode', + 'expires_at' => now()->subMinute(), + ]); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('expiredcode')) + ->assertNoContent(); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('does-not-exist')) + ->assertNoContent(); + + expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0); +}); + +it('reports connection status while pending and once connected', function () { + $request = TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'statuscode', + 'expires_at' => now()->addMinutes(15), + ]); + + $this->actingAs($this->user) + ->getJson(route('app.social.telegram.status', ['code' => 'statuscode'])) + ->assertOk() + ->assertJson(['status' => 'pending']); + + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + $request->update(['social_account_id' => $account->id]); + + $this->actingAs($this->user) + ->getJson(route('app.social.telegram.status', ['code' => 'statuscode'])) + ->assertOk() + ->assertJson(['status' => 'connected']); +}); + +it('registers the webhook via the artisan command', function () { + Http::fake([ + '*/botTESTTOKEN/setWebhook' => Http::response(['ok' => true, 'result' => true], 200), + ]); + + $this->artisan('telegram:set-webhook')->assertSuccessful(); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/setWebhook') + && $request['secret_token'] === 'shh-secret' + && str_contains($request['url'], 'telegram/webhook'); + }); +}); From 79acdccc45ba920d61fb88e83b56a37f31143e20 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 21:58:49 -0300 Subject: [PATCH 03/35] Add Telegram connection UI (connect dialog + code polling) --- lang/en/accounts.php | 17 ++ lang/es/accounts.php | 17 ++ lang/pt-BR/accounts.php | 17 ++ public/images/accounts/telegram.svg | 10 + .../components/accounts/AddSocialDialog.vue | 215 +++++++++++------ .../accounts/TelegramConnectDialog.vue | 225 ++++++++++++++++++ 6 files changed, 434 insertions(+), 67 deletions(-) create mode 100644 public/images/accounts/telegram.svg create mode 100644 resources/js/components/accounts/TelegramConnectDialog.vue diff --git a/lang/en/accounts.php b/lang/en/accounts.php index ef6a4e93..9a58a9ad 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -51,6 +51,7 @@ 'pinterest' => 'Connect your Pinterest account', 'bluesky' => 'Connect your Bluesky account', 'mastodon' => 'Connect your Mastodon account', + 'telegram' => 'Connect a Telegram channel or group', ], 'disconnect_modal' => [ @@ -82,6 +83,22 @@ 'submitting' => 'Connecting...', ], + 'telegram' => [ + 'title' => 'Connect Telegram', + 'description' => 'Link a channel or group', + 'step_admin' => 'Add :bot as an administrator to your Telegram channel or group.', + 'step_command' => 'Post this command in the channel or group:', + 'waiting' => 'Waiting for the channel to connect…', + 'connected' => 'Channel connected!', + 'connected_toast' => 'Telegram channel connected successfully!', + 'copied_toast' => 'Command copied to clipboard', + 'expired' => 'This code has expired. Generate a new one to try again.', + 'new_code' => 'Generate a new code', + 'retry' => 'Try again', + 'error_generic' => 'Could not start the connection. Please try again.', + 'close' => 'Close', + ], + 'facebook' => [ 'title' => 'Select Facebook Page', 'description' => 'Choose which page you want to connect', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 7970b605..48842e10 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -51,6 +51,7 @@ 'pinterest' => 'Conecta tu cuenta de Pinterest', 'bluesky' => 'Conecta tu cuenta de Bluesky', 'mastodon' => 'Conecta tu cuenta de Mastodon', + 'telegram' => 'Conecta un canal o grupo de Telegram', ], 'disconnect_modal' => [ @@ -82,6 +83,22 @@ 'submitting' => 'Conectando...', ], + 'telegram' => [ + 'title' => 'Conectar Telegram', + 'description' => 'Vincula un canal o grupo', + 'step_admin' => 'Añade :bot como administrador de tu canal o grupo de Telegram.', + 'step_command' => 'Publica este comando en el canal o grupo:', + 'waiting' => 'Esperando a que el canal se conecte…', + 'connected' => '¡Canal conectado!', + 'connected_toast' => '¡Canal de Telegram conectado correctamente!', + 'copied_toast' => 'Comando copiado al portapapeles', + 'expired' => 'Este código ha caducado. Genera uno nuevo para volver a intentarlo.', + 'new_code' => 'Generar un nuevo código', + 'retry' => 'Reintentar', + 'error_generic' => 'No se pudo iniciar la conexión. Inténtalo de nuevo.', + 'close' => 'Cerrar', + ], + 'facebook' => [ 'title' => 'Seleccionar página de Facebook', 'description' => 'Elige qué página deseas conectar', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 1b20f80c..7c238aa9 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -51,6 +51,7 @@ 'pinterest' => 'Conecte sua conta do Pinterest', 'bluesky' => 'Conecte sua conta do Bluesky', 'mastodon' => 'Conecte sua conta do Mastodon', + 'telegram' => 'Conecte um canal ou grupo do Telegram', ], 'disconnect_modal' => [ @@ -82,6 +83,22 @@ 'submitting' => 'Conectando...', ], + 'telegram' => [ + 'title' => 'Conectar Telegram', + 'description' => 'Vincule um canal ou grupo', + 'step_admin' => 'Adicione :bot como administrador do seu canal ou grupo do Telegram.', + 'step_command' => 'Publique este comando no canal ou grupo:', + 'waiting' => 'Aguardando o canal conectar…', + 'connected' => 'Canal conectado!', + 'connected_toast' => 'Canal do Telegram conectado com sucesso!', + 'copied_toast' => 'Comando copiado para a área de transferência', + 'expired' => 'Este código expirou. Gere um novo para tentar de novo.', + 'new_code' => 'Gerar um novo código', + 'retry' => 'Tentar novamente', + 'error_generic' => 'Não foi possível iniciar a conexão. Tente novamente.', + 'close' => 'Fechar', + ], + 'facebook' => [ 'title' => 'Selecionar Página do Facebook', 'description' => 'Escolha qual página você deseja conectar', diff --git a/public/images/accounts/telegram.svg b/public/images/accounts/telegram.svg new file mode 100644 index 00000000..8e5fccef --- /dev/null +++ b/public/images/accounts/telegram.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue index 1aee18a1..46eaac40 100644 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ b/resources/js/components/accounts/AddSocialDialog.vue @@ -2,8 +2,9 @@ import { router } from '@inertiajs/vue3'; import { IconPlus } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; -import { onMounted, onUnmounted } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; +import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -32,24 +33,92 @@ const getPlatformDescription = (platform: string): string => // + ink 2px border + slight rotation per platform, real PNG logo inside. // `linkedin-page` / `instagram-facebook` fall back to the base brand // image and same color since they're variants of the same network. -const platformTheme: Record = { - instagram: { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' }, - 'instagram-facebook': { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' }, - facebook: { bg: 'bg-sky-200', rotate: 'rotate-1', image: '/images/accounts/facebook.png' }, - linkedin: { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' }, - 'linkedin-page': { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' }, - x: { bg: 'bg-amber-200', rotate: 'rotate-2', image: '/images/accounts/x.png' }, - tiktok: { bg: 'bg-fuchsia-200', rotate: '-rotate-1', image: '/images/accounts/tiktok.png' }, - youtube: { bg: 'bg-red-200', rotate: 'rotate-1', image: '/images/accounts/youtube.png' }, - pinterest: { bg: 'bg-rose-200', rotate: '-rotate-2', image: '/images/accounts/pinterest.png' }, - threads: { bg: 'bg-emerald-200', rotate: 'rotate-2', image: '/images/accounts/threads.png' }, - bluesky: { bg: 'bg-cyan-200', rotate: '-rotate-1', image: '/images/accounts/bluesky.png' }, - mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1', image: '/images/accounts/mastodon.png' }, +const platformTheme: Record< + string, + { bg: string; rotate: string; image: string } +> = { + instagram: { + bg: 'bg-pink-200', + rotate: '-rotate-2', + image: '/images/accounts/instagram.png', + }, + 'instagram-facebook': { + bg: 'bg-pink-200', + rotate: '-rotate-2', + image: '/images/accounts/instagram.png', + }, + facebook: { + bg: 'bg-sky-200', + rotate: 'rotate-1', + image: '/images/accounts/facebook.png', + }, + linkedin: { + bg: 'bg-blue-200', + rotate: '-rotate-1', + image: '/images/accounts/linkedin.png', + }, + 'linkedin-page': { + bg: 'bg-blue-200', + rotate: '-rotate-1', + image: '/images/accounts/linkedin.png', + }, + x: { + bg: 'bg-amber-200', + rotate: 'rotate-2', + image: '/images/accounts/x.png', + }, + tiktok: { + bg: 'bg-fuchsia-200', + rotate: '-rotate-1', + image: '/images/accounts/tiktok.png', + }, + youtube: { + bg: 'bg-red-200', + rotate: 'rotate-1', + image: '/images/accounts/youtube.png', + }, + pinterest: { + bg: 'bg-rose-200', + rotate: '-rotate-2', + image: '/images/accounts/pinterest.png', + }, + threads: { + bg: 'bg-emerald-200', + rotate: 'rotate-2', + image: '/images/accounts/threads.png', + }, + bluesky: { + bg: 'bg-cyan-200', + rotate: '-rotate-1', + image: '/images/accounts/bluesky.png', + }, + mastodon: { + bg: 'bg-violet-200', + rotate: 'rotate-1', + image: '/images/accounts/mastodon.png', + }, + telegram: { + bg: 'bg-sky-200', + rotate: '-rotate-2', + image: '/images/accounts/telegram.svg', + }, }; const themeFor = (value: string) => platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' }; +const telegramOpen = ref(false); + +const connectPlatform = (platformValue: string) => { + if (platformValue === 'telegram') { + open.value = false; + telegramOpen.value = true; + return; + } + + openOAuthPopup(platformValue); +}; + const openOAuthPopup = (platformValue: string) => { const url = `/connect/${platformValue}`; const width = 600; @@ -84,64 +153,76 @@ onUnmounted(() => { diff --git a/resources/js/components/accounts/TelegramConnectDialog.vue b/resources/js/components/accounts/TelegramConnectDialog.vue new file mode 100644 index 00000000..2ff715b6 --- /dev/null +++ b/resources/js/components/accounts/TelegramConnectDialog.vue @@ -0,0 +1,225 @@ + + + From b6605ea2287993581201ec24f2bd97ce5691056e Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:01:37 -0300 Subject: [PATCH 04/35] Document Telegram env vars in .env.example --- .env.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.example b/.env.example index fc3ed8b5..dd9e2b17 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,12 @@ PINTEREST_CLIENT_ID= PINTEREST_CLIENT_SECRET= PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback" +# Telegram (single shared bot — create one via https://t.me/BotFather) +# After setting these, run: php artisan telegram:set-webhook +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= +TELEGRAM_WEBHOOK_SECRET= + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= @@ -196,6 +202,7 @@ NIGHTWATCH_TOKEN= # PINTEREST_ENABLED=true # MASTODON_ENABLED=true # BLUESKY_ENABLED=true +# TELEGRAM_ENABLED=true # Media Services UNSPLASH_ACCESS_KEY= From b50424321296dcd9b1c93aabcda3c7e5e8af79a0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:02:02 -0300 Subject: [PATCH 05/35] Document Telegram env vars in docker .env example --- docker/.env.docker.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 5d8e48b6..706b63df 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -128,6 +128,12 @@ PINTEREST_CLIENT_ID= PINTEREST_CLIENT_SECRET= PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback" +# Telegram (single shared bot — create one via https://t.me/BotFather) +# After setting these, run: php artisan telegram:set-webhook +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= +TELEGRAM_WEBHOOK_SECRET= + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= From f37846984207b41e2ccfafb52ed11db2ed3fc10f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:10:12 -0300 Subject: [PATCH 06/35] Wire Telegram into the post composer (preview, content type, logo) --- lang/en/posts.php | 5 + lang/es/posts.php | 5 + lang/pt-BR/posts.php | 5 + .../js/components/SocialAccountsGrid.vue | 1 + .../posts/previews/PlatformPreview.vue | 3 + .../posts/previews/TelegramPreview.vue | 130 ++++++++++++++++++ resources/js/composables/usePlatformLogo.ts | 3 + resources/js/types/content-type.ts | 1 + resources/js/types/platform.ts | 1 + 9 files changed, 154 insertions(+) create mode 100644 resources/js/components/posts/previews/TelegramPreview.vue diff --git a/lang/en/posts.php b/lang/en/posts.php index b078650f..b38b845d 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -484,6 +484,10 @@ 'label' => 'Post', 'description' => 'Text post with optional media', ], + 'telegram_post' => [ + 'label' => 'Post', + 'description' => 'Text post with optional media', + ], ], 'platforms' => [ @@ -586,6 +590,7 @@ 'bluesky_post' => 'Bluesky Post', 'threads_post' => 'Threads Post', 'mastodon_post' => 'Mastodon Post', + 'telegram_post' => 'Telegram Post', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', diff --git a/lang/es/posts.php b/lang/es/posts.php index e04c19e0..220880f2 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -484,6 +484,10 @@ 'label' => 'Post', 'description' => 'Post de texto con multimedia opcional', ], + 'telegram_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto con multimedia opcional', + ], ], 'platforms' => [ @@ -587,6 +591,7 @@ 'bluesky_post' => 'Post en Bluesky', 'threads_post' => 'Post en Threads', 'mastodon_post' => 'Post en Mastodon', + 'telegram_post' => 'Post en Telegram', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 299bcfcc..e3311e02 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -484,6 +484,10 @@ 'label' => 'Post', 'description' => 'Post de texto com mídia opcional', ], + 'telegram_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto com mídia opcional', + ], ], 'platforms' => [ @@ -586,6 +590,7 @@ 'bluesky_post' => 'Post no Bluesky', 'threads_post' => 'Post no Threads', 'mastodon_post' => 'Post no Mastodon', + 'telegram_post' => 'Post no Telegram', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', diff --git a/resources/js/components/SocialAccountsGrid.vue b/resources/js/components/SocialAccountsGrid.vue index cc3be23f..40b54a9e 100644 --- a/resources/js/components/SocialAccountsGrid.vue +++ b/resources/js/components/SocialAccountsGrid.vue @@ -121,6 +121,7 @@ const getProfileUrl = (platform: string, username: string | null, platformUserId 'threads': `https://threads.net/@${username}`, 'bluesky': `https://bsky.app/profile/${username}`, 'pinterest': `https://pinterest.com/${username}`, + 'telegram': `https://t.me/${username}`, }; return urls[platform] || null; }; diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 42cacc39..565521ac 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -9,6 +9,7 @@ import InstagramPreview from './InstagramPreview.vue'; import LinkedInPreview from './LinkedInPreview.vue'; import MastodonPreview from './MastodonPreview.vue'; import PinterestPreview from './PinterestPreview.vue'; +import TelegramPreview from './TelegramPreview.vue'; import ThreadsPreview from './ThreadsPreview.vue'; import TikTokPreview from './TikTokPreview.vue'; import XPreview from './XPreview.vue'; @@ -65,6 +66,8 @@ const previewComponent = computed(() => { return BlueskyPreview; case 'mastodon': return MastodonPreview; + case 'telegram': + return TelegramPreview; default: return LinkedInPreview; } diff --git a/resources/js/components/posts/previews/TelegramPreview.vue b/resources/js/components/posts/previews/TelegramPreview.vue new file mode 100644 index 00000000..96ea9e6a --- /dev/null +++ b/resources/js/components/posts/previews/TelegramPreview.vue @@ -0,0 +1,130 @@ + + + diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index dc46ee93..317278f0 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -11,6 +11,7 @@ const PLATFORM_LOGOS: Record = { bluesky: '/images/accounts/bluesky.png', pinterest: '/images/accounts/pinterest.png', mastodon: '/images/accounts/mastodon.png', + telegram: '/images/accounts/telegram.svg', }; const PLATFORM_LABELS: Record = { @@ -26,6 +27,7 @@ const PLATFORM_LABELS: Record = { bluesky: 'Bluesky', pinterest: 'Pinterest', mastodon: 'Mastodon', + telegram: 'Telegram', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -41,6 +43,7 @@ const PLATFORM_CONTENT_TYPES: Record = { pinterest: ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'], bluesky: ['bluesky_post'], mastodon: ['mastodon_post'], + telegram: ['telegram_post'], }; export interface ContentTypeOption { diff --git a/resources/js/types/content-type.ts b/resources/js/types/content-type.ts index 3d8a22bb..8db617ca 100644 --- a/resources/js/types/content-type.ts +++ b/resources/js/types/content-type.ts @@ -19,6 +19,7 @@ export const ContentType = { PinterestCarousel: 'pinterest_carousel', BlueskyPost: 'bluesky_post', MastodonPost: 'mastodon_post', + TelegramPost: 'telegram_post', } as const; export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType]; diff --git a/resources/js/types/platform.ts b/resources/js/types/platform.ts index ec785f19..56d29781 100644 --- a/resources/js/types/platform.ts +++ b/resources/js/types/platform.ts @@ -11,6 +11,7 @@ export const Platform = { Pinterest: 'pinterest', Bluesky: 'bluesky', Mastodon: 'mastodon', + Telegram: 'telegram', } as const; export type PlatformValue = (typeof Platform)[keyof typeof Platform]; From deb1c6fa69b4b69db3ba414aef67ce193c5a6aec Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:20:52 -0300 Subject: [PATCH 07/35] Extract Telegram webhook registration into an action --- .../SocialAccount/RegisterTelegramWebhook.php | 45 +++++++++++++++++ app/Console/Commands/Telegram/SetWebhook.php | 27 +++-------- .../RegisterTelegramWebhookTest.php | 48 +++++++++++++++++++ 3 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 app/Actions/SocialAccount/RegisterTelegramWebhook.php create mode 100644 tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php diff --git a/app/Actions/SocialAccount/RegisterTelegramWebhook.php b/app/Actions/SocialAccount/RegisterTelegramWebhook.php new file mode 100644 index 00000000..4b0757e0 --- /dev/null +++ b/app/Actions/SocialAccount/RegisterTelegramWebhook.php @@ -0,0 +1,45 @@ + $url, + 'secret_token' => $secret, + 'allowed_updates' => ['message', 'channel_post'], + ]); + + if (! $response->successful() || data_get($response->json(), 'ok') !== true) { + throw new RuntimeException("Failed to set Telegram webhook: {$response->body()}"); + } + + return $url; + } +} diff --git a/app/Console/Commands/Telegram/SetWebhook.php b/app/Console/Commands/Telegram/SetWebhook.php index 33a77dea..28a473c9 100644 --- a/app/Console/Commands/Telegram/SetWebhook.php +++ b/app/Console/Commands/Telegram/SetWebhook.php @@ -4,10 +4,11 @@ namespace App\Console\Commands\Telegram; +use App\Actions\SocialAccount\RegisterTelegramWebhook; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Http; +use Throwable; #[Signature('telegram:set-webhook')] #[Description('Register the Telegram bot webhook with the configured URL and secret token')] @@ -15,26 +16,10 @@ class SetWebhook extends Command { public function handle(): int { - $token = (string) config('trypost.platforms.telegram.bot_token'); - $api = rtrim((string) config('trypost.platforms.telegram.api'), '/'); - $secret = (string) config('trypost.platforms.telegram.webhook_secret'); - - if ($token === '' || $secret === '') { - $this->error('TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET must both be set.'); - - return self::FAILURE; - } - - $url = route('telegram.webhook'); - - $response = Http::post("{$api}/bot{$token}/setWebhook", [ - 'url' => $url, - 'secret_token' => $secret, - 'allowed_updates' => ['message', 'channel_post'], - ]); - - if (! $response->successful() || data_get($response->json(), 'ok') !== true) { - $this->error('Failed to set webhook: '.$response->body()); + try { + $url = RegisterTelegramWebhook::execute(); + } catch (Throwable $e) { + $this->error($e->getMessage()); return self::FAILURE; } diff --git a/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php b/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php new file mode 100644 index 00000000..4bfea510 --- /dev/null +++ b/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php @@ -0,0 +1,48 @@ + 'TESTTOKEN', + 'trypost.platforms.telegram.webhook_secret' => 'shh-secret', + ]); +}); + +test('it registers the webhook with the url, secret and allowed updates', function () { + Http::fake([ + '*/botTESTTOKEN/setWebhook' => Http::response(['ok' => true, 'result' => true], 200), + ]); + + $url = RegisterTelegramWebhook::execute(); + + expect($url)->toBe(route('telegram.webhook')); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/botTESTTOKEN/setWebhook') + && $request['url'] === route('telegram.webhook') + && $request['secret_token'] === 'shh-secret' + && $request['allowed_updates'] === ['message', 'channel_post']; + }); +}); + +test('it throws when the bot token or secret is missing', function () { + config(['trypost.platforms.telegram.webhook_secret' => '']); + + Http::fake(); + + expect(fn () => RegisterTelegramWebhook::execute())->toThrow(InvalidArgumentException::class); + + Http::assertNothingSent(); +}); + +test('it throws when telegram rejects the request', function () { + Http::fake([ + '*/botTESTTOKEN/setWebhook' => Http::response(['ok' => false, 'description' => 'Unauthorized'], 401), + ]); + + expect(fn () => RegisterTelegramWebhook::execute())->toThrow(RuntimeException::class); +}); From 2585d89cb445a4ba7df625e1dbd1448a46f564bc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:38:23 -0300 Subject: [PATCH 08/35] Address PR review: connect-status enum, publisher cleanup, fill test gaps --- .../SocialAccount/TelegramConnectStatus.php | 28 +++++++++ .../Social/TelegramPublishException.php | 2 +- .../Controllers/Auth/TelegramController.php | 12 ++-- .../Webhooks/TelegramWebhookController.php | 2 +- app/Services/Social/TelegramPublisher.php | 38 ++++------- .../accounts/TelegramConnectDialog.vue | 35 ++++++----- .../Services/Social/TelegramPublisherTest.php | 49 +++++++++++++++ .../Feature/Social/TelegramConnectionTest.php | 44 +++++++++++++ .../Social/TelegramPublishExceptionTest.php | 63 +++++++++++++++++++ 9 files changed, 224 insertions(+), 49 deletions(-) create mode 100644 app/Enums/SocialAccount/TelegramConnectStatus.php create mode 100644 tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php diff --git a/app/Enums/SocialAccount/TelegramConnectStatus.php b/app/Enums/SocialAccount/TelegramConnectStatus.php new file mode 100644 index 00000000..3afeb0bc --- /dev/null +++ b/app/Enums/SocialAccount/TelegramConnectStatus.php @@ -0,0 +1,28 @@ + self::Unknown, + $request->social_account_id !== null => self::Connected, + $request->isExpired() => self::Expired, + default => self::Pending, + }; + } +} diff --git a/app/Exceptions/Social/TelegramPublishException.php b/app/Exceptions/Social/TelegramPublishException.php index 26943e1a..56675239 100644 --- a/app/Exceptions/Social/TelegramPublishException.php +++ b/app/Exceptions/Social/TelegramPublishException.php @@ -55,7 +55,7 @@ public static function fromApiResponse(mixed $response): static return new static( userMessage: $description, - category: ErrorCategory::ContentPolicy, + category: ErrorCategory::Unknown, platformErrorCode: (string) $status, rawResponse: $rawResponse, ); diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index fb146860..9e7d7530 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Enums\SocialAccount\TelegramConnectStatus; use App\Models\TelegramConnectRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -56,13 +57,8 @@ public function status(Request $request): JsonResponse ->where('code', (string) $request->query('code')) ->first(); - $status = match (true) { - $connectRequest === null => 'unknown', - $connectRequest->social_account_id !== null => 'connected', - $connectRequest->isExpired() => 'expired', - default => 'pending', - }; - - return response()->json(['status' => $status]); + return response()->json([ + 'status' => TelegramConnectStatus::for($connectRequest)->value, + ]); } } diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index c394dade..1ebbb066 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -56,7 +56,7 @@ public function handle(Request $request): Response ], [ 'username' => $username, - 'display_name' => data_get($chat, 'title') ?? $username, + 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", 'access_token' => '', 'refresh_token' => '', 'token_expires_at' => null, diff --git a/app/Services/Social/TelegramPublisher.php b/app/Services/Social/TelegramPublisher.php index 24e79e24..8772fda5 100644 --- a/app/Services/Social/TelegramPublisher.php +++ b/app/Services/Social/TelegramPublisher.php @@ -104,37 +104,25 @@ private function sendSingleMedia(string $chatId, array $item, string $caption): */ private function sendMediaGroup(string $chatId, array $items, string $caption): int { - $firstMessageId = 0; + $group = []; - foreach (array_chunk($items, self::ALBUM_CHUNK) as $chunkIndex => $chunk) { - $group = []; + foreach ($items as $index => $item) { + $entry = ['type' => $item['type'], 'media' => $item['url']]; - 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; + if ($index === 0 && $caption !== '') { + $entry['caption'] = $caption; + $entry['parse_mode'] = 'HTML'; } - $response = $this->call('sendMediaGroup', [ - 'chat_id' => $chatId, - 'media' => json_encode($group), - ]); - - if ($chunkIndex === 0) { - $firstMessageId = (int) data_get($response->json(), 'result.0.message_id'); - } + $group[] = $entry; } - return $firstMessageId; + $response = $this->call('sendMediaGroup', [ + 'chat_id' => $chatId, + 'media' => json_encode($group), + ]); + + return (int) data_get($response->json(), 'result.0.message_id'); } /** diff --git a/resources/js/components/accounts/TelegramConnectDialog.vue b/resources/js/components/accounts/TelegramConnectDialog.vue index 2ff715b6..d295a372 100644 --- a/resources/js/components/accounts/TelegramConnectDialog.vue +++ b/resources/js/components/accounts/TelegramConnectDialog.vue @@ -23,17 +23,26 @@ import { const open = defineModel('open', { required: true }); type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error'; +type ConnectStatus = 'unknown' | 'pending' | 'connected' | 'expired'; + +interface ConnectResponse { + code: string; + bot_username: string; + expires_at: string; +} + +const POLL_INTERVAL_MS = 3000; +const SUCCESS_CLOSE_DELAY_MS = 1200; const phase = ref('loading'); const code = ref(''); const botUsername = ref(''); const errorMessage = ref(''); -const httpConnect = useHttp< - Record, - { code: string; bot_username: string; expires_at: string } ->({}); -const httpStatus = useHttp, { status: string }>({}); +const httpConnect = useHttp, ConnectResponse>({}); +const httpStatus = useHttp, { status: ConnectStatus }>( + {}, +); let pollTimer: ReturnType | null = null; @@ -59,7 +68,7 @@ const poll = async () => { setTimeout(() => { open.value = false; router.reload(); - }, 1200); + }, SUCCESS_CLOSE_DELAY_MS); return; } @@ -72,7 +81,7 @@ const poll = async () => { // Transient polling failures are ignored; the next tick retries. } - pollTimer = setTimeout(poll, 3000); + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); }; const start = async () => { @@ -176,13 +185,11 @@ onUnmounted(stopPolling); class="flex size-6 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-xs font-semibold" >1 - + {{ + trans('accounts.telegram.step_admin', { + bot: `@${botUsername}`, + }) + }}
  • post->update([ + 'content' => 'A clip', + 'media' => [[ + 'id' => 'm1', + 'path' => 'media/clip.mp4', + 'url' => 'https://cdn.test/clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + ]], + ]); + + Http::fake([ + '*/botTESTTOKEN/sendVideo' => Http::response(telegramOk(['message_id' => 8]), 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/sendVideo') + && str_contains($request['video'], 'clip.mp4') + && $request['caption'] === 'A clip'; + }); +}); + +test('telegram publisher sends a non-image, non-video file as a document', function () { + $this->post->update([ + 'content' => 'A file', + 'media' => [[ + 'id' => 'm1', + 'path' => 'media/report.pdf', + 'url' => 'https://cdn.test/report.pdf', + 'mime_type' => 'application/pdf', + 'original_filename' => 'report.pdf', + ]], + ]); + + Http::fake([ + '*/botTESTTOKEN/sendDocument' => Http::response(telegramOk(['message_id' => 9]), 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/sendDocument') + && str_contains($request['document'], 'report.pdf'); + }); +}); + test('telegram publisher sends multiple media as an album', function () { $this->post->update([ 'content' => 'Album', diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php index 489d6b76..e930537f 100644 --- a/tests/Feature/Social/TelegramConnectionTest.php +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -8,6 +8,7 @@ use App\Models\TelegramConnectRequest; use App\Models\User; use App\Models\Workspace; +use App\Services\Social\ConnectionVerifier; use Illuminate\Support\Facades\Http; beforeEach(function () { @@ -82,6 +83,25 @@ function telegramUpdate(string $code, array $chat = []): array expect($request->fresh()->social_account_id)->toBe($account->id); }); +it('links a private channel that has no username', function () { + TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'privatecode', + 'expires_at' => now()->addMinutes(15), + ]); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('privatecode', ['username' => null])) + ->assertNoContent(); + + $account = SocialAccount::where('platform', Platform::Telegram)->first(); + + expect($account->username)->toBeNull(); + expect($account->display_name)->toBe('My Channel'); + expect(data_get($account->meta, 'username'))->toBeNull(); +}); + it('rejects the webhook without the secret token', function () { $this->postJson(route('telegram.webhook'), telegramUpdate('whatever')) ->assertForbidden(); @@ -128,6 +148,30 @@ function telegramUpdate(string $code, array $chat = []): array ->assertJson(['status' => 'connected']); }); +it('verifies a connected telegram account via getChat', function () { + config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']); + + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + + Http::fake([ + '*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => ['id' => -1001234567890]], 200), + ]); + + expect(app(ConnectionVerifier::class)->verify($account))->toBeTrue(); +}); + +it('reports a telegram account as invalid when getChat fails', function () { + config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']); + + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + + Http::fake([ + '*/botTESTTOKEN/getChat*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400), + ]); + + expect(app(ConnectionVerifier::class)->verify($account))->toBeFalse(); +}); + it('registers the webhook via the artisan command', function () { Http::fake([ '*/botTESTTOKEN/setWebhook' => Http::response(['ok' => true, 'result' => true], 200), diff --git a/tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php b/tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php new file mode 100644 index 00000000..1e577d7d --- /dev/null +++ b/tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php @@ -0,0 +1,63 @@ + Http::response($body, $status)])->post('https://api.telegram.org/botX/sendMessage'); +} + +test('HTTP 403 maps to Permission category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Forbidden'], 403), + ); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('403'); +}); + +test('HTTP 401 maps to Permission category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Unauthorized'], 401), + ); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('401'); +}); + +test('HTTP 429 maps to RateLimit category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Too Many Requests'], 429), + ); + + expect($exception->category)->toBe(ErrorCategory::RateLimit); +}); + +test('HTTP 500 maps to ServerError category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Internal'], 500), + ); + + expect($exception->category)->toBe(ErrorCategory::ServerError); +}); + +test('other errors map to Unknown category with the api description', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Bad Request: chat not found'], 400), + ); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('Bad Request: chat not found'); +}); + +test('platform returns telegram', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Error'], 400), + ); + + expect($exception->platform())->toBe('telegram'); +}); From c096092c7b8e3edd7cab15d777c6724874f31dcf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:48:21 -0300 Subject: [PATCH 09/35] Polish PR: status FormRequest, sanitizer anchor guard, webhook limit, dedupe OAuth popup --- .../Controllers/Auth/TelegramController.php | 5 +- .../Webhooks/TelegramWebhookController.php | 28 +- .../App/Auth/TelegramStatusRequest.php | 25 ++ app/Services/Social/ContentSanitizer.php | 3 + .../js/components/SocialAccountsGrid.vue | 277 ++++++++++++------ .../components/accounts/AddSocialDialog.vue | 43 +-- resources/js/composables/useOAuthPopup.ts | 34 +++ .../Services/Social/ContentSanitizerTest.php | 6 + .../Feature/Social/TelegramConnectionTest.php | 54 ++++ 9 files changed, 348 insertions(+), 127 deletions(-) create mode 100644 app/Http/Requests/App/Auth/TelegramStatusRequest.php create mode 100644 resources/js/composables/useOAuthPopup.ts diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index 9e7d7530..a6367822 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -6,6 +6,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\TelegramConnectStatus; +use App\Http\Requests\App\Auth\TelegramStatusRequest; use App\Models\TelegramConnectRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -47,14 +48,14 @@ public function connect(Request $request): JsonResponse /** * Poll whether the channel has been linked yet. */ - public function status(Request $request): JsonResponse + public function status(TelegramStatusRequest $request): JsonResponse { $workspace = $request->user()->currentWorkspace; abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.'); $connectRequest = TelegramConnectRequest::query() ->where('workspace_id', $workspace->id) - ->where('code', (string) $request->query('code')) + ->where('code', $request->validated('code')) ->first(); return response()->json([ diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index 1ebbb066..6b281df4 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -6,10 +6,13 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Features\SocialAccountLimit; use App\Http\Controllers\Controller; use App\Models\TelegramConnectRequest; +use App\Models\Workspace; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Laravel\Pennant\Feature; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class TelegramWebhookController extends Controller @@ -49,7 +52,19 @@ public function handle(Request $request): Response $chatId = (string) data_get($chat, 'id'); $username = data_get($chat, 'username'); - $account = $connectRequest->workspace->socialAccounts()->updateOrCreate( + $workspace = $connectRequest->workspace; + + // Mirror the controller's limit gate: block only brand-new accounts, never reconnects. + $isNewAccount = ! $workspace->socialAccounts() + ->where('platform', SocialPlatform::Telegram->value) + ->where('platform_user_id', $chatId) + ->exists(); + + if ($isNewAccount && $this->workspaceAtAccountLimit($workspace)) { + return response()->noContent(); + } + + $account = $workspace->socialAccounts()->updateOrCreate( [ 'platform' => SocialPlatform::Telegram->value, 'platform_user_id' => $chatId, @@ -76,4 +91,15 @@ public function handle(Request $request): Response return response()->noContent(); } + + private function workspaceAtAccountLimit(Workspace $workspace): bool + { + if (config('trypost.self_hosted')) { + return false; + } + + $limit = Feature::for($workspace->account)->value(SocialAccountLimit::class); + + return $workspace->socialAccounts()->count() >= $limit; + } } diff --git a/app/Http/Requests/App/Auth/TelegramStatusRequest.php b/app/Http/Requests/App/Auth/TelegramStatusRequest.php new file mode 100644 index 00000000..3810e203 --- /dev/null +++ b/app/Http/Requests/App/Auth/TelegramStatusRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string'], + ]; + } +} diff --git a/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php index 79a3ba0b..d9fa3df0 100644 --- a/app/Services/Social/ContentSanitizer.php +++ b/app/Services/Social/ContentSanitizer.php @@ -35,6 +35,9 @@ private function toTelegramHtml(string $content): string $content = preg_replace(['/<(\/?)strong>/i', '/<(\/?)em>/i'], ['<$1b>', '<$1i>'], $content); $content = strip_tags($content, ['b', 'i', 'u', 's', 'a', 'code', 'pre']); + // Telegram requires every to carry an href; drop bare anchors so the parser doesn't reject the whole message. + $content = preg_replace('/]*\shref=)[^>]*>(.*?)<\/a>/is', '$1', $content); + // Escape bare ampersands while leaving existing entities intact. $content = preg_replace('/&(?!(?:amp|lt|gt|quot|#\d+);)/', '&', $content); diff --git a/resources/js/components/SocialAccountsGrid.vue b/resources/js/components/SocialAccountsGrid.vue index 40b54a9e..6e2bcc63 100644 --- a/resources/js/components/SocialAccountsGrid.vue +++ b/resources/js/components/SocialAccountsGrid.vue @@ -1,13 +1,25 @@