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:
parent
bf0494fbe6
commit
cc8f6625a1
8 changed files with 236 additions and 14 deletions
|
|
@ -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),
|
||||
|
|
|
|||
107
app/Services/Social/Discord/DiscordAnalytics.php
Normal file
107
app/Services/Social/Discord/DiscordAnalytics.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
'impressions' => 'Impressions',
|
||||
'interactions' => 'Interactions',
|
||||
'likes' => 'Likes',
|
||||
'members' => 'Members',
|
||||
'minutes_watched' => 'Minutes Watched',
|
||||
'organic_followers' => 'Organic Followers',
|
||||
'outbound_clicks' => 'Outbound Clicks',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
85
tests/Feature/Services/Social/DiscordAnalyticsTest.php
Normal file
85
tests/Feature/Services/Social/DiscordAnalyticsTest.php
Normal 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']]);
|
||||
});
|
||||
Loading…
Reference in a new issue