Add Telegram analytics: per-post reactions (webhook) + channel subscribers

This commit is contained in:
Paulo Castellano 2026-06-14 11:18:40 -03:00
parent ce5d59a149
commit ba246a3ddd
14 changed files with 279 additions and 2 deletions

View file

@ -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) {

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use Illuminate\Support\Facades\Cache;
class StoreTelegramReactions
{
/**
* Persist the reaction counts pushed by a `message_reaction_count` update
* onto the matching published post, so they surface as post metrics.
*
* @param array<string, mixed> $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}");
}
}

View file

@ -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 => [],
};

View file

@ -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');

View file

@ -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),

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
class TelegramAnalytics
{
/**
* Account-level metrics. The Bot API only exposes the subscriber count.
*
* @return array<int, array{label: string, value: int}>
*/
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<int, array{label: string, value: int}>
*/
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));
}
}

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -0,0 +1,57 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { onMounted, ref, watch } from 'vue';
import MetricsGrid from '@/components/analytics/MetricsGrid.vue';
import { show as showAnalytics } from '@/routes/app/analytics';
interface MetricItem {
label: string;
value: number;
}
const props = defineProps<{
accountId: string;
}>();
const metrics = ref<MetricItem[]>([]);
const isLoading = ref(false);
const http = useHttp<Record<string, never>, { metrics: MetricItem[] }>({});
const fetchMetrics = async () => {
isLoading.value = true;
metrics.value = [];
try {
const response = await http.get(showAnalytics.url(props.accountId));
metrics.value = response?.metrics || [];
} catch {
metrics.value = [];
} finally {
isLoading.value = false;
}
};
watch(
() => props.accountId,
() => {
fetchMetrics();
},
);
onMounted(() => {
fetchMetrics();
});
defineExpose({ supportsDateRange: false });
</script>
<template>
<MetricsGrid
:metrics="metrics"
:loading="isLoading"
:empty-label="trans('analytics.no_data')"
/>
</template>

View file

@ -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"
/>
<TelegramAnalytics
v-else-if="selectedAccount?.platform === 'telegram'"
:account-id="selectedAccountId"
/>
<div v-else class="flex flex-1 items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>

View file

@ -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'];
});
});

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\TelegramAnalytics;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['trypost.platforms.telegram.bot_token' => '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([]);
});

View file

@ -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));