diff --git a/.env.example b/.env.example index fc3ed8b5..dc13c3bb 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# Public base URL inbound webhooks (e.g. Telegram) are registered on. +# Defaults to APP_URL; set a tunnel URL (e.g. ngrok) for local development. +WEBHOOK_URL= + # Self-hosted mode (skips payment requirements) SELF_HOSTED=true @@ -152,6 +156,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 +206,7 @@ NIGHTWATCH_TOKEN= # PINTEREST_ENABLED=true # MASTODON_ENABLED=true # BLUESKY_ENABLED=true +# TELEGRAM_ENABLED=true # Media Services UNSPLASH_ACCESS_KEY= diff --git a/app/Actions/SocialAccount/ConnectTelegramChannel.php b/app/Actions/SocialAccount/ConnectTelegramChannel.php new file mode 100644 index 00000000..3743a113 --- /dev/null +++ b/app/Actions/SocialAccount/ConnectTelegramChannel.php @@ -0,0 +1,116 @@ + $chat The `chat` object from the Bot API update. + * @return SocialAccount|null The linked account, or null when blocked (account + * limit reached or the code was already consumed). + */ + public static function execute(Workspace $workspace, array $chat, string $nonce): ?SocialAccount + { + $chatId = (string) data_get($chat, 'id'); + $username = data_get($chat, 'username'); + + // Block only brand-new accounts against the plan limit, never reconnects. + $isNewAccount = ! $workspace->socialAccounts() + ->where('platform', Platform::Telegram->value) + ->where('platform_user_id', $chatId) + ->exists(); + + if ($isNewAccount && self::workspaceAtAccountLimit($workspace)) { + return null; + } + + // Consume the code once so a leaked code can't be replayed to link another chat. + if (! Cache::add("telegram:connect:{$nonce}", true, now()->addMinutes(15))) { + return null; + } + + $account = $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => Platform::Telegram->value, + 'platform_user_id' => $chatId, + ], + [ + 'username' => $username, + 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", + 'avatar_url' => self::fetchChannelAvatar($chatId), + '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'), + 'connect_nonce' => $nonce, + ], + ], + ); + + TelegramChannelConnected::dispatch($workspace->id, $nonce); + + return $account; + } + + /** + * Download the channel's photo via the Bot API and store it, returning the path. + */ + private static function fetchChannelAvatar(string $chatId): ?string + { + if (TelegramApi::token() === '') { + return null; + } + + try { + $fileId = data_get(Http::get(TelegramApi::endpoint('getChat'), ['chat_id' => $chatId])->json(), 'result.photo.big_file_id'); + + if (! is_string($fileId)) { + return null; + } + + $filePath = data_get(Http::get(TelegramApi::endpoint('getFile'), ['file_id' => $fileId])->json(), 'result.file_path'); + + if (! is_string($filePath)) { + return null; + } + + return uploadFromUrl(TelegramApi::fileUrl($filePath)); + } catch (Throwable) { + return null; + } + } + + private static 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/Actions/SocialAccount/RegisterTelegramWebhook.php b/app/Actions/SocialAccount/RegisterTelegramWebhook.php new file mode 100644 index 00000000..68d12ddc --- /dev/null +++ b/app/Actions/SocialAccount/RegisterTelegramWebhook.php @@ -0,0 +1,44 @@ + $url, + 'secret_token' => $secret, + 'allowed_updates' => ['message', 'channel_post', 'message_reaction_count'], + ]); + + 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/Actions/SocialAccount/StoreTelegramReactions.php b/app/Actions/SocialAccount/StoreTelegramReactions.php new file mode 100644 index 00000000..2b2f1be5 --- /dev/null +++ b/app/Actions/SocialAccount/StoreTelegramReactions.php @@ -0,0 +1,49 @@ + $update The `message_reaction_count` payload. + */ + public static function execute(array $update): void + { + $chatId = (string) data_get($update, 'chat.id'); + $messageId = (string) data_get($update, 'message_id'); + + if ($chatId === '' || $messageId === '') { + return; + } + + $postPlatform = PostPlatform::query() + ->where('platform', Platform::Telegram->value) + ->where('platform_post_id', $messageId) + ->whereHas('socialAccount', fn ($query) => $query->where('meta->chat_id', $chatId)) + ->first(); + + if ($postPlatform === null) { + return; + } + + $rawReactions = data_get($update, 'reactions'); + + $reactions = array_values(array_map(fn (array $reaction): array => [ + 'type' => (string) (data_get($reaction, 'type.emoji') ?? __('analytics.metrics.custom_reaction')), + 'count' => (int) data_get($reaction, 'total_count'), + ], is_array($rawReactions) ? $rawReactions : [])); + + $postPlatform->update(['meta' => [...$postPlatform->meta ?? [], 'reactions' => $reactions]]); + + Cache::forget("post_metrics:{$postPlatform->id}"); + } +} diff --git a/app/Console/Commands/Telegram/SetWebhook.php b/app/Console/Commands/Telegram/SetWebhook.php new file mode 100644 index 00000000..28a473c9 --- /dev/null +++ b/app/Console/Commands/Telegram/SetWebhook.php @@ -0,0 +1,31 @@ +error($e->getMessage()); + + return self::FAILURE; + } + + $this->info("Telegram webhook registered at {$url}"); + + return self::SUCCESS; + } +} 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/Events/TelegramChannelConnected.php b/app/Events/TelegramChannelConnected.php new file mode 100644 index 00000000..604207e3 --- /dev/null +++ b/app/Events/TelegramChannelConnected.php @@ -0,0 +1,45 @@ +workspaceId}"), + ]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'nonce' => $this->nonce, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Exceptions/Social/TelegramPublishException.php b/app/Exceptions/Social/TelegramPublishException.php new file mode 100644 index 00000000..56675239 --- /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::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'telegram'; + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index e3205698..aee64aad 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -11,6 +11,7 @@ use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; +use App\Services\Social\Telegram\TelegramAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; @@ -34,6 +35,7 @@ class AnalyticsController extends Controller Platform::LinkedInPage, Platform::Pinterest, Platform::YouTube, + Platform::Telegram, ]; public function index(Request $request): Response @@ -77,6 +79,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until), Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), + Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), default => [], }; diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php new file mode 100644 index 00000000..e24fa9cf --- /dev/null +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -0,0 +1,43 @@ +`). The code carries the workspace, so the webhook + * can link the channel without any persisted state. The returned `nonce` lets + * the UI recognise its own connection on the broadcast channel. + */ + 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); + + $expiresAt = now()->addMinutes(15); + $code = TelegramConnectCode::issue($workspace->id, $expiresAt); + + return response()->json([ + 'code' => $code, + 'nonce' => data_get(TelegramConnectCode::decode($code), 'nonce'), + 'bot_username' => config('trypost.platforms.telegram.bot_username'), + 'expires_at' => $expiresAt->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php new file mode 100644 index 00000000..d00e8717 --- /dev/null +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -0,0 +1,56 @@ +` + * message/channel_post: the signed code carries the workspace, so we link the + * originating channel to it. 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(); + + if (is_array($reactionUpdate = data_get($update, 'message_reaction_count'))) { + StoreTelegramReactions::execute($reactionUpdate); + + return response()->noContent(); + } + + $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(); + } + + $payload = TelegramConnectCode::decode($matches[1]); + $workspace = $payload === null ? null : Workspace::find(data_get($payload, 'workspace_id')); + + if ($workspace !== null) { + ConnectTelegramChannel::execute($workspace, $chat, data_get($payload, 'nonce')); + } + + return response()->noContent(); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index c040f6f0..e4d494d1 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\Telegram\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/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index 9f6ba8c4..de26dff9 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -13,6 +13,7 @@ use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\MastodonAnalytics; use App\Services\Social\PinterestAnalytics; +use App\Services\Social\Telegram\TelegramAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\XAnalytics; use App\Services\Social\YouTubeAnalytics; @@ -64,6 +65,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform), diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 74cd90e8..79ea1d50 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -8,6 +8,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; +use App\Services\Social\Telegram\TelegramApi; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -66,6 +67,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 +506,16 @@ private function verifyBluesky(SocialAccount $account): bool return $response->successful(); } + private function verifyTelegram(SocialAccount $account): bool + { + // getChat succeeds only while the bot can still reach the chat. + $response = Http::get(TelegramApi::endpoint('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/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php index f5f73e50..d9fa3df0 100644 --- a/app/Services/Social/ContentSanitizer.php +++ b/app/Services/Social/ContentSanitizer.php @@ -13,10 +13,39 @@ 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']); + + // 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); + + $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/Telegram/TelegramAnalytics.php b/app/Services/Social/Telegram/TelegramAnalytics.php new file mode 100644 index 00000000..395f2e9a --- /dev/null +++ b/app/Services/Social/Telegram/TelegramAnalytics.php @@ -0,0 +1,72 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + $chatId = data_get($account->meta, 'chat_id'); + + if (TelegramApi::token() === '' || $chatId === null) { + return []; + } + + try { + $count = data_get( + Http::get(TelegramApi::endpoint('getChatMemberCount'), ['chat_id' => $chatId])->json(), + 'result', + ); + } catch (Throwable) { + return []; + } + + if (! is_int($count)) { + return []; + } + + return [ + ['label' => __('analytics.metrics.subscribers'), 'value' => $count], + ]; + } + + /** + * Post-level metrics. Reaction counts are pushed by the webhook and stored + * on the post platform's meta (the Bot API offers no post views to bots). + * + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $metrics = $account + ? array_map(fn (array $metric): array => [...$metric, 'kind' => 'subscribers'], $this->getMetrics($account)) + : []; + + $reactions = data_get($postPlatform->meta, 'reactions', []); + + if (is_array($reactions)) { + foreach ($reactions as $reaction) { + $metrics[] = [ + 'label' => (string) data_get($reaction, 'type'), + 'value' => (int) data_get($reaction, 'count'), + 'kind' => 'reaction', + ]; + } + } + + return $metrics; + } +} diff --git a/app/Services/Social/Telegram/TelegramApi.php b/app/Services/Social/Telegram/TelegramApi.php new file mode 100644 index 00000000..dbe5d412 --- /dev/null +++ b/app/Services/Social/Telegram/TelegramApi.php @@ -0,0 +1,44 @@ +/` shape and config keys live in one place. + */ +class TelegramApi +{ + public static function token(): string + { + return (string) config('trypost.platforms.telegram.bot_token'); + } + + /** + * Endpoint for a Bot API method, e.g. `https://api.telegram.org/bot/sendMessage`. + */ + public static function endpoint(string $method): string + { + $base = self::baseUrl(); + $token = self::token(); + + return "{$base}/bot{$token}/{$method}"; + } + + /** + * Download URL for a file path returned by `getFile`. + */ + public static function fileUrl(string $path): string + { + $base = self::baseUrl(); + $token = self::token(); + + return "{$base}/file/bot{$token}/{$path}"; + } + + private static function baseUrl(): string + { + return rtrim((string) config('trypost.platforms.telegram.api'), '/'); + } +} diff --git a/app/Services/Social/Telegram/TelegramConnectCode.php b/app/Services/Social/Telegram/TelegramConnectCode.php new file mode 100644 index 00000000..c8ad463c --- /dev/null +++ b/app/Services/Social/Telegram/TelegramConnectCode.php @@ -0,0 +1,58 @@ + $workspaceId, + 'nonce' => Str::lower(Str::random(16)), + 'expires_at' => $expiresAt->getTimestamp(), + ])); + } + + /** + * Decode and validate a code, returning its payload or null when the code is + * missing, tampered with, malformed, or expired. + * + * @return array{workspace_id: string, nonce: string, expires_at: int}|null + */ + public static function decode(mixed $code): ?array + { + if (! is_string($code) || $code === '') { + return null; + } + + try { + $payload = json_decode(Crypt::decryptString($code), true); + } catch (DecryptException) { + return null; + } + + if ( + ! is_array($payload) + || ! is_string(data_get($payload, 'workspace_id')) + || ! is_string(data_get($payload, 'nonce')) + || now()->getTimestamp() > (int) data_get($payload, 'expires_at') + ) { + return null; + } + + return $payload; + } +} diff --git a/app/Services/Social/Telegram/TelegramMediaType.php b/app/Services/Social/Telegram/TelegramMediaType.php new file mode 100644 index 00000000..d7b881b3 --- /dev/null +++ b/app/Services/Social/Telegram/TelegramMediaType.php @@ -0,0 +1,39 @@ +isImage() => self::Photo, + $media->isVideo() => self::Video, + default => self::Document, + }; + } + + /** + * The Bot API method that sends a single media of this type. + */ + public function sendMethod(): string + { + return match ($this) { + self::Photo => 'sendPhoto', + self::Video => 'sendVideo', + self::Document => 'sendDocument', + }; + } +} diff --git a/app/Services/Social/Telegram/TelegramPublisher.php b/app/Services/Social/Telegram/TelegramPublisher.php new file mode 100644 index 00000000..7a589da1 --- /dev/null +++ b/app/Services/Social/Telegram/TelegramPublisher.php @@ -0,0 +1,162 @@ +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: TelegramMediaType, url: string} $item + */ + private function sendSingleMedia(string $chatId, array $item, string $caption): int + { + $type = $item['type']; + + $response = $this->call($type->sendMethod(), [ + 'chat_id' => $chatId, + $type->value => $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 + { + $group = []; + + foreach ($items as $index => $item) { + $entry = ['type' => $item['type']->value, 'media' => $item['url']]; + + if ($index === 0 && $caption !== '') { + $entry['caption'] = $caption; + $entry['parse_mode'] = 'HTML'; + } + + $group[] = $entry; + } + + $response = $this->call('sendMediaGroup', [ + 'chat_id' => $chatId, + 'media' => json_encode($group), + ]); + + return (int) data_get($response->json(), 'result.0.message_id'); + } + + /** + * @return array{type: TelegramMediaType, url: string} + */ + private function telegramMedia(MediaItem $media): array + { + return ['type' => TelegramMediaType::for($media), 'url' => $media->url]; + } + + private function call(string $method, array $payload): Response + { + $response = $this->socialHttp()->post(TelegramApi::endpoint($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/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/config/app.php b/config/app.php index 997df9a8..e8080f34 100644 --- a/config/app.php +++ b/config/app.php @@ -54,7 +54,20 @@ | */ - 'url' => env('APP_URL', 'https://trypost.it'), + 'url' => env('APP_URL', 'https://app.trypost.it'), + + /* + |-------------------------------------------------------------------------- + | Webhook URL + |-------------------------------------------------------------------------- + | + | Public base URL that inbound provider webhooks (e.g. Telegram) are + | registered on. Defaults to the app URL; override it (for example with a + | tunnel like ngrok) when the app URL isn't reachable from the internet. + | + */ + + 'webhook_url' => env('WEBHOOK_URL', env('APP_URL', 'https://app.trypost.it')), /* |-------------------------------------------------------------------------- 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/docker/.env.docker.example b/docker/.env.docker.example index 5d8e48b6..0c31fd1a 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -4,6 +4,10 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 +# Public base URL inbound webhooks (e.g. Telegram) are registered on. +# Defaults to APP_URL; set a tunnel URL (e.g. ngrok) for local development. +WEBHOOK_URL= + # Self-hosted mode (skips payment requirements) SELF_HOSTED=true @@ -128,6 +132,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= diff --git a/lang/en/accounts.php b/lang/en/accounts.php index ef6a4e93..71a3369f 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', + 'copy_tooltip' => 'Copy command', + '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.', + ], + 'facebook' => [ 'title' => 'Select Facebook Page', 'description' => 'Choose which page you want to connect', diff --git a/lang/en/analytics.php b/lang/en/analytics.php index a91ae0e8..ee6fbf22 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -13,6 +13,7 @@ 'bookmarks' => 'Bookmarks', 'clicks' => 'Clicks', 'comments' => 'Comments', + 'custom_reaction' => 'Custom', 'engagement' => 'Engagement', 'favourites' => 'Favourites', 'followers' => 'Followers', @@ -42,6 +43,7 @@ 'retweets' => 'Retweets', 'saves' => 'Saves', 'shares' => 'Shares', + 'subscribers' => 'Subscribers', 'subscribers_gained' => 'Subscribers Gained', 'subscribers_lost' => 'Subscribers Lost', 'total_likes' => 'Total Likes', 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/accounts.php b/lang/es/accounts.php index 7970b605..4fe5eb1e 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', + 'copy_tooltip' => 'Copiar comando', + '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.', + ], + 'facebook' => [ 'title' => 'Seleccionar página de Facebook', 'description' => 'Elige qué página deseas conectar', diff --git a/lang/es/analytics.php b/lang/es/analytics.php index d8a9d5f7..4f0a4ab0 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -13,6 +13,7 @@ 'bookmarks' => 'Guardados', 'clicks' => 'Clics', 'comments' => 'Comentarios', + 'custom_reaction' => 'Personalizada', 'engagement' => 'Engagement', 'favourites' => 'Favoritos', 'followers' => 'Seguidores', @@ -42,6 +43,7 @@ 'retweets' => 'Retweets', 'saves' => 'Guardados', 'shares' => 'Compartidos', + 'subscribers' => 'Suscriptores', 'subscribers_gained' => 'Suscriptores Ganados', 'subscribers_lost' => 'Suscriptores Perdidos', 'total_likes' => 'Total de Me gusta', 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/accounts.php b/lang/pt-BR/accounts.php index 1b20f80c..9323dc74 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', + 'copy_tooltip' => 'Copiar comando', + '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.', + ], + 'facebook' => [ 'title' => 'Selecionar Página do Facebook', 'description' => 'Escolha qual página você deseja conectar', diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index 215139bb..95e2139d 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -13,6 +13,7 @@ 'bookmarks' => 'Salvos', 'clicks' => 'Cliques', 'comments' => 'Comentários', + 'custom_reaction' => 'Personalizada', 'engagement' => 'Engajamento', 'favourites' => 'Favoritos', 'followers' => 'Seguidores', @@ -42,6 +43,7 @@ 'retweets' => 'Retweets', 'saves' => 'Salvos', 'shares' => 'Compartilhamentos', + 'subscribers' => 'Inscritos', 'subscribers_gained' => 'Inscritos Ganhos', 'subscribers_lost' => 'Inscritos Perdidos', 'total_likes' => 'Curtidas Totais', 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/public/images/accounts/telegram.png b/public/images/accounts/telegram.png new file mode 100644 index 00000000..80ebeb22 Binary files /dev/null and b/public/images/accounts/telegram.png differ diff --git a/resources/js/components/SocialAccountsGrid.vue b/resources/js/components/SocialAccountsGrid.vue index cc3be23f..6e2bcc63 100644 --- a/resources/js/components/SocialAccountsGrid.vue +++ b/resources/js/components/SocialAccountsGrid.vue @@ -1,13 +1,25 @@