From ba246a3ddd01780ef12bba40f731da8c0b24a66b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 14 Jun 2026 11:18:40 -0300 Subject: [PATCH] Add Telegram analytics: per-post reactions (webhook) + channel subscribers --- .../SocialAccount/RegisterTelegramWebhook.php | 2 +- .../SocialAccount/StoreTelegramReactions.php | 47 ++++++++++++++ .../Controllers/App/AnalyticsController.php | 3 + .../Webhooks/TelegramWebhookController.php | 8 +++ app/Services/Post/PostMetricsFetcher.php | 2 + app/Services/Social/TelegramAnalytics.php | 61 +++++++++++++++++++ lang/en/analytics.php | 2 + lang/es/analytics.php | 2 + lang/pt-BR/analytics.php | 2 + .../analytics/TelegramAnalytics.vue | 57 +++++++++++++++++ resources/js/pages/analytics/Index.vue | 6 ++ .../RegisterTelegramWebhookTest.php | 2 +- .../Services/Social/TelegramAnalyticsTest.php | 53 ++++++++++++++++ .../Feature/Social/TelegramConnectionTest.php | 34 +++++++++++ 14 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 app/Actions/SocialAccount/StoreTelegramReactions.php create mode 100644 app/Services/Social/TelegramAnalytics.php create mode 100644 resources/js/components/analytics/TelegramAnalytics.vue create mode 100644 tests/Feature/Services/Social/TelegramAnalyticsTest.php diff --git a/app/Actions/SocialAccount/RegisterTelegramWebhook.php b/app/Actions/SocialAccount/RegisterTelegramWebhook.php index 4b0757e0..58f42dda 100644 --- a/app/Actions/SocialAccount/RegisterTelegramWebhook.php +++ b/app/Actions/SocialAccount/RegisterTelegramWebhook.php @@ -33,7 +33,7 @@ public static function execute(): string $response = Http::post("{$api}/bot{$token}/setWebhook", [ 'url' => $url, 'secret_token' => $secret, - 'allowed_updates' => ['message', 'channel_post'], + 'allowed_updates' => ['message', 'channel_post', 'message_reaction_count'], ]); if (! $response->successful() || data_get($response->json(), 'ok') !== true) { diff --git a/app/Actions/SocialAccount/StoreTelegramReactions.php b/app/Actions/SocialAccount/StoreTelegramReactions.php new file mode 100644 index 00000000..83998c5d --- /dev/null +++ b/app/Actions/SocialAccount/StoreTelegramReactions.php @@ -0,0 +1,47 @@ + $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; + } + + $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'), + ], data_get($update, 'reactions', []))); + + $postPlatform->update(['meta' => [...$postPlatform->meta ?? [], 'reactions' => $reactions]]); + + Cache::forget("post_metrics:{$postPlatform->id}"); + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index e3205698..084554b7 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\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/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index 04400724..c823a072 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhooks; use App\Actions\SocialAccount\ConnectTelegramChannel; +use App\Actions\SocialAccount\StoreTelegramReactions; use App\Http\Controllers\Controller; use App\Models\Workspace; use App\Services\Social\TelegramConnectCode; @@ -29,6 +30,13 @@ public function handle(Request $request): Response ); $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'); diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index 9f6ba8c4..78a5bb55 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\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/TelegramAnalytics.php b/app/Services/Social/TelegramAnalytics.php new file mode 100644 index 00000000..1cf9f150 --- /dev/null +++ b/app/Services/Social/TelegramAnalytics.php @@ -0,0 +1,61 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + $token = (string) config('trypost.platforms.telegram.bot_token'); + $api = rtrim((string) config('trypost.platforms.telegram.api'), '/'); + $chatId = data_get($account->meta, 'chat_id'); + + if ($token === '' || $chatId === null) { + return []; + } + + $count = data_get( + Http::get("{$api}/bot{$token}/getChatMemberCount", ['chat_id' => $chatId])->json(), + 'result', + ); + + 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 + { + $reactions = data_get($postPlatform->meta, 'reactions', []); + + if (! is_array($reactions)) { + return []; + } + + return array_values(array_map(fn (array $reaction): array => [ + 'label' => (string) data_get($reaction, 'type'), + 'value' => (int) data_get($reaction, 'count'), + ], $reactions)); + } +} 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/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/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/resources/js/components/analytics/TelegramAnalytics.vue b/resources/js/components/analytics/TelegramAnalytics.vue new file mode 100644 index 00000000..44994bff --- /dev/null +++ b/resources/js/components/analytics/TelegramAnalytics.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/pages/analytics/Index.vue b/resources/js/pages/analytics/Index.vue index 8b7f9408..8c4b009b 100644 --- a/resources/js/pages/analytics/Index.vue +++ b/resources/js/pages/analytics/Index.vue @@ -8,6 +8,7 @@ import FacebookAnalytics from '@/components/analytics/FacebookAnalytics.vue'; import InstagramAnalytics from '@/components/analytics/InstagramAnalytics.vue'; import LinkedInPageAnalytics from '@/components/analytics/LinkedInPageAnalytics.vue'; import PinterestAnalytics from '@/components/analytics/PinterestAnalytics.vue'; +import TelegramAnalytics from '@/components/analytics/TelegramAnalytics.vue'; import ThreadsAnalytics from '@/components/analytics/ThreadsAnalytics.vue'; import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue'; import type { AnalyticsAccount } from '@/components/analytics/types'; @@ -115,6 +116,11 @@ const platformSupportsDateRange = computed(() => { :date-range="dateRange" /> + +
{{ $t('analytics.no_data') }}
diff --git a/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php b/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php index 4bfea510..ef47b125 100644 --- a/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php +++ b/tests/Feature/Actions/SocialAccount/RegisterTelegramWebhookTest.php @@ -25,7 +25,7 @@ return str_contains($request->url(), '/botTESTTOKEN/setWebhook') && $request['url'] === route('telegram.webhook') && $request['secret_token'] === 'shh-secret' - && $request['allowed_updates'] === ['message', 'channel_post']; + && $request['allowed_updates'] === ['message', 'channel_post', 'message_reaction_count']; }); }); diff --git a/tests/Feature/Services/Social/TelegramAnalyticsTest.php b/tests/Feature/Services/Social/TelegramAnalyticsTest.php new file mode 100644 index 00000000..cfd215a1 --- /dev/null +++ b/tests/Feature/Services/Social/TelegramAnalyticsTest.php @@ -0,0 +1,53 @@ + 'TESTTOKEN']); +}); + +it('returns the channel subscriber count as an account metric', function () { + $account = SocialAccount::factory()->telegram()->create(); + + Http::fake([ + '*/botTESTTOKEN/getChatMemberCount*' => Http::response(['ok' => true, 'result' => 1234], 200), + ]); + + expect(app(TelegramAnalytics::class)->getMetrics($account)) + ->toBe([['label' => 'Subscribers', 'value' => 1234]]); +}); + +it('returns no account metrics when the member count call fails', function () { + $account = SocialAccount::factory()->telegram()->create(); + + Http::fake([ + '*/botTESTTOKEN/getChatMemberCount*' => Http::response(['ok' => false], 400), + ]); + + expect(app(TelegramAnalytics::class)->getMetrics($account))->toBe([]); +}); + +it('maps stored reactions to post metrics', function () { + $postPlatform = PostPlatform::factory()->create([ + 'platform' => Platform::Telegram, + 'meta' => ['reactions' => [['type' => '👍', 'count' => 12], ['type' => '❤️', 'count' => 5]]], + ]); + + expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform)) + ->toBe([['label' => '👍', 'value' => 12], ['label' => '❤️', 'value' => 5]]); +}); + +it('returns no post metrics when there are no reactions yet', function () { + $postPlatform = PostPlatform::factory()->create([ + 'platform' => Platform::Telegram, + 'meta' => [], + ]); + + expect(app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform))->toBe([]); +}); diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php index 4ba127aa..bb80d30b 100644 --- a/tests/Feature/Social/TelegramConnectionTest.php +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -5,6 +5,8 @@ use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; use App\Events\TelegramChannelConnected; +use App\Models\Post; +use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; @@ -172,6 +174,38 @@ function telegramUpdate(string $code, array $chat = []): array )->toBeTrue(); }); +it('stores reaction counts on the matching post from a reaction update', function () { + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $postPlatform = PostPlatform::factory()->published()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => Platform::Telegram, + 'platform_post_id' => '42', + ]); + + $update = [ + 'message_reaction_count' => [ + 'chat' => ['id' => -1001234567890], + 'message_id' => 42, + 'reactions' => [ + ['type' => ['type' => 'emoji', 'emoji' => '👍'], 'total_count' => 12], + ['type' => ['type' => 'emoji', 'emoji' => '❤️'], 'total_count' => 5], + ], + ], + ]; + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), $update) + ->assertNoContent(); + + expect(data_get($postPlatform->fresh()->meta, 'reactions')) + ->toBe([['type' => '👍', 'count' => 12], ['type' => '❤️', 'count' => 5]]); +}); + it('rejects the webhook without the secret token', function () { $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));