Add Telegram analytics: per-post reactions (webhook) + channel subscribers
This commit is contained in:
parent
ce5d59a149
commit
ba246a3ddd
14 changed files with 279 additions and 2 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
47
app/Actions/SocialAccount/StoreTelegramReactions.php
Normal file
47
app/Actions/SocialAccount/StoreTelegramReactions.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 => [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
61
app/Services/Social/TelegramAnalytics.php
Normal file
61
app/Services/Social/TelegramAnalytics.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
57
resources/js/components/analytics/TelegramAnalytics.vue
Normal file
57
resources/js/components/analytics/TelegramAnalytics.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
53
tests/Feature/Services/Social/TelegramAnalyticsTest.php
Normal file
53
tests/Feature/Services/Social/TelegramAnalyticsTest.php
Normal 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([]);
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue