feat(channels): Discord post-level engagement metrics

Add per-post Discord metrics (server member count, thread replies as comments,
and reaction counts) read live with the bot token via GET message + guild
with_counts, wired into PostMetricsFetcher. Redesign the shared post metrics
row: monochrome line icons for member/comment counts vs clean reaction pills,
so audience stats read distinctly from reactions. Applies to Telegram too.
This commit is contained in:
Paulo Castellano 2026-06-16 17:05:13 -03:00
parent bf0494fbe6
commit cc8f6625a1
8 changed files with 236 additions and 14 deletions

View file

@ -8,6 +8,7 @@
use App\Models\Post;
use App\Models\PostPlatform;
use App\Services\Social\BlueskyAnalytics;
use App\Services\Social\Discord\DiscordAnalytics;
use App\Services\Social\FacebookAnalytics;
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
@ -66,6 +67,7 @@ public function forPlatform(PostPlatform $postPlatform): array
Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Discord => app(DiscordAnalytics::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,107 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Discord;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Throwable;
/**
* Discord engagement metrics. Discord exposes no impressions/reach/views for
* bot messages, so the signals are the server member count (account-level) and,
* per post, reaction counts and thread replies read live with the bot token.
*/
class DiscordAnalytics
{
public function __construct(private DiscordClient $client) {}
/**
* Account-level metric: the server's approximate member count.
*
* @return array<int, array{label: string, value: int}>
*/
public function getMetrics(SocialAccount $account): array
{
$guildId = (string) $account->platform_user_id;
if ($guildId === '') {
return [];
}
try {
$response = $this->client->getGuild($guildId);
} catch (Throwable) {
return [];
}
if ($response->failed()) {
return [];
}
$count = data_get($response->json(), 'approximate_member_count');
if (! is_int($count)) {
return [];
}
return [
['label' => __('analytics.metrics.members'), 'value' => $count],
];
}
/**
* Post-level metrics: thread replies plus reaction counts per emoji, tagged
* so the UI renders them as pills alongside the member count.
*
* @return array<int, array{label: string, value: int, kind?: string}>
*/
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$metrics = $account
? array_map(fn (array $metric): array => [...$metric, 'kind' => 'subscribers'], $this->getMetrics($account))
: [];
$channelId = (string) data_get($postPlatform->meta, 'channel_id');
$messageId = (string) $postPlatform->platform_post_id;
if ($channelId === '' || $messageId === '') {
return $metrics;
}
try {
$response = $this->client->getMessage($channelId, $messageId);
} catch (Throwable) {
return $metrics;
}
if ($response->failed()) {
return $metrics;
}
$message = $response->json();
foreach ((array) data_get($message, 'reactions', []) as $reaction) {
$name = (string) data_get($reaction, 'emoji.name');
$label = data_get($reaction, 'emoji.id') ? ":{$name}:" : $name;
$metrics[] = [
'label' => $label !== '' ? $label : __('analytics.metrics.custom_reaction'),
'value' => (int) data_get($reaction, 'count'),
'kind' => 'reaction',
];
}
// Thread replies are their own metric (kind "comments") so the UI renders
// them as a 💬 pill next to the member count, with a distinguishing tooltip.
$replies = (int) data_get($message, 'thread.message_count', 0);
if ($replies > 0) {
$metrics[] = ['label' => __('analytics.metrics.comments'), 'value' => $replies, 'kind' => 'comments'];
}
return $metrics;
}
}

View file

@ -242,7 +242,12 @@ private function getList(string $url, array $query = []): array
public function getGuild(string $guildId): Response
{
return $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}");
return $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}", ['with_counts' => 'true']);
}
public function getMessage(string $channelId, string $messageId): Response
{
return $this->bot()->get("{$this->baseUrl()}/channels/{$channelId}/messages/{$messageId}");
}
private function bot(): PendingRequest

View file

@ -21,6 +21,7 @@
'impressions' => 'Impressions',
'interactions' => 'Interactions',
'likes' => 'Likes',
'members' => 'Members',
'minutes_watched' => 'Minutes Watched',
'organic_followers' => 'Organic Followers',
'outbound_clicks' => 'Outbound Clicks',

View file

@ -21,6 +21,7 @@
'impressions' => 'Impresiones',
'interactions' => 'Interacciones',
'likes' => 'Me gusta',
'members' => 'Miembros',
'minutes_watched' => 'Minutos Vistos',
'organic_followers' => 'Seguidores Orgánicos',
'outbound_clicks' => 'Clics Externos',

View file

@ -21,6 +21,7 @@
'impressions' => 'Impressões',
'interactions' => 'Interações',
'likes' => 'Curtidas',
'members' => 'Membros',
'minutes_watched' => 'Minutos Assistidos',
'organic_followers' => 'Seguidores Orgânicos',
'outbound_clicks' => 'Cliques Externos',

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { IconChartBar, IconLoader2, IconUsers } from '@tabler/icons-vue';
import { IconChartBar, IconLoader2, IconMessageCircle, IconUsers } from '@tabler/icons-vue';
import { computed, onMounted, ref } from 'vue';
import {
@ -34,6 +34,9 @@ const stats = computed(() => metrics.value.filter((m) => !m.kind));
const subscribers = computed(() =>
metrics.value.find((m) => m.kind === 'subscribers'),
);
const comments = computed(() =>
metrics.value.find((m) => m.kind === 'comments'),
);
const reactions = computed(() =>
metrics.value.filter((m) => m.kind === 'reaction'),
);
@ -98,18 +101,17 @@ onMounted(async () => {
</div>
<div
v-if="subscribers || reactions.length > 0"
class="flex flex-wrap items-center gap-1.5"
v-if="subscribers || comments || reactions.length > 0"
class="flex flex-wrap items-center gap-x-3 gap-y-2"
:class="{ 'mt-2': stats.length > 0 }"
>
<!-- Audience counts: monochrome line icons read as stats, not reactions. -->
<TooltipProvider v-if="subscribers">
<Tooltip>
<TooltipTrigger as-child>
<span
class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs"
>
<IconUsers class="size-3.5 text-muted-foreground" />
<span class="font-semibold tabular-nums">{{
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconUsers class="size-4" :stroke="1.75" />
<span class="font-semibold tabular-nums text-foreground">{{
formatNumberCompact(subscribers.value)
}}</span>
</span>
@ -120,19 +122,37 @@ onMounted(async () => {
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="comments">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconMessageCircle class="size-4" :stroke="1.75" />
<span class="font-semibold tabular-nums text-foreground">{{
formatNumberCompact(comments.value)
}}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ comments.label }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Divider between audience stats and the reactions chip. -->
<span
v-if="subscribers && reactions.length > 0"
class="mx-0.5 h-4 w-px bg-border"
v-if="(subscribers || comments) && reactions.length > 0"
class="h-3.5 w-px bg-border"
aria-hidden="true"
/>
<!-- Reactions: each emoji its own clean pill with its count. -->
<span
v-for="reaction in reactions"
:key="reaction.label"
class="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-1 text-xs"
class="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs"
>
<span class="text-sm leading-none">{{ reaction.label }}</span>
<span class="font-semibold tabular-nums">{{
<span class="text-[13px] leading-none">{{ reaction.label }}</span>
<span class="font-semibold tabular-nums text-foreground/80">{{
formatNumberCompact(reaction.value)
}}</span>
</span>

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordAnalytics;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
});
it('returns the server member count as an account metric', function () {
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['id' => '111222333', 'approximate_member_count' => 4200], 200),
]);
expect(app(DiscordAnalytics::class)->getMetrics($account))
->toBe([['label' => 'Members', 'value' => 4200]]);
});
it('returns no account metrics when the guild lookup fails', function () {
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['message' => 'Unknown Guild'], 404),
]);
expect(app(DiscordAnalytics::class)->getMetrics($account))->toBe([]);
});
it('maps message reactions and thread replies to post metrics', function () {
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
$postPlatform = PostPlatform::factory()->create([
'social_account_id' => $account->id,
'platform' => Platform::Discord,
'status' => Status::Published,
'platform_post_id' => '777',
'meta' => ['channel_id' => '444555666'],
]);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['approximate_member_count' => 50], 200),
config('trypost.platforms.discord.api').'/channels/444555666/messages/777' => Http::response([
'id' => '777',
'thread' => ['message_count' => 8],
'reactions' => [
['count' => 12, 'emoji' => ['id' => null, 'name' => '🔥']],
['count' => 3, 'emoji' => ['id' => '999', 'name' => 'partyblob']],
],
], 200),
]);
expect(app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform))
->toBe([
['label' => 'Members', 'value' => 50, 'kind' => 'subscribers'],
['label' => '🔥', 'value' => 12, 'kind' => 'reaction'],
['label' => ':partyblob:', 'value' => 3, 'kind' => 'reaction'],
['label' => 'Comments', 'value' => 8, 'kind' => 'comments'],
]);
});
it('returns only the member count when the message has no engagement yet', function () {
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
$postPlatform = PostPlatform::factory()->create([
'social_account_id' => $account->id,
'platform' => Platform::Discord,
'status' => Status::Published,
'platform_post_id' => '777',
'meta' => ['channel_id' => '444555666'],
]);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['approximate_member_count' => 50], 200),
config('trypost.platforms.discord.api').'/channels/444555666/messages/777' => Http::response(['id' => '777'], 200),
]);
expect(app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform))
->toBe([['label' => 'Members', 'value' => 50, 'kind' => 'subscribers']]);
});