Skip to content
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5a86ff9
fix(docker): update postgres volume mount path for PG 18 compatibilit…
hefeus May 3, 2026
9b81210
Merge branch '4.x' of https://github.com/he4rt/heartdevs.com into 4.x
hefeus May 3, 2026
00c3721
Merge branch '4.x' of https://github.com/he4rt/heartdevs.com into 4.x
hefeus May 4, 2026
754648e
Merge branch '4.x' of https://github.com/he4rt/heartdevs.com into 4.x
hefeus May 6, 2026
59ac75b
feat(config): adicionar token do Discord e chave da OpenAI nas config…
hefeus May 7, 2026
5c0160f
feat(moderation): implementar JsonSerializable e aceitar autor por pa…
hefeus May 7, 2026
d67bd42
feat(moderation): priorizar regras manuais e preservar ação sugerida …
hefeus May 7, 2026
5765bd1
test(moderation): atualizar testes da pipeline de classificação com n…
hefeus May 7, 2026
abc4fd1
feat(bot-discord): implementar DiscordModerationAdapter com mute, kic…
hefeus May 7, 2026
6ca0fd9
feat(moderation): adicionar traduções de DM do Discord para ações de …
hefeus May 7, 2026
315f9e2
feat(bot-discord): integrar pipeline de moderação automática ao event…
hefeus May 7, 2026
b6a61c0
test(bot-discord): adicionar testes de feature do DiscordModerationAd…
hefeus May 7, 2026
3a22a0b
chore: registrar HubPanelProvider e adicionar dependência he4rt/hub
hefeus May 7, 2026
9c1a942
feat(database): adicionar seeder do servidor Discord pessoal para des…
hefeus May 7, 2026
94c9a88
fix(bot-discord): usar dispatch assíncrono no ExecuteAction para não …
hefeus May 7, 2026
6a2c84e
fix(bot-discord): centralizar cliente HTTP com timeout explícito de 1…
hefeus May 7, 2026
cdef161
fix(moderation): substituir instanciação desnecessária de User no Ing…
hefeus May 7, 2026
6520f45
fix(moderation): adicionar type guard instanceof User no fromPlatform…
hefeus May 7, 2026
a8cda07
fix(moderation): corrigir expectativa de status no RouteDecisionTest …
hefeus May 7, 2026
182b24a
fix(bot-discord): tratar channelID em branco no DiscordModerationAdap…
hefeus May 7, 2026
82f9558
fix(bot-discord): remocao de dependencia adicionada precocemente
hefeus May 8, 2026
5e18561
chore: remocao de depedencia prematura do he4rt/hub
hefeus May 8, 2026
2517bbd
fix(moderation): adicionar anotação de tipo de retorno para jsonSeria…
hefeus May 8, 2026
4f5705c
fix(moderacao): alinhar escalada de penalidades aos testes
hefeus May 8, 2026
2285bd5
test(moderacao): ajustar plataforma invalida no teste
hefeus May 8, 2026
f37d6a6
fix(bot-discord): refatorar PersonalDiscordServerSeeder para reutiliz…
hefeus May 9, 2026
74dc714
fix(moderacao): preservar acao de regra no roteamento
hefeus May 9, 2026
008c6d6
fix(bot-discord): alinhar execucao de moderacao e requests
hefeus May 9, 2026
510b84f
Merge branch '4.x' into feature/discord-bot-moderation
hefeus May 9, 2026
ebd1e34
fix(panel): corrigir namespace do Dashboard
hefeus May 9, 2026
a896f9e
config(discord): adicionar variáveis de ambiente para moderação
hefeus May 11, 2026
d0edf47
feat(bot-discord): bloquear ações punitivas contra admins e moderadores
hefeus May 11, 2026
475c550
feat(bot-discord): notificar canal de moderação ao criar novo caso
hefeus May 11, 2026
385df71
test(bot-discord): adicionar testes de imunidade e notificação de canal
hefeus May 11, 2026
072f865
fix(moderation): disparar evento CaseCreated ao ingerir novo caso
hefeus May 11, 2026
d13b049
fix(bot-discord): não executar ação automática para classificações da IA
hefeus May 11, 2026
8f396e0
feat(bot-discord): mencionar cargos de admin e mod na notificação de …
hefeus May 11, 2026
d018810
feat(moderation): criar evento CaseQueued disparado ao aprovar caso n…
hefeus May 12, 2026
d923362
fix(bot-discord): notificar canal apenas para casos aprovados na fila
hefeus May 12, 2026
3ba06ac
fix(bot-discord): corrigir duração nula ao silenciar usuário automati…
hefeus May 12, 2026
c214eef
feat(bot-discord): apagar mensagem original em todas as ações punitivas
hefeus May 12, 2026
b127a63
refactor(bot-discord): atualiza imports para usar classificadores
hefeus May 15, 2026
53eb705
Merge branch '4.x' into feature/discord-bot-moderation
danielhe4rt May 16, 2026
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ HE4RT_BOT_SECRET=
HE4RT_DISCORD_BOT_KEY=
HE4RT_DISCORD_LEVELUP_CHANNEL=
HE4RT_DISCORD_GUILD=
HE4RT_DISCORD_MODERATION_ADMIN_ROLES=547549573959385098,547543574091268118,547549400164073472
HE4RT_DISCORD_MODERATION_MOD_ROLES=547549463942791181
HE4RT_DISCORD_MODERATION_CHANNEL=1095115912820043829
HE4RT_DYNAMIC_VOICE_CATEGORY=

DISCORD_OAUTH_CLIENT_ID=
Expand Down
17 changes: 17 additions & 0 deletions PR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Summary

- Ajusta a escalada de penalidades para alinhar com as regras esperadas pelos testes.
- Corrige o teste de execucao para validar plataforma realmente nao suportada.

## Changes

- `app-modules/moderation/src/Classification/Actions/Advisors/HistoryBasedPenaltyAdvisor.php`
- `app-modules/moderation/tests/Feature/Enforcement/ExecuteActionTest.php`

## Testing

- `vendor/bin/pest --ci --parallel`

## Notes

- O teste "skips platforms not in target list" foi ajustado porque o Discord agora e uma plataforma registrada; antes, nao existia adapter Discord e o teste passava mesmo com `discord` na lista. Agora a lista usa `skype` (plataforma inexistente) para garantir que a execucao seja realmente ignorada.
13 changes: 11 additions & 2 deletions app-modules/bot-discord/src/BotDiscordServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@

namespace He4rt\BotDiscord;

use He4rt\BotDiscord\Listeners\NotifyModerationChannel;
use He4rt\BotDiscord\Moderation\DiscordModerationAdapter;
use He4rt\Moderation\Cases\Events\CaseQueued;
use Illuminate\Support\Facades\Event;
use Laracord\Laracord;
use Laracord\LaracordServiceProvider;

class BotDiscordServiceProvider extends LaracordServiceProvider
{
public function register(): void
{
parent::register();

$this->mergeConfigFrom(__DIR__.'/../config/bot-discord.php', 'bot-discord');

$this->app->singleton(DiscordModerationAdapter::class);
$this->app->tag([DiscordModerationAdapter::class], 'moderation.platforms');

parent::register();
}

public function boot(): void
{
parent::boot();

Event::listen(CaseQueued::class, [NotifyModerationChannel::class, 'handle']);

if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/bot-discord.php' => config_path('bot-discord.php'),
Expand Down
103 changes: 101 additions & 2 deletions app-modules/bot-discord/src/Events/MessageReceivedEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@

namespace He4rt\BotDiscord\Events;

use Discord\Discord;
use Discord\Parts\Channel\Message;
use Discord\WebSockets\Event as Events;
use He4rt\Activity\Message\Actions\NewMessage;
use He4rt\Activity\Message\DTOs\NewMessageDTO;
use He4rt\BotDiscord\Moderation\DiscordModerationAdapter;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Moderation\Classification\Actions\Classifiers\AggregateClassifier;
use He4rt\Moderation\Classification\Actions\Classifiers\OpenAiClassifier;
use He4rt\Moderation\Classification\Actions\Classifiers\RuleBasedClassifier;
use He4rt\Moderation\Classification\Jobs\IngestContent;
use He4rt\Moderation\Classification\Jobs\RouteDecision;
use He4rt\Moderation\Enforcement\ExecuteAction;
use He4rt\Moderation\Enforcement\ModerationAction;
use He4rt\Moderation\Enums\ActionType;
use He4rt\Moderation\Enums\CaseSource;
use He4rt\Moderation\Enums\Platform;
use He4rt\Moderation\Rules\ModerationRule;
use Laracord\Events\Event;
use Throwable;

Expand All @@ -24,7 +35,7 @@ class MessageReceivedEvent extends Event
*/
protected $handler = Events::MESSAGE_CREATE;

public function handle(Message $message, Discord $discord): void
public function handle(Message $message): void
{
if ($message->author->bot) {
return;
Expand All @@ -36,6 +47,11 @@ public function handle(Message $message, Discord $discord): void
->where('external_account_id', (string) $message->guild_id)
->firstOrFail();

$authorIdentity = ExternalIdentity::query()
->where('provider', IdentityProvider::Discord)
->where('external_account_id', (string) $message->user_id)
->first();

resolve(NewMessage::class)->persist(new NewMessageDTO(
tenantId: $tenantProvider->tenant_id,
provider: IdentityProvider::Discord,
Expand All @@ -47,6 +63,89 @@ public function handle(Message $message, Discord $discord): void
sentAt: $message->timestamp->toDateTimeImmutable()
));

$content = DiscordModerationAdapter::make()->ingest([
'message_id' => $message->id,
'author_id' => $message->user_id,
'content' => $message->content,
'channel_id' => $message->channel_id,
'guild_id' => (string) $message->guild_id,
'username' => $message->author->username,
'attachments' => [],
'tenant_id' => (string) $tenantProvider->tenant_id,
'author' => $authorIdentity?->user,
]);

$this->logger()->info('[Moderation] Pre-screening message: '.$message->id);

$ruleResult = RuleBasedClassifier::make()->classify($content);
$hasRuleMatch = $ruleResult->matchedRules !== [];

$ruleAction = null;
if ($hasRuleMatch) {
$ruleAction = ModerationRule::query()
->whereIn('id', $ruleResult->matchedRules)
->get()
->sortByDesc(fn (ModerationRule $rule): int => $rule->severity->weight())
->first()?->action_on_match;
}

$aiResult = $hasRuleMatch
? $ruleResult
: AggregateClassifier::make()
->addClassifier(OpenAiClassifier::make())
->classify($content);

$maxScore = blank($aiResult->scores) ? 0.0 : max($aiResult->scores);
$flagThreshold = config('moderation.thresholds.flag', 0.7);

$shouldCreateCase = $hasRuleMatch || $maxScore >= $flagThreshold;

$this->logger()->info(
'[Moderation] Pre-screening result: rule_match='.($hasRuleMatch ? 'yes' : 'no')
.' max_score='.$maxScore.' threshold='.$flagThreshold
.' create_case='.($shouldCreateCase ? 'yes' : 'no')
);

if (!$shouldCreateCase) {
return;
}

$case = new IngestContent($content, CaseSource::AutoDetect)->handle();
$this->logger()->info('[Moderation] Case created: '.$case->id.' status='.$case->status->value);

$case->update([
'ai_scores' => $aiResult->scores,
'violation_type' => $aiResult->primary,
'severity' => $aiResult->severity,
'classifier_version' => $aiResult->classifierName,
'suggested_action' => $ruleAction,
]);

new RouteDecision($case)->handle();
$case->refresh();
$this->logger()->info('[Moderation] After route: status='.$case->status->value.' priority='.$case->priority);

// Only auto-execute when the action was set by a deterministic rule (classifier_version='rules').
// AI-only suggestions stay pending for human moderator review.
if ($case->suggested_action && $case->author && $case->classifier_version === 'rules') {
$action = ModerationAction::query()->create([
'case_id' => $case->id,
'moderator_id' => null,
'action_type' => $case->suggested_action,
'target_platforms' => [Platform::Discord->value],
'duration' => match ($case->suggested_action) {
ActionType::Ban => 'permanent',
ActionType::Mute, ActionType::Suspend => '24h',
default => null,
},
'reason' => 'Auto-moderation triggered by Discord message classification.',
'automated' => true,
'tenant_id' => $case->tenant_id,
]);

dispatch(new ExecuteAction($action, $case->author));
}
Comment thread
danielhe4rt marked this conversation as resolved.

} catch (Throwable $throwable) {
$this->logger()->error(
sprintf('%s | File: %s | Line: %s | Trace: %s', $throwable->getMessage(), $throwable->getFile(), $throwable->getLine(), $throwable->getTraceAsString()),
Expand Down
89 changes: 89 additions & 0 deletions app-modules/bot-discord/src/Listeners/NotifyModerationChannel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Listeners;

use He4rt\Moderation\Cases\Events\CaseQueued;
use He4rt\Moderation\Cases\Models\ModerationCase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

final class NotifyModerationChannel
{
public function handle(CaseQueued $event): void
{
$token = config('discord.token', config('he4rt.discord.token'));
$channelId = config()->string('he4rt.discord.moderation.mod_channel_id');

if (!is_string($token) || blank($token) || blank($channelId)) {
return;
}

$case = $event->case;

$response = Http::withHeaders(['Authorization' => 'Bot '.$token])
->post(
sprintf('https://discord.com/api/v10/channels/%s/messages', $channelId),
[
'content' => $this->buildMentions(),
'embeds' => [$this->buildEmbed($case)],
],
);

if ($response->failed()) {
Log::warning('Failed to notify mod channel about new moderation case.', [
'case_id' => $case->id,
'status' => $response->status(),
'response' => $response->json() ?: $response->body(),
]);
}
}
Comment thread
danielhe4rt marked this conversation as resolved.

private function buildMentions(): string
{
/** @var array<int, string> $adminRoles */
$adminRoles = config('he4rt.discord.moderation.admin_role_ids', []);

/** @var array<int, string> $modRoles */
$modRoles = config('he4rt.discord.moderation.mod_role_ids', []);

$mentions = array_map(
fn (string $id): string => sprintf('<@&%s>', $id),
array_merge($adminRoles, $modRoles),
);

return implode(' ', $mentions);
}

/** @return array<string, mixed> */
private function buildEmbed(ModerationCase $case): array
{
$author = $case->content_snapshot['metadata']['username'] ?? 'Unknown';
$text = $case->content_snapshot['text'] ?? null;
$platform = $case->source_platform->value;

$fields = [
['name' => 'Platform', 'value' => ucfirst($platform), 'inline' => true],
['name' => 'Source', 'value' => $case->source->value, 'inline' => true],
['name' => 'Priority', 'value' => (string) $case->priority, 'inline' => true],
];

if ($text !== null) {
$fields[] = [
'name' => 'Content',
'value' => mb_substr($text, 0, 1024),
'inline' => false,
];
}

return [
'title' => sprintf(':warning: New moderation case — %s', $author),
'description' => sprintf('Case ID: `%s`', $case->id),
'color' => 0xFFA500,
'fields' => $fields,
'timestamp' => $case->created_at->toIso8601String(),
'footer' => ['text' => 'He4rt Moderation System'],
];
}
}
Loading