Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -28,6 +28,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 @@ -39,12 +39,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 48 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