Skip to content
Merged
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
7 changes: 7 additions & 0 deletions app-modules/bot-discord/config/bot-discord.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

return [
'guild_id' => env('HE4RT_DISCORD_GUILD_ID'),

/*
* The bot process has no session, so it never inherits the per-request
* locale. Every message the bot sends uses this locale.
*/
'locale' => env('HE4RT_BOT_LOCALE', 'pt_BR'),

'channels' => [
'auto-report' => env('HE4RT_AUTO_REPORT_CHANNEL_ID', '1045804587195576451'),
'presentations' => env('HE4RT_PRESENTATIONS_CHANNEL_ID', '540993663468306433'),
Expand Down
16 changes: 16 additions & 0 deletions app-modules/bot-discord/lang/en/enums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

return [
'discord_error_code' => [
'cannot_send_messages_to_user' => [
'label' => 'DM closed',
'description' => 'The user blocks direct messages from the bot',
],
'no_mutual_guilds' => [
'label' => 'No mutual server',
'description' => 'The user is no longer in any server of the bot',
],
],
];
34 changes: 34 additions & 0 deletions app-modules/bot-discord/lang/en/welcome.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

return [
'dm' => [
'description' => "Good to have you here, **:username**! 💜\n\nHe4rt is one of the largest developer communities in Brazil — a place to learn together, share ideas, join events and grow with people who love code. 💻",
],
'fallback' => [
'description' => "I tried to welcome you in your DM, but it looks closed 👀\n\nNo problem — you can start right here!",
'body' => ':mention 👋',
],
'embed' => [
'title' => 'Welcome to He4rt! 💜',
'cta_title' => '🙋 Start with an introduction',
'cta' => 'Tap **Introduce me** below (it takes you to the right channel) and send `/apresentar`. It takes less than a minute — name, nickname and a few words about you. That is how the community meets you and how you unlock the rest of the server. 🚀',
'footer' => ':year © He4rt Developers',
],
'buttons' => [
'present' => 'Introduce me',
'portal' => 'Portal',
'socials' => 'Our socials',
],
'announcement' => [
'title' => 'A new member arrived',
'message' => 'Welcome, :username!',
'body' => ':mention just arrived. To start, use the `/apresentar` command and tell the community a bit about you.',
],
'profile_failure' => [
'title' => 'New member',
'message' => 'Welcome!',
'body' => ':mention joined the server, but there was a problem starting their profile. If something does not work, talk to the moderation team.',
],
];
16 changes: 16 additions & 0 deletions app-modules/bot-discord/lang/pt_BR/enums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

return [
'discord_error_code' => [
'cannot_send_messages_to_user' => [
'label' => 'DM fechada',
'description' => 'O usuário bloqueou mensagens diretas do bot',
],
'no_mutual_guilds' => [
'label' => 'Sem servidor em comum',
'description' => 'O usuário não está mais em nenhum servidor do bot',
],
],
];
34 changes: 34 additions & 0 deletions app-modules/bot-discord/lang/pt_BR/welcome.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

return [
'dm' => [
'description' => "Que bom ter você por aqui, **:username**! 💜\n\nA He4rt é uma das maiores comunidades de desenvolvedores do Brasil — um lugar pra aprender junto, trocar ideia, participar de eventos e evoluir com gente que curte código. 💻",
],
'fallback' => [
'description' => "Tentei te dar as boas-vindas na sua DM, mas parece que ela está fechada 👀\n\nSem problema — dá pra começar por aqui mesmo!",
'body' => ':mention 👋',
],
'embed' => [
'title' => 'Bem-vindo(a) à He4rt! 💜',
'cta_title' => '🙋 Comece se apresentando',
'cta' => 'Toca em **Me apresentar** aqui embaixo (te levo direto pro canal certo) e manda `/apresentar`. Leva menos de um minuto — nome, nickname e um pouco sobre você. É assim que a comunidade te conhece e você desbloqueia o resto do servidor. 🚀',
'footer' => ':year © He4rt Developers',
],
'buttons' => [
'present' => 'Me apresentar',
'portal' => 'Portal',
'socials' => 'Nossas redes',
],
'announcement' => [
'title' => 'Novo membro chegou',
'message' => 'Seja bem-vindo(a), :username!',
'body' => ':mention acabou de chegar. Para começar, use o comando `/apresentar` e conte um pouco sobre você para a comunidade.',
],
'profile_failure' => [
'title' => 'Novo membro',
'message' => 'Seja bem-vindo(a)!',
'body' => ':mention entrou no servidor, mas houve um problema ao inicializar seu perfil. Caso algo não funcione, fale com a moderação.',
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Actions\Welcome;

use He4rt\BotDiscord\DTO\WelcomeContextDTO;
use Laracord\Discord\Message;
use Laracord\HasLaracord;

/**
* @method Message message(string $content = '')
*/
final class AnnounceNewMemberAction
{
use HasLaracord;

private readonly string $autoReportChannelId;

public function __construct()
{
$this->autoReportChannelId = config()->string('bot-discord.channels.auto-report');
}

public function execute(WelcomeContextDTO $context): void
{
$this
->message(__('bot-discord::welcome.announcement.message', ['username' => $context->username]))
->title(__('bot-discord::welcome.announcement.title'))
->thumbnailUrl($context->avatarUrl)
->body(__('bot-discord::welcome.announcement.body', ['mention' => $context->mention()]))
->color('#5865F2')
->send($this->autoReportChannelId);
}

/**
* Announce a member whose internal profile could not be initialized, so the
* moderation team knows the welcome flow degraded.
*/
public function executeProfileFailure(WelcomeContextDTO $context): void
{
$this
->message(__('bot-discord::welcome.profile_failure.message'))
->title(__('bot-discord::welcome.profile_failure.title'))
->body(__('bot-discord::welcome.profile_failure.body', ['mention' => $context->mention()]))
->color('#ED4245')
->send($this->autoReportChannelId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Actions\Welcome;

use Discord\Parts\User\Member;
use He4rt\BotDiscord\DTO\WelcomeContextDTO;
use He4rt\BotDiscord\Enums\DiscordErrorCode;
use He4rt\BotDiscord\Welcome\WelcomeEmbedBuilder;
use Illuminate\Support\Facades\Log;
use Laracord\Discord\Message;
use Laracord\HasLaracord;
use Throwable;

/**
* @method Message message(string $content = '')
*/
final class SendWelcomeDmAction
{
use HasLaracord;

private readonly string $generalChannelId;

public function __construct(private readonly WelcomeEmbedBuilder $builder)
{
$this->generalChannelId = config()->string('bot-discord.channels.general');
}

public function execute(Member $member, WelcomeContextDTO $context): void
{
$message = $this->builder->decorate($this->message(), $context, $this->generalChannelId);

$message
->sendTo($member->user)
?->catch(fn (Throwable $throwable) => $this->announceFallback($message, $context, $throwable));
}

/**
* A member without mutual guilds has left the server, so the public mention
* reaches nobody.
*/
public function shouldAnnounceFallback(?DiscordErrorCode $errorCode): bool
{
return $errorCode !== DiscordErrorCode::NoMutualGuilds;
}

private function announceFallback(Message $message, WelcomeContextDTO $context, Throwable $throwable): void
{
$errorCode = DiscordErrorCode::fromThrowable($throwable);

$this->logFailure($context, $errorCode, $throwable);

if (!$this->shouldAnnounceFallback($errorCode)) {
return;
}

$this->builder
->asFallback($message, $context)
->send($this->generalChannelId);
}

private function logFailure(WelcomeContextDTO $context, ?DiscordErrorCode $errorCode, Throwable $throwable): void
{
match ($errorCode) {
null => Log::channel('bot-discord')->warning('WelcomeMember: failed to deliver welcome DM', [
'external_account_id' => $context->userId,
'exception' => $throwable,
]),
default => Log::channel('bot-discord')->info('WelcomeMember: welcome DM not delivered', [
'external_account_id' => $context->userId,
'reason' => $errorCode->name,
]),
};
}
}
4 changes: 4 additions & 0 deletions app-modules/bot-discord/src/BotDiscordServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public function boot(): void
{
parent::boot();

$this->loadTranslationsFrom(__DIR__.'/../lang', 'bot-discord');

// Domain event listeners: moderation module emits events, bot-discord reacts with Discord-specific behavior.
Event::listen(CaseQueued::class, [NotifyModerationChannel::class, 'handle']);
Event::listen(CaseReadyForEnforcement::class, [AutoExecuteAction::class, 'handle']);
Expand All @@ -50,6 +52,8 @@ public function boot(): void

public function bot(Laracord $bot): Laracord
{
$this->app->setLocale(config()->string('bot-discord.locale'));

return $bot
->disableHttpServer()
->discoverEvents(__DIR__.'/Events', 'He4rt\BotDiscord\Events')
Expand Down
40 changes: 40 additions & 0 deletions app-modules/bot-discord/src/Concerns/ResolvesGuildIcon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Concerns;

use Discord\Parts\User\Member;

trait ResolvesGuildIcon
{
/**
* Build the Discord CDN URL for a guild icon hash. Returns null when the
* guild has no icon; animated hashes (`a_…`) resolve to `.gif`.
*/
public function guildIconUrl(string $guildId, ?string $iconHash): ?string
{
if (blank($guildId) || blank($iconHash)) {
return null;
}

$extension = str_starts_with($iconHash, 'a_') ? 'gif' : 'png';

return sprintf(
'https://cdn.discordapp.com/icons/%s/%s.%s',
$guildId,
$iconHash,
$extension
);
}

public function resolveGuildIcon(Member $member): ?string
{
$iconHash = $member->guild?->icon;

return $this->guildIconUrl(
(string) $member->guild_id,
is_string($iconHash) ? $iconHash : null,
);
}
}
34 changes: 34 additions & 0 deletions app-modules/bot-discord/src/DTO/WelcomeContextDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\DTO;

use Discord\Parts\User\Member;

final readonly class WelcomeContextDTO
{
public function __construct(
public string $userId,
public string $guildId,
public string $username,
public ?string $avatarUrl,
public ?string $serverIconUrl,
) {}

public static function fromMember(Member $member, ?string $serverIconUrl): self
{
return new self(
userId: (string) $member->user?->id,
guildId: (string) $member->guild_id,
username: (string) $member->user?->username,
avatarUrl: $member->user?->avatar,
serverIconUrl: $serverIconUrl,
);
}

public function mention(): string
{
return sprintf('<@%s>', $this->userId);
}
}
50 changes: 50 additions & 0 deletions app-modules/bot-discord/src/Enums/DiscordErrorCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Enums;

use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasDescription;
use Filament\Support\Contracts\HasLabel;
use Throwable;

/**
* Discord JSON error codes returned by the HTTP API.
*
* @see https://discord.com/developers/docs/topics/opcodes-and-status-codes#json-json-error-codes
*/
enum DiscordErrorCode: int implements HasColor, HasDescription, HasLabel
{
case CannotSendMessagesToUser = 50_007;
case NoMutualGuilds = 50_278;

public static function fromThrowable(Throwable $throwable): ?self
{
return self::tryFrom($throwable->getCode());
}

public function getLabel(): string
{
return match ($this) {
self::CannotSendMessagesToUser => __('bot-discord::enums.discord_error_code.cannot_send_messages_to_user.label'),
self::NoMutualGuilds => __('bot-discord::enums.discord_error_code.no_mutual_guilds.label'),
};
}

public function getColor(): string
{
return match ($this) {
self::CannotSendMessagesToUser => 'warning',
self::NoMutualGuilds => 'gray',
};
}

public function getDescription(): string
{
return match ($this) {
self::CannotSendMessagesToUser => __('bot-discord::enums.discord_error_code.cannot_send_messages_to_user.description'),
self::NoMutualGuilds => __('bot-discord::enums.discord_error_code.no_mutual_guilds.description'),
};
}
}
Loading
Loading