Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app-modules/activity/src/Message/Actions/NewMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function persist(NewMessageDTO $messageDTO): void
'external_account_id' => $messageDTO->externalAccountId,
'model_type' => (new User)->getMorphClass(),
'username' => $messageDTO->providerUsername,
'avatar' => $messageDTO->avatar,
]);

$userContext = resolve(ResolveUserContext::class)->handle($userDto);
Expand Down
4 changes: 3 additions & 1 deletion app-modules/activity/src/Message/DTOs/NewMessageDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function __construct(
public string $channelId,
public string $content,
public DateTimeImmutable $sentAt,
public ?string $avatar = null,
) {}

/**
Expand All @@ -33,7 +34,8 @@ public static function make(array $payload): self
providerMessageId: $payload['provider_message_id'],
channelId: $payload['channel_id'],
content: $payload['content'],
sentAt: new DateTimeImmutable($payload['sent_at'])
sentAt: new DateTimeImmutable($payload['sent_at']),
avatar: $payload['avatar'] ?? null
);
}
}
5 changes: 3 additions & 2 deletions app-modules/bot-discord/src/Events/MessageReceivedEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,13 @@
resolve(NewMessage::class)->persist(new NewMessageDTO(
tenantId: $tenantProvider->tenant_id,
provider: IdentityProvider::Discord,
providerUsername: $message->author->username.'#'.$message->author->discriminator,
providerUsername: $message->author->username,
externalAccountId: $message->user_id,
providerMessageId: $message->id,
channelId: $message->channel_id,
content: $message->content,
sentAt: $message->timestamp->toDateTimeImmutable()
sentAt: $message->timestamp->toDateTimeImmutable(),
avatar: $message->author->avatar,

Check failure on line 53 in app-modules/bot-discord/src/Events/MessageReceivedEvent.php

View workflow job for this annotation

GitHub Actions / Perform Phpstan Check / Run

Access to an undefined property Discord\Parts\User\User::$avatar.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and display the relevant section of the file.
FILE="app-modules/bot-discord/src/Events/MessageReceivedEvent.php"
if [ -f "$FILE" ]; then
  echo "=== $FILE (around line 40-80) ==="
  nl -ba "$FILE" | sed -n '35,80p'
else
  echo "Missing file: $FILE"
fi

# Search for avatar getter usage patterns across the repo.
echo "=== Searching for avatar usage patterns ==="
rg -n --hidden --no-ignore-vcs "getAvatarAttribute\\(|\\$author->avatar\\b|->avatar\\b" .

# Search for DiscordPHP User avatar accessor patterns if present.
echo "=== Searching for 'Discord\\Parts\\User\\User' type usage ==="
rg -n --hidden --no-ignore-vcs "Discord\\\\Parts\\\\User\\\\User" .

# Search for any existing workaround/stubs for DiscordPHP magic properties.
echo "=== Searching for PHPStan stubs/workarounds ==="
rg -n --hidden --no-ignore-vcs "(phpstan|stub|ignoreErrors|magic|undefined property|User::\\$avatar)" .

Repository: he4rt/heartdevs.com

Length of output: 212


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app-modules/bot-discord/src/Events/MessageReceivedEvent.php"
if [ -f "$FILE" ]; then
  echo "=== $FILE (around lines 40-80) ==="
  # show with line numbers via awk (nl isn't available)
  awk 'NR>=35 && NR<=80 {printf "%6d  %s\n", NR, $0}' "$FILE"
else
  echo "Missing file: $FILE"
  exit 1
fi

echo "=== Searching for avatar usage patterns ==="
rg -n --hidden --no-ignore-vcs "getAvatarAttribute\\(|\\$author->avatar\\b|->avatar\\b" app-modules/bot-discord src . || true

echo "=== Searching for DiscordPHP User avatar accessor patterns ==="
rg -n --hidden --no-ignore-vcs "getAvatarAttribute\\(" app-modules/bot-discord . || true

echo "=== Searching for 'Discord\\\\Parts\\\\User\\\\User' type usage ==="
rg -n --hidden --no-ignore-vcs "Discord\\\\Parts\\\\User\\\\User" . || true

echo "=== Searching for existing PHPStan/stub/ignore workarounds ==="
rg -n --hidden --no-ignore-vcs "(phpstan|stub|ignoreErrors|magic|undefined property|User::\\$avatar)" . || true

Repository: he4rt/heartdevs.com

Length of output: 2582


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Contents around $FILE ==="
FILE="app-modules/bot-discord/src/Events/MessageReceivedEvent.php"
awk 'NR>=45 && NR<=65 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo
echo "=== Searching for getAvatarAttribute usage ==="
rg -n --hidden --no-ignore-vcs "getAvatarAttribute\\(" app-modules . || true

echo
echo "=== Searching for direct avatar magic-property usage (${message->author->avatar}) ==="
rg -n --hidden --no-ignore-vcs -F "\$message->author->avatar" . || true

echo
echo "=== Searching for other '->avatar' property accesses ==="
rg -n --hidden --no-ignore-vcs "->avatar\\b" app-modules . || true

echo
echo "=== Searching for PHPStan ignore/stubs for undefined/magic properties ==="
rg -n --hidden --no-ignore-vcs "(ignoreErrors|`@phpstan-ignore`|undefined property|stub|magic property|User::\\$avatar)" . || true

echo
echo "=== Searching for any 'User' avatar accessor patterns ==="
rg -n --hidden --no-ignore-vcs "Avatar" app-modules/bot-discord/src . || true

Repository: he4rt/heartdevs.com

Length of output: 105


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app-modules/bot-discord/src/Events/MessageReceivedEvent.php"

echo "=== Contents around $FILE ==="
awk 'NR>=45 && NR<=65 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo
echo "=== Searching for getAvatarAttribute usage ==="
rg -n --hidden --no-ignore-vcs "getAvatarAttribute\\(" app-modules . || true

echo
echo "=== Searching for direct avatar magic-property usage ($message->author->avatar) ==="
rg -n --hidden --no-ignore-vcs -F "\$message->author->avatar" . || true

echo
echo "=== Searching for other '->avatar' property accesses ==="
rg -n --hidden --no-ignore-vcs "->avatar\\b" app-modules . || true

echo
echo "=== Searching for PHPStan ignore/stubs for undefined/magic properties ==="
rg -n --hidden --no-ignore-vcs "(ignoreErrors|`@phpstan-ignore`|undefined property|stub|magic property|User::\\$avatar|User\\s*::\\s*\\$avatar)" . || true

Repository: he4rt/heartdevs.com

Length of output: 1495


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app-modules/bot-discord/src/Events/MessageReceivedEvent.php"

echo "=== Contents around $FILE ==="
awk 'NR>=45 && NR<=60 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo
echo "=== Searching for getAvatarAttribute usage ==="
rg -n --hidden --no-ignore-vcs 'getAvatarAttribute\s*\(' app-modules . || true

echo
echo '=== Searching for direct avatar magic-property usage ($message->author->avatar) ==='
rg -n --hidden --no-ignore-vcs -F '\$message->author->avatar' app-modules . || true

echo
echo "=== Searching for any '->avatar' accesses ==="
rg -n --hidden --no-ignore-vcs '->avatar\b' app-modules . || true

echo
echo "=== Searching for PHPStan ignore/stub patterns for undefined/magic properties ==="
rg -n --hidden --no-ignore-vcs '(ignoreErrors|`@phpstan-ignore`|undefined property|stub|magic property|User::\$avatar|User\s*::\s*\$avatar)' . || true

Repository: he4rt/heartdevs.com

Length of output: 10461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect the PHPStan ignore rules for bot-discord, focusing on $avatar
IGNORE_FILE="app-modules/bot-discord/phpstan.ignore.neon"
echo "=== Contents of $IGNORE_FILE (full file) ==="
awk '{print NR ":" $0}' "$IGNORE_FILE" | sed -n '1,120p' || true

echo
echo "=== Searching for getAvatarAttribute in repo ==="
rg -n --hidden --no-ignore-vcs -F 'getAvatarAttribute' app-modules . || true

echo
echo "=== Searching for magic $author->avatar / $message->author->avatar ==="
rg -n --hidden --no-ignore-vcs -F '\$author->avatar' app-modules . || true
rg -n --hidden --no-ignore-vcs -F '\$message->author->avatar' app-modules . || true

echo
echo "=== Searching for other direct '->avatar' property reads (avoid regex escapes) ==="
rg -n --hidden --no-ignore-vcs -F '->avatar' app-modules . || true

Repository: he4rt/heartdevs.com

Length of output: 3466


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Contents of $IGNORE_FILE (full file) ==="
IGNORE_FILE="app-modules/bot-discord/phpstan.ignore.neon"
awk '{print NR ":" $0}' "$IGNORE_FILE" | sed -n '1,120p'

echo
echo "=== Search for getAvatarAttribute usage (anywhere) ==="
rg -n --hidden --no-ignore-vcs -F 'getAvatarAttribute' app-modules . || true

echo
echo "=== Search for direct magic avatar reads: '$message->author->avatar' ==="
rg -n --hidden --no-ignore-vcs -F '$message->author->avatar' app-modules . || true

echo
echo "=== Search for other magic avatar reads: '->avatar' in bot-discord module ==="
rg -n --hidden --no-ignore-vcs -F '->avatar' app-modules/bot-discord . || true

Repository: he4rt/heartdevs.com

Length of output: 112


🏁 Script executed:

#!/bin/bash
set -euo pipefail

IGNORE_FILE="app-modules/bot-discord/phpstan.ignore.neon"

echo "=== Contents of $IGNORE_FILE (full file) ==="
awk '{print NR ":" $0}' "$IGNORE_FILE" | sed -n '1,200p'

echo
echo "=== Search for getAvatarAttribute usage (anywhere) ==="
rg -n --hidden --no-ignore-vcs -F 'getAvatarAttribute' app-modules . || true

echo
echo "=== Search for direct magic avatar reads: '$message->author->avatar' ==="
rg -n --hidden --no-ignore-vcs -F '$message->author->avatar' app-modules . || true

echo
echo "=== Search for other magic avatar reads: '->avatar' in bot-discord module ==="
rg -n --hidden --no-ignore-vcs -F '->avatar' app-modules/bot-discord . || true

Repository: he4rt/heartdevs.com

Length of output: 3473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app-modules/bot-discord/src/Events/MessageReceivedEvent.php"

echo "=== app-modules/bot-discord/src/Events/MessageReceivedEvent.php (lines 1-120) ==="
awk 'NR>=1 && NR<=120 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo
echo "=== app-modules/bot-discord/src/Events/MessageReceivedEvent.php (lines 40-70) ==="
awk 'NR>=40 && NR<=70 {printf "%6d  %s\n", NR, $0}' "$FILE"

echo
echo "=== Search for getAvatarAttribute usage (whole repo) ==="
rg -n --hidden --no-ignore-vcs 'getAvatarAttribute\s*\(' . || true

echo
echo "=== Search for getAvatar( usage (whole repo) ==="
rg -n --hidden --no-ignore-vcs 'getAvatar\s*\(' . || true

echo
echo "=== Search for direct magic avatar reads: \\$message->author->avatar ==="
rg -n --hidden --no-ignore-vcs -F '\$message->author->avatar' . || true

echo
echo "=== Search for other direct '->avatar' reads in bot-discord module ==="
rg -n --hidden --no-ignore-vcs '->avatar\b' app-modules/bot-discord . || true

echo
echo "=== Show phpstan ignore entries for User::\\$avatar ==="
rg -n --hidden --no-ignore-vcs 'Discord\\\\Parts\\\\User\\\\User::\\\$avatar' app-modules/bot-discord/phpstan.ignore.neon || true

Repository: he4rt/heartdevs.com

Length of output: 5810


🌐 Web query:

DiscordPHP Discord\Parts\User\User avatar getter getAvatarAttribute

💡 Result:

In DiscordPHP, the getAvatarAttribute method is a helper function used to generate the URL for a user's avatar [1][2]. Method Signature: public function getAvatarAttribute(?string $format = null, int $size = 1024): string Parameters: - $format (string|null): The desired image format (e.g., 'png', 'jpg', 'webp', or 'gif'). If not provided, it defaults to 'webp', or 'gif' if the avatar is animated [2]. - $size (int): The width/height of the image in pixels. It defaults to 1024 and is automatically clamped to allowed powers of two between 16 and 4096 [2]. Usage: While you can access the default avatar URL via the $user->avatar property, getAvatarAttribute is intended for cases where you need to specify a custom image format or size [1][2]. Example: $url = $user->getAvatarAttribute('png', 2048); echo $url; // Outputs: https://cdn.discordapp.com/avatars/:user_id/:avatar_hash.png?size=2048 If the user does not have a custom avatar set, the method returns the URL to their default Discord embed avatar based on their discriminator or ID [2]. Note that a similar method exists for the Member part to fetch guild-specific avatars [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for phpstan ignore entries for Discord\\Parts\\User\\User::\\$avatar ==="
rg -n --hidden --no-ignore-vcs "Discord\\\\Parts\\\\User\\\\User::\\\$avatar" . || true

echo
echo "=== Search for phpstan ignore entries for User::\\$avatar (broader) ==="
rg -n --hidden --no-ignore-vcs "User::\\\$avatar" . || true

echo
echo "=== Search for 'avatar:' DTO field assignment patterns in bot-discord ==="
rg -n --hidden --no-ignore-vcs "avatar:\s*\$" app-modules/bot-discord . || true

echo
echo "=== Search for any getAvatarAttribute usage anywhere ==="
rg -n --hidden --no-ignore-vcs "getAvatarAttribute\s*\(" . || true

Repository: he4rt/heartdevs.com

Length of output: 107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for phpstan ignore entries for Discord\\Parts\\User\\User::\\$avatar ==="
rg -n --hidden --no-ignore-vcs 'Discord\\\\Parts\\\\User\\\\User::\$avatar' . || true

echo
echo "=== Search for phpstan ignore entries for User::\\$avatar (broader) ==="
rg -n --hidden --no-ignore-vcs 'User::\$avatar' . || true

echo
echo "=== Search for 'avatar:' DTO field assignment patterns in bot-discord ==="
rg -n --hidden --no-ignore-vcs 'avatar:\s*\$' app-modules/bot-discord . || true

echo
echo "=== Search for any getAvatarAttribute usage anywhere ==="
rg -n --hidden --no-ignore-vcs 'getAvatarAttribute\s*\(' . || true

Repository: he4rt/heartdevs.com

Length of output: 107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for phpstan ignore entries for Discord\\Parts\\User\\User::(avatar) ==="
rg -n --hidden --no-ignore-vcs 'Discord\\\\Parts\\\\User\\\\User::\$avatar' . || true

echo
echo "=== Search for phpstan ignore entries mentioning (User)::(avatar) ==="
rg -n --hidden --no-ignore-vcs 'User::\$avatar' . || true

echo
echo "=== Search for 'avatar:' DTO field assignment patterns in bot-discord ==="
rg -n --hidden --no-ignore-vcs 'avatar:\s*\$' app-modules/bot-discord . || true

echo
echo "=== Search for any getAvatarAttribute usage anywhere ==="
rg -n --hidden --no-ignore-vcs 'getAvatarAttribute\s*\(' . || true

Repository: he4rt/heartdevs.com

Length of output: 950


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the DTO type for `avatar`
DTO_FILE="app-modules/activity/src/Message/DTOs/NewMessageDTO.php"
echo "=== $DTO_FILE (lines 1-120) ==="
awk 'NR>=1 && NR<=140 {printf "%6d  %s\n", NR, $0}' "$DTO_FILE"

echo
echo "=== Inspect bot-discord phpstan ignore entry lines mentioning User\\User::\\\$avatar ==="
IGNORE_FILE="app-modules/bot-discord/phpstan.ignore.neon"
# print the full matching line numbers (no regex confusion)
rg -n --hidden --no-ignore-vcs -F 'Discord\\Parts\\User\\User::\$avatar' "$IGNORE_FILE" || true

Repository: he4rt/heartdevs.com

Length of output: 2002


Use the explicit DiscordPHP avatar getter for static analysis

Replace avatar: $message->author->avatar in app-modules/bot-discord/src/Events/MessageReceivedEvent.php with the documented accessor ($message->author->getAvatarAttribute()). The repo’s phpstan.ignore.neon suppresses Discord\Parts\User\User::$avatar only for other files, not MessageReceivedEvent.php, so this magic-property access is likely still flagged by PHPStan.

🧰 Tools
🪛 GitHub Check: Perform Phpstan Check / Run

[failure] 53-53:
Access to an undefined property Discord\Parts\User\User::$avatar.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/bot-discord/src/Events/MessageReceivedEvent.php` at line 53,
MessageReceivedEvent uses the magic property avatar on the Discord User which
PHPStan flags; replace the property access with the explicit accessor by calling
getAvatarAttribute() on the author object (i.e., change occurrences of
$message->author->avatar to $message->author->getAvatarAttribute()) in the
MessageReceivedEvent class so static analysis recognizes the getter.

));

// Moderation pipeline — SubmitForModeration handles pre-screen (sync) + async AI.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="flex items-center gap-3 py-1">
<img src="{{ asset('images/logo.svg') }}" alt="He4rt Developers" class="h-8 w-auto dark:invert" />
<div class="flex flex-col leading-none">
<span class="text-sm font-black tracking-tight text-zinc-950 dark:text-white uppercase">He4rt</span>
<span class="text-[10px] font-bold text-purple-600 uppercase tracking-widest">Developers</span>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class ResolveExternalIdentity
{
public function handle(ResolveUserProviderDTO $dto): ExternalIdentity
{
return ExternalIdentity::query()->firstOrCreate(
$identity = ExternalIdentity::query()->firstOrCreate(
[
'provider' => $dto->provider,
'tenant_id' => $dto->tenantId,
Expand All @@ -24,13 +24,22 @@ public function handle(ResolveUserProviderDTO $dto): ExternalIdentity
'type' => $dto->provider->getType(),
'credentials_type' => CredentialsType::OAuth2,
'credentials' => ClientAccessManager::make(),
'metadata' => array_filter([
'username' => $dto->username,
'email' => $dto->email,
'avatar' => $dto->avatar,
]),
'connected_at' => now(),
]
);

$metadata = array_filter([
'username' => $dto->username,
'email' => $dto->email,
'avatar' => $dto->avatar,
]);

if ($metadata !== []) {
$identity->update([
'metadata' => array_merge($identity->metadata ?? [], $metadata),
]);
Comment on lines +37 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip no-op metadata updates.

Lines 37-40 execute an UPDATE on every message whenever any metadata exists, even if username / email / avatar did not change. This action runs on the message-ingestion hot path, so the extra writes will churn updated_at and amplify DB load for active users. Compare the merged payload with $identity->metadata first and only update when it actually changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/identity/src/ExternalIdentity/Actions/ResolveExternalIdentity.php`
around lines 37 - 40, The current code always calls $identity->update when
$metadata is non-empty which causes no-op DB writes; compute $merged =
array_merge($identity->metadata ?? [], $metadata) and compare it to
$identity->metadata (strict equality) and only call
$identity->update(['metadata' => $merged]) if $merged !== ($identity->metadata
?? []); this ensures ResolveExternalIdentity only updates the DB when the
metadata actually changes.

}

return $identity;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use He4rt\IntegrationDiscord\ETL\Console\MergeDuplicateDiscordProfilesCommand;
use He4rt\IntegrationDiscord\Models\DiscordEventLog;
use He4rt\IntegrationDiscord\Sync\Console\SyncDiscordGuildCommand;
use He4rt\IntegrationDiscord\Sync\Console\SyncDiscordHistoryCommand;
use He4rt\IntegrationDiscord\Sync\Observers\DiscordEventLogObserver;
use He4rt\IntegrationDiscord\Transport\DiscordConnector;
use He4rt\IntegrationDiscord\Transport\DiscordOAuthConnector;
Expand Down Expand Up @@ -40,6 +41,7 @@ public function boot(): void
ImportDiscordMessagesCommand::class,
MergeDuplicateDiscordProfilesCommand::class,
SyncDiscordGuildCommand::class,
SyncDiscordHistoryCommand::class,
BackfillVoiceLogsCommand::class,
]);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

Check warning on line 1 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Pint format / Run

Found violation(s) of type: no_whitespace_in_blank_line

Check warning on line 1 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Pint format / Run

Found violation(s) of type: blank_line_before_statement

Check warning on line 1 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Pint format / Run

Found violation(s) of type: unary_operator_spaces

Check warning on line 1 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Pint format / Run

Found violation(s) of type: concat_space

Check warning on line 1 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Pint format / Run

Found violation(s) of type: single_quote

declare(strict_types=1);

namespace He4rt\IntegrationDiscord\Sync\Console;

use DateTimeImmutable;
use He4rt\Activity\Message\Actions\NewMessage;
use He4rt\Activity\Message\DTOs\NewMessageDTO;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\IntegrationDiscord\Transport\DiscordConnector;
use He4rt\IntegrationDiscord\Transport\Requests\Messages\ListChannelMessages;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Throwable;

#[Description('Sync historical messages from a Discord channel to ensure Meeting Showcase data is available.')]
#[Signature('discord:sync-history {channel_id} {--limit=100 : Number of messages to fetch}')]
final class SyncDiscordHistoryCommand extends Command
{
public function handle(NewMessage $newMessageAction): int
{
$channelId = (string) $this->argument('channel_id');

Check failure on line 26 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Rector Check / Run

ApplyDefaultInsteadOfNullCoalesceRector / CatchExceptionNameMatchingTypeRector / EncapsedStringsToSprintfRector / RemoveUnusedVariableInCatchRector / NewMethodCallWithoutParenthesesRector / AddClosureVoidReturnTypeWhereNoReturnRector --- Original +++ New @@ -26,7 +26,7 @@ $channelId = (string) $this->argument('channel_id'); $limit = (int) $this->option('limit'); - $botToken = config('discord.token') ?? env('HE4RT_DISCORD_BOT_KEY'); + $botToken = config('discord.token', env('HE4RT_DISCORD_BOT_KEY')); $guildId = env('HE4RT_DISCORD_GUILD'); if (! $botToken) { @@ -36,16 +36,16 @@ // Find the tenant associated with this guild to ensure multi-tenancy rules are respected $tenantProvider = ExternalIdentity::query() - ->where('model_type', (new Tenant())->getMorphClass()) + ->where('model_type', new Tenant()->getMorphClass()) ->where('external_account_id', (string) $guildId) ->first(); if (! $tenantProvider) { - $this->error("Tenant not found for guild {$guildId}. Check your environment configuration."); + $this->error(sprintf('Tenant not found for guild %s. Check your environment configuration.', $guildId)); return self::FAILURE; } - $this->info("Fetching last {$limit} messages from channel {$channelId}..."); + $this->info(sprintf('Fetching last %d messages from channel %s...', $limit, $channelId)); try { $connector = new DiscordConnector($botToken); @@ -59,7 +59,7 @@ $messages = $response->json(); $count = 0; - $this->withProgressBar($messages, function (array $m) use ($tenantProvider, $newMessageAction, &$count) { + $this->withProgressBar($messages, function (array $m) use ($tenantProvider, $newMessageAction, &$count): void { if (isset($m['author']['bot']) && $m['author']['bot']) { return; } @@ -79,17 +79,17 @@ avatar: $author['avatar'] ?? null, )); $count++; - } catch (Throwable $e) { + } catch (Throwable) { // Silently skip individual message failures to keep progress } }); $this->newLine(); - $this->info("Successfully synced {$count} messages and updated participant profiles."); + $this->info(sprintf('Successfully synced %d messages and updated participant profiles.', $count)); return self::SUCCESS; - } catch (Throwable $e) { - $this->error("An error occurred during sync: " . $e->getMessage()); + } catch (Throwable $throwable) { + $this->error("An error occurred during sync: " . $throwable->getMessage()); return self::FAILURE; } }
$limit = (int) $this->option('limit');

$botToken = config('discord.token') ?? env('HE4RT_DISCORD_BOT_KEY');

Check failure on line 29 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Phpstan Check / Run

Called 'env' outside of the config directory which returns null when the config is cached, use 'config'.
$guildId = env('HE4RT_DISCORD_GUILD');

Check failure on line 30 in app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php

View workflow job for this annotation

GitHub Actions / Perform Phpstan Check / Run

Called 'env' outside of the config directory which returns null when the config is cached, use 'config'.
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php"

echo "== Target file =="
ls -l "$FILE" || true
sed -n '1,120p' "$FILE" | nl -ba | sed -n '15,70p'

echo
echo "== Search for discord config keys =="
rg -n "config\('discord\.(token|guild)" -S . || true
rg -n "discord\.token|HE4RT_DISCORD_(BOT_KEY|GUILD)" -S . || true

echo
echo "== Locate discord config file =="
# Try common config locations, but without assuming too much: find any config file referencing discord.*
fd -t f "discord*.php" config || true
fd -t f "*discord*" config || true

echo
echo "== Print any likely discord config =="
# Print files that mention HE4RT_DISCORD or "discord.token"
fd -t f "*discord*.php" config -x sh -c 'rg -n "HE4RT_DISCORD_|discord\.token|token" "$1" && echo "---" && sed -n "1,200p" "$1"' sh {} || true

echo
echo "== Check NewMessage::persist implementation (for other issues in scratchpad) =="
MSG="app-modules/activity/src/Message/Actions/NewMessage.php"
ls -l "$MSG" || true
sed -n '1,220p' "$MSG" | nl -ba | sed -n '1,220p'

echo
echo "== Look for avatar field handling in sync command =="
rg -n "\['avatar'\]|avatar" "$FILE" || true

Repository: he4rt/heartdevs.com

Length of output: 270


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php"
echo "== Show relevant lines (29-40) =="
sed -n '20,45p' "$FILE" | cat -n

echo
echo "== Search for config/env usage for discord token/guild =="
rg -n "config\('discord\.(token|guild)" -S . || true
rg -n "HE4RT_DISCORD_(BOT_KEY|GUILD)|discord\.token|HE4RT_DISCORD_GUILD" -S . || true
rg -n "env\('HE4RT_DISCORD" -S . || true

echo
echo "== Locate discord-related config files =="
# These commands may fail if fd isn't present; handle gracefully.
if command -v fd >/dev/null 2>&1; then
  fd -t f "*discord*" config || true
  fd -t f "discord*.php" config || true
else
  echo "fd not available; skipping file discovery"
fi

echo
echo "== Find any config files that mention HE4RT_DISCORD or discord token keys =="
rg -n "HE4RT_DISCORD_|discord\.token|discord\.guild" -S config || true
rg -n "discord\.(token|guild)" -S . || true

echo
echo "== Inspect NewMessage::persist for exception swallowing / return type =="
MSG="app-modules/activity/src/Message/Actions/NewMessage.php"
ls -l "$MSG" || true
sed -n '1,260p' "$MSG" | cat -n

echo
echo "== Search persist() signature/usages =="
rg -n "function persist|persist\(" "$MSG" || true
rg -n "NewMessage::persist" -S . || true

echo
echo "== Search avatar handling in the sync command payload =="
sed -n '1,140p' "$FILE" | cat -n
rg -n "avatar" "$FILE" || true

Repository: he4rt/heartdevs.com

Length of output: 15012


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== config/discord.php =="
sed -n '1,120p' config/discord.php | cat -n

echo
echo "== config/he4rt.php (discord section) =="
rg -n "discord\s*=>" -n config/he4rt.php || true
sed -n '1,120p' config/he4rt.php | cat -n
sed -n '120,260p' config/he4rt.php | cat -n || true

echo
echo "== IntegrationDiscordServiceProvider (discord.token usage) =="
sed -n '1,120p' app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php | cat -n

echo
echo "== SyncDiscordHistoryCommand full relevant section =="
sed -n '20,90p' app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php | cat -n

Repository: he4rt/heartdevs.com

Length of output: 8671


Replace runtime env() reads with config() in SyncDiscordHistoryCommand
SyncDiscordHistoryCommand.php reads env('HE4RT_DISCORD_BOT_KEY') (via fallback) and env('HE4RT_DISCORD_GUILD') directly at runtime; switch to config-backed values instead—e.g. $botToken = config('discord.token') and $guildId = config('he4rt.discord.guild_id')—so behavior remains stable under Laravel config caching.

🧰 Tools
🪛 GitHub Check: Perform Phpstan Check / Run

[failure] 30-30:
Called 'env' outside of the config directory which returns null when the config is cached, use 'config'.


[failure] 29-29:
Called 'env' outside of the config directory which returns null when the config is cached, use 'config'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php`
around lines 29 - 30, SyncDiscordHistoryCommand currently reads environment
variables at runtime; change the two places where $botToken and $guildId are set
to use config() instead of env() so config caching works. Replace the fallback
env('HE4RT_DISCORD_BOT_KEY') with a config key (e.g. config('discord.token'))
when assigning $botToken, and replace env('HE4RT_DISCORD_GUILD') with a config
lookup (e.g. config('he4rt.discord.guild_id')) when assigning $guildId; update
any references in the class to use these config-backed variables.


if (! $botToken) {
$this->error('Discord bot token not configured.');
return self::FAILURE;
}

// Find the tenant associated with this guild to ensure multi-tenancy rules are respected
$tenantProvider = ExternalIdentity::query()
->where('model_type', (new Tenant())->getMorphClass())
->where('external_account_id', (string) $guildId)
->first();

if (! $tenantProvider) {
$this->error("Tenant not found for guild {$guildId}. Check your environment configuration.");
return self::FAILURE;
}

$this->info("Fetching last {$limit} messages from channel {$channelId}...");

try {
$connector = new DiscordConnector($botToken);
$response = $connector->send(new ListChannelMessages($channelId, limit: $limit));

if (! $response->successful()) {
$this->error("Failed to fetch messages from Discord: " . $response->body());
return self::FAILURE;
}

$messages = $response->json();
$count = 0;

$this->withProgressBar($messages, function (array $m) use ($tenantProvider, $newMessageAction, &$count) {
if (isset($m['author']['bot']) && $m['author']['bot']) {
return;
}

$author = $m['author'];

try {
$newMessageAction->persist(new NewMessageDTO(
tenantId: $tenantProvider->tenant_id,
provider: IdentityProvider::Discord,
providerUsername: $author['username'],
externalAccountId: $author['id'],
providerMessageId: $m['id'],
channelId: $m['channel_id'],
content: $m['content'] ?? '',
sentAt: new DateTimeImmutable($m['timestamp']),
avatar: $author['avatar'] ?? null,
));
$count++;
} catch (Throwable $e) {
// Silently skip individual message failures to keep progress
}
Comment on lines +69 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The success counter never sees persistence failures.

Lines 69-84 assume NewMessage::persist() will throw on write errors, but app-modules/activity/src/Message/Actions/NewMessage.php catches Throwable internally and only logs. That means $count++ still runs after failed writes and this command can report a successful sync after partial data loss. Return a success flag from persist() or use a non-swallowing path for batch imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/integration-discord/src/Sync/Console/SyncDiscordHistoryCommand.php`
around lines 69 - 84, The success counter is incremented regardless because
NewMessageAction::persist() swallows exceptions; update the call site in
SyncDiscordHistoryCommand (the block creating NewMessageDTO and calling
$newMessageAction->persist(...)) to only increment $count when persist() returns
a success flag, and change NewMessageAction::persist() (in
app-modules/activity/src/Message/Actions/NewMessage.php) to return a boolean
(true on successful persistence, false or throw on failure) or rethrow failures
instead of swallowing them; then modify SyncDiscordHistoryCommand to check the
returned boolean (or catch rethrown exceptions) and only $count++ on
true/success so failed writes are not counted.

});

$this->newLine();
$this->info("Successfully synced {$count} messages and updated participant profiles.");

return self::SUCCESS;
} catch (Throwable $e) {
$this->error("An error occurred during sync: " . $e->getMessage());
return self::FAILURE;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ public static function getEloquentQuery(): Builder
->withoutGlobalScopes([
SoftDeletingScope::class,
])
->when(
session('active_provider'),
fn (Builder $query, string $provider) => $query->where('provider', $provider)
)
Comment on lines +69 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply the provider scope to global search as well.

getEloquentQuery() is now filtered by session('active_provider'), but getGlobalSearchEloquentQuery() still returns the parent query unchanged. That makes the table provider-scoped while Filament global search can still surface ExternalIdentity records from other providers, which breaks the new topbar scope contract. Please extract the scope into a shared helper and reuse it in both builders.

Also applies to: 79-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app-modules/panel-admin/src/Filament/Resources/ExternalIdentities/ExternalIdentityResource.php`
around lines 69 - 72, getEloquentQuery is being scoped by
session('active_provider') but getGlobalSearchEloquentQuery is not, causing
global search to bypass the provider filter; extract the provider filtering into
a shared helper (e.g., private static function applyProviderScope(Builder
$query): Builder or a protected method) that applies the
session('active_provider') when present and then call that helper from both
getEloquentQuery() and getGlobalSearchEloquentQuery() (also update the same
pattern used around the other builder code at the region noted for lines 79-84)
so both the table and global search use the identical provider scope.

->with(['model', 'connectedByUser']);
}

Expand Down
30 changes: 30 additions & 0 deletions app-modules/panel-admin/src/Http/Middleware/SetProviderScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace He4rt\PanelAdmin\Http\Middleware;

use Closure;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SetProviderScope
{
public function handle(Request $request, Closure $next): Response
{
if ($request->has('provider')) {
$provider = IdentityProvider::tryFrom($request->query('provider'));

if ($provider) {
session(['active_provider' => $provider->value]);
}
}

if (!session()->has('active_provider')) {
session(['active_provider' => IdentityProvider::Discord->value]);
}

return $next($request);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php

Check warning on line 1 in app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php

View workflow job for this annotation

GitHub Actions / Perform Pint format / Run

Found violation(s) of type: no_whitespace_in_blank_line

declare(strict_types=1);

Expand Down Expand Up @@ -49,6 +49,17 @@
return __('panel-admin::marketing.navigation.meeting_showcase');
}

public function mount(): void
{
// Default to today's meeting time as a helpful preset
$tz = config('app.display_timezone', 'America/Sao_Paulo');
$this->startDate = now($tz)->setTime(22, 0)->format('Y-m-d\TH:i');
$this->endDate = now($tz)->setTime(23, 40)->format('Y-m-d\TH:i');

// Default meeting channel
$this->channelId = '853401652471398400';
}

public function loadParticipants(): void
{
if ($this->channelId === '' || $this->startDate === '' || $this->endDate === '') {
Expand All @@ -68,17 +79,20 @@
->orderByDesc('total_messages')
->get();

$identityIds = $messageStats->pluck('external_identity_id');
$identityIds = $messageStats->pluck('external_identity_id')->unique()->filter()->values()->all();

$identities = ExternalIdentity::query()
->withoutGlobalScopes()
->withTrashed()
->whereIn('id', $identityIds)
->get()
->keyBy('id');

$this->participants = $messageStats->map(function (Message $stat) use ($identities): array {
$identity = $identities->get($stat->external_identity_id);
$identityId = (string) $stat->external_identity_id;
$identity = $identities->get($identityId);

return $this->extractDiscordData($identity, (int) $stat->total_messages); // @phpstan-ignore property.notFound
return $this->extractDiscordData($identity, (int) $stat->total_messages);

Check failure on line 95 in app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php

View workflow job for this annotation

GitHub Actions / Perform Phpstan Check / Run

Access to an undefined property He4rt\Activity\Message\Models\Message::$total_messages.
})->all();
Comment on lines 91 to 96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix incorrect type hint causing static analysis failure.

The type hint Message $stat on line 91 is incorrect. The $messageStats collection contains query results with only external_identity_id and total_messages columns (from line 77), not full Message model instances. This causes the static analysis error on line 95.

🔧 Proposed fix
-        $this->participants = $messageStats->map(function (Message $stat) use ($identities): array {
+        $this->participants = $messageStats->map(function (object $stat) use ($identities): array {
             $identityId = (string) $stat->external_identity_id;
             $identity = $identities->get($identityId);
 
             return $this->extractDiscordData($identity, (int) $stat->total_messages);
         })->all();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->participants = $messageStats->map(function (Message $stat) use ($identities): array {
$identity = $identities->get($stat->external_identity_id);
$identityId = (string) $stat->external_identity_id;
$identity = $identities->get($identityId);
return $this->extractDiscordData($identity, (int) $stat->total_messages); // @phpstan-ignore property.notFound
return $this->extractDiscordData($identity, (int) $stat->total_messages);
})->all();
$this->participants = $messageStats->map(function (object $stat) use ($identities): array {
$identityId = (string) $stat->external_identity_id;
$identity = $identities->get($identityId);
return $this->extractDiscordData($identity, (int) $stat->total_messages);
})->all();
🧰 Tools
🪛 GitHub Check: Perform Phpstan Check / Run

[failure] 95-95:
Access to an undefined property He4rt\Activity\Message\Models\Message::$total_messages.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/panel-admin/src/Marketing/Pages/MeetingShowcasePage.php` around
lines 91 - 96, The closure passed to map incorrectly type-hints its parameter as
Message ($stat) even though $messageStats contains query result rows with only
external_identity_id and total_messages; remove the Message type hint (change to
function ($stat) or function (object $stat)) so static analysis sees the correct
shape, keep using (string) $stat->external_identity_id and (int)
$stat->total_messages and call $this->extractDiscordData($identity, (int)
$stat->total_messages) as before.


$this->loaded = true;
Expand Down
6 changes: 3 additions & 3 deletions app-modules/panel-admin/src/PanelAdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ private function moderationNavigation(NavigationBuilder $builder): NavigationBui
NavigationItem::make(__('panel-admin::moderation.navigation.back_to_admin'))
->sort(0)
->icon('heroicon-o-arrow-left')
->url(Dashboard::getUrl()),
->url(Dashboard::getUrl(['tenant' => filament()->getTenant()])),

])->groups(resolve(ModerationCluster::class)->getCachedSubNavigation());
}
Expand All @@ -124,7 +124,7 @@ private function marketingNavigation(NavigationBuilder $builder): NavigationBuil
NavigationItem::make(__('panel-admin::marketing.navigation.back_to_admin'))
->sort(0)
->icon('heroicon-o-arrow-left')
->url(Dashboard::getUrl()),
->url(Dashboard::getUrl(['tenant' => filament()->getTenant()])),

])->groups(resolve(MarketingCluster::class)->getCachedSubNavigation());
}
Expand All @@ -135,7 +135,7 @@ private function twitchNavigation(NavigationBuilder $builder): NavigationBuilder
NavigationItem::make(__('panel-admin::twitch.navigation.back_to_admin'))
->sort(0)
->icon('heroicon-o-arrow-left')
->url(Dashboard::getUrl()),
->url(Dashboard::getUrl(['tenant' => filament()->getTenant()])),

])->groups(resolve(TwitchCluster::class)->getCachedSubNavigation());
}
Expand Down
Loading
Loading