From f60a26e79a349b6cdabd1479dfb71cfcc0328e5b Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:17:21 -0300 Subject: [PATCH 1/5] feat(bot-discord): sala empresarial private rooms Add /sala-empresarial, converting a /sala-tracked voice room into a private company room by stamping Discord permission overwrites: deny @everyone and allow the selected Empresa Parceira's Partner Role for connect, speak, use_vad, send_messages and read_message_history. The last two lock the room's built-in text chat; VIEW_CHANNEL is left untouched (visible-but-locked) and MENTION_EVERYONE keeps its category inheritance. The decision (guards + role resolution + overwrite plan) lives in a pure, unit-tested ConfigureEmpresarialRoomAction; the command edge only performs the setPermissions I/O. Gating is by Partner Role membership, not room ownership, so any company member can privatize the room. Partners are a flat bot-discord.roles.partners registry (slug => role id); onboarding a new partner is one env + one line. Closes #448 --- app-modules/bot-discord/CONTEXT.md | 17 ++- .../bot-discord/config/bot-discord.php | 10 ++ ...la-empresarial-role-gated-private-rooms.md | 48 ++++++ .../ConfigureEmpresarialRoomAction.php | 72 +++++++++ .../src/DTO/EmpresarialOverwritePlan.php | 50 +++++++ .../src/DTO/EmpresarialRoomDecision.php | 34 +++++ .../src/Enums/EmpresarialRejectionReason.php | 27 ++++ .../SlashCommands/SalaEmpresarialCommand.php | 138 ++++++++++++++++++ .../ConfigureEmpresarialRoomActionTest.php | 134 +++++++++++++++++ .../Enums/EmpresarialRejectionReasonTest.php | 22 +++ 10 files changed, 545 insertions(+), 7 deletions(-) create mode 100644 app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md create mode 100644 app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php create mode 100644 app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php create mode 100644 app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php create mode 100644 app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php create mode 100644 app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php create mode 100644 app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php create mode 100644 app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php diff --git a/app-modules/bot-discord/CONTEXT.md b/app-modules/bot-discord/CONTEXT.md index de05e8608..6277a851c 100644 --- a/app-modules/bot-discord/CONTEXT.md +++ b/app-modules/bot-discord/CONTEXT.md @@ -4,13 +4,16 @@ Discord bot runtime powered by Laracord. Handles websocket events, slash command ## Glossary -| Term | Definition | Not to be confused with | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | -| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | -| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | -| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | -| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| Term | Definition | Not to be confused with | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | +| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | +| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | +| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | +| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| **Sala Empresarial** | A `/sala`-created voice room that a partner-company member privatized via `/sala-empresarial`: `@everyone` is denied connect/speak/use-voice-activity/send-messages/read-history while one Partner Role is allowed. Stays visible in the channel list, just locked. | A normal `/sala` room (public, permissions inherited from the category) | +| **Empresa Parceira** | An external company in a partnership with He4rt whose members hold a dedicated Discord role. Registered in `bot-discord.roles.partners` (`slug => role_id`); the system supports many, not just one. | A Squad (internal community group, owned by the `squads` module) | +| **Partner Role** | The Discord role that identifies the members of one Empresa Parceira (e.g. `brd`). It is the `allow` target stamped on a room when it becomes a Sala Empresarial; a caller must hold the role of the company they select. | Moderation admin/mod roles (`he4rt.discord.moderation.*`) | ## Module Boundaries diff --git a/app-modules/bot-discord/config/bot-discord.php b/app-modules/bot-discord/config/bot-discord.php index df1fd3429..f0971b0fa 100644 --- a/app-modules/bot-discord/config/bot-discord.php +++ b/app-modules/bot-discord/config/bot-discord.php @@ -14,5 +14,15 @@ 'presentation' => env('HE4RT_PRESENTATION_ROLE_ID', '546150872397119491'), 'comite_delas' => env('HE4RT_COMITE_DELAS_ROLE_ID', '1014311739397001288'), 'he4rt_delas' => env('HE4RT_DELAS_ROLE_ID', '1018009963903328308'), + + /* + * Empresa Parceira registry: partner-company slug => Discord Partner Role id. + * Each entry enables `/sala-empresarial empresa:` for that company. + * Onboarding a new partner is one env + one line here — no code change. + */ + 'partners' => [ + 'brd' => env('HE4RT_PARTNER_BRD_ROLE_ID', '1522599722370465904'), + '3p' => env('HE4RT_PARTNER_3P_ROLE_ID', '1405611374998061108'), + ], ], ]; diff --git a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md new file mode 100644 index 000000000..3a089f9c8 --- /dev/null +++ b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md @@ -0,0 +1,48 @@ +--- +type: adr +title: 'Sala Empresarial — role-gated private voice rooms' +module: bot-discord +status: accepted +date: 2026-07-23 +--- + +# Sala Empresarial — role-gated private voice rooms + +Partner companies (**Empresa Parceira**) need private voice rooms on the guild. We add a +separate `/sala-empresarial empresa:` slash command that **converts an existing +`/sala`-tracked room** into a private one by stamping Discord permission overwrites: `deny` +`@everyone` and `allow` the selected company's **Partner Role** for `CONNECT`, `SPEAK`, +`USE_VAD`, `SEND_MESSAGES` and `READ_MESSAGE_HISTORY` (the last two lock the room's built-in +text chat). `MENTION_EVERYONE` stays denied via category +inheritance and `VIEW_CHANNEL` is left untouched, so the room is visible-but-locked. + +## Considered Options + +- **Convert existing room vs. a flag on `/sala`.** We convert. `/sala` is shared by the + whole guild; branching its logic and gating a `empresarial:true` flag by role would + couple a general command to partner concerns. A dedicated command mirrors `/sala-limite` + and keeps `/sala` untouched. +- **Explicit `empresa` parameter vs. inferring the caller's role.** Explicit parameter. A + caller can belong to more than one Empresa Parceira, so intersecting their roles with the + registry is ambiguous. The parameter picks exactly one company; we then validate the + caller actually holds that company's Partner Role and reject (ephemeral) otherwise. +- **Config registry vs. hardcoding `brd`.** A flat `bot-discord.roles.partners` + (`slug => role_id`) registry. Adding a second partner company is one env + one config + line, no code change. The slash-command `choices` are built from this map. Each partner's + Partner Role id is seeded as an env-var default in `config/bot-discord.php` (the source of + truth), so no role ids live in this ADR. +- **Stateless vs. persisting the empresarial state.** Stateless. We only convert + `/sala`-tracked rooms, which `DynamicVoiceTask` auto-deletes when empty, so the overwrites + die with the channel. `VoiceChannelDTO` is left unchanged and Discord's overwrites are the + single source of truth; re-running the command is a harmless re-stamp. There is no revert + command — a room un-privatizes by emptying and being deleted. + +## Consequences + +- This is the first permission-overwrite code in `bot-discord`; prior room commands only + set the channel's parent/user-limit and relied on category inheritance. +- Gating is by Partner Role membership, **not** room ownership (unlike `/sala-limite`) — any + member of the selected company may privatize the room. +- Because `VIEW_CHANNEL` is not denied, empresarial rooms remain listed to everyone. A + non-member already connected at conversion time is not force-disconnected but is muted + (`deny SPEAK`/`USE_VAD`) until they leave. Both are accepted trade-offs for the MVP. diff --git a/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php b/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php new file mode 100644 index 000000000..b851f9263 --- /dev/null +++ b/app-modules/bot-discord/src/Actions/VoiceChannel/ConfigureEmpresarialRoomAction.php @@ -0,0 +1,72 @@ + $callerRoleIds Discord role ids held by the caller. + * @param list $activeChannels The `/sala`-tracked rooms cache. + */ + public function execute( + string $companySlug, + array $callerRoleIds, + ?string $currentChannelId, + array $activeChannels, + ): EmpresarialRoomDecision { + if (!$this->isInsideTrackedRoom($currentChannelId, $activeChannels)) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::NotInTrackedRoom); + } + + $partnerRoleId = $this->resolvePartnerRoleId($companySlug); + + if ($partnerRoleId === null) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::UnknownCompany); + } + + if (!in_array($partnerRoleId, $callerRoleIds, strict: true)) { + return EmpresarialRoomDecision::reject(EmpresarialRejectionReason::MissingPartnerRole); + } + + return EmpresarialRoomDecision::approve( + EmpresarialOverwritePlan::for($companySlug, $partnerRoleId), + ); + } + + /** + * @param list $activeChannels + */ + private function isInsideTrackedRoom(?string $currentChannelId, array $activeChannels): bool + { + if ($currentChannelId === null) { + return false; + } + + return array_any($activeChannels, fn (VoiceChannelDTO $channel): bool => $channel->channelId === $currentChannelId); + } + + private function resolvePartnerRoleId(string $companySlug): ?string + { + /** @var array $partners */ + $partners = config('bot-discord.roles.partners', []); + + if (!array_key_exists($companySlug, $partners)) { + return null; + } + + return $partners[$companySlug]; + } +} diff --git a/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php new file mode 100644 index 000000000..cf374c6cf --- /dev/null +++ b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php @@ -0,0 +1,50 @@ + + */ + public const array PRIVATE_ROOM_PERMISSIONS = [ + 'connect', + 'speak', + 'use_vad', + 'send_messages', + 'read_message_history', + ]; + + /** + * @param list $denyEveryone Permissions denied to `@everyone`. + * @param list $allowPartnerRole Permissions allowed for the Partner Role. + */ + private function __construct( + public string $companySlug, + public string $partnerRoleId, + public array $denyEveryone, + public array $allowPartnerRole, + ) {} + + public static function for(string $companySlug, string $partnerRoleId): self + { + return new self( + companySlug: $companySlug, + partnerRoleId: $partnerRoleId, + denyEveryone: self::PRIVATE_ROOM_PERMISSIONS, + allowPartnerRole: self::PRIVATE_ROOM_PERMISSIONS, + ); + } +} diff --git a/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php b/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php new file mode 100644 index 000000000..1894b304f --- /dev/null +++ b/app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.php @@ -0,0 +1,34 @@ +plan instanceof EmpresarialOverwritePlan; + } +} diff --git a/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php b/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php new file mode 100644 index 000000000..2b1a987ed --- /dev/null +++ b/app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php @@ -0,0 +1,27 @@ + '❌ Você precisa estar dentro de uma sala criada com /sala para usar este comando!', + self::MissingPartnerRole => '❌ Você não faz parte da empresa selecionada e não pode torná-la privada.', + self::UnknownCompany => '❌ Empresa parceira não encontrada.', + }; + } +} diff --git a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php new file mode 100644 index 000000000..9c98e0380 --- /dev/null +++ b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php @@ -0,0 +1,138 @@ +member->id; + $channelId = cache()->tags(['voice_tracking'])->get('user_last_channel_'.$userId); + + /** @var list $activeChannels */ + $activeChannels = cache()->tags(['voice_channels'])->get('active_voice_channels_keys', []); + + $decision = resolve(ConfigureEmpresarialRoomAction::class)->execute( + companySlug: (string) $this->value('empresa'), + callerRoleIds: $this->callerRoleIds($interaction), + currentChannelId: $channelId ? (string) $channelId : null, + activeChannels: $activeChannels, + ); + + if (!$decision->isApproved()) { + $this->message() + ->content($decision->rejection->message()) + ->reply($interaction, ephemeral: true); + + return; + } + + $this->applyOverwrites($interaction, (string) $channelId, $decision->plan); + } + + /** + * @return array + */ + public function options(): array + { + return [ + [ + 'name' => 'empresa', + 'description' => 'Empresa parceira para a qual a sala ficará privada.', + 'type' => Option::STRING, + 'required' => true, + 'choices' => $this->companyChoices(), + ], + ]; + } + + /** + * @return list + */ + private function callerRoleIds(Interaction $interaction): array + { + $roleIds = []; + + foreach ($interaction->member->roles as $role) { + /** @var Role $role */ + $roleIds[] = $role->id; + } + + return $roleIds; + } + + private function applyOverwrites(Interaction $interaction, string $channelId, EmpresarialOverwritePlan $plan): void + { + /** @var Channel|null $voiceChannel */ + $voiceChannel = $interaction->guild->channels->get('id', $channelId); + + /** @var Role|null $partnerRole */ + $partnerRole = $interaction->guild->roles->get('id', $plan->partnerRoleId); + + // The `@everyone` role shares the guild id in Discord. + /** @var Role|null $everyoneRole */ + $everyoneRole = $interaction->guild->roles->get('id', $interaction->guild->id); + + if (!$voiceChannel || !$partnerRole || !$everyoneRole) { + $this->message() + ->content('❌ Não foi possível configurar a sala. Tente novamente.') + ->reply($interaction, ephemeral: true); + + return; + } + + try { + /** @var PromiseInterface $denyEveryone */ + $denyEveryone = $voiceChannel->setPermissions($everyoneRole, [], $plan->denyEveryone); + await($denyEveryone); + + /** @var PromiseInterface $allowPartner */ + $allowPartner = $voiceChannel->setPermissions($partnerRole, $plan->allowPartnerRole, []); + await($allowPartner); + + $this->message() + ->content(sprintf('✅ Sala configurada como privada para a empresa **%s**!', mb_strtoupper($plan->companySlug))) + ->reply($interaction, ephemeral: true); + } catch (Exception) { + $this->message() + ->content('❌ Erro ao configurar a sala. Tente novamente.') + ->reply($interaction, ephemeral: true); + } + } + + /** + * @return list + */ + private function companyChoices(): array + { + /** @var array $partners */ + $partners = config('bot-discord.roles.partners', []); + + return array_map( + fn (string $slug): array => ['name' => mb_strtoupper($slug), 'value' => $slug], + array_keys($partners), + ); + } +} diff --git a/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php new file mode 100644 index 000000000..b6e522f72 --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php @@ -0,0 +1,134 @@ +set('bot-discord.roles.partners', [ + 'brd' => 'role-brd', + 'acme' => 'role-acme', + ]); +}); + +/** + * @param list $users + */ +function trackedRoom(string $channelId, string $ownerId = 'owner-1', array $users = []): VoiceChannelDTO +{ + return VoiceChannelDTO::make([ + 'guildId' => 'guild-1', + 'channelId' => $channelId, + 'ownerId' => $ownerId, + 'usersCount' => count($users), + 'users' => $users, + ]); +} + +it('approves a member privatizing their own /sala room', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->rejection)->toBeNull() + ->and($decision->plan)->toBeInstanceOf(EmpresarialOverwritePlan::class) + ->and($decision->plan->companySlug)->toBe('brd') + ->and($decision->plan->partnerRoleId)->toBe('role-brd') + ->and($decision->plan->denyEveryone)->toBe(['connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']) + ->and($decision->plan->allowPartnerRole)->toBe(['connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']); +}); + +it('approves privatizing a room a teammate created (gate is role, not ownership)', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1', ownerId: 'someone-else')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->plan->partnerRoleId)->toBe('role-brd'); +}); + +it('rejects when the caller does not hold the selected company role', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'acme', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeFalse() + ->and($decision->plan)->toBeNull() + ->and($decision->rejection)->toBe(EmpresarialRejectionReason::MissingPartnerRole); +}); + +it('rejects when the caller is in a channel that is not /sala-tracked', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: 'permanent-channel', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::NotInTrackedRoom); +}); + +it('rejects when the caller is not connected to any voice channel', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'brd', + callerRoleIds: ['role-brd'], + currentChannelId: null, + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::NotInTrackedRoom); +}); + +it('rejects when the selected company is not registered', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'ghost-corp', + callerRoleIds: ['role-brd'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->rejection)->toBe(EmpresarialRejectionReason::UnknownCompany); +}); + +it('allows only the selected company role for a member of several partners', function (): void { + $decision = new ConfigureEmpresarialRoomAction()->execute( + companySlug: 'acme', + callerRoleIds: ['role-brd', 'role-acme'], + currentChannelId: 'room-1', + activeChannels: [trackedRoom('room-1')], + ); + + expect($decision->isApproved())->toBeTrue() + ->and($decision->plan->companySlug)->toBe('acme') + ->and($decision->plan->partnerRoleId)->toBe('role-acme'); +}); + +it('is a harmless re-stamp when run again on an already-configured room', function (): void { + $action = new ConfigureEmpresarialRoomAction(); + $args = [ + 'companySlug' => 'brd', + 'callerRoleIds' => ['role-brd'], + 'currentChannelId' => 'room-1', + 'activeChannels' => [trackedRoom('room-1')], + ]; + + $first = $action->execute(...$args); + $second = $action->execute(...$args); + + expect($first->plan->partnerRoleId)->toBe($second->plan->partnerRoleId) + ->and($first->plan->denyEveryone)->toBe($second->plan->denyEveryone) + ->and($first->plan->allowPartnerRole)->toBe($second->plan->allowPartnerRole); +}); diff --git a/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php b/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php new file mode 100644 index 000000000..0daef9de9 --- /dev/null +++ b/app-modules/bot-discord/tests/Unit/Enums/EmpresarialRejectionReasonTest.php @@ -0,0 +1,22 @@ + $reason->message(), + EmpresarialRejectionReason::cases(), + ); + + expect($messages) + ->each->toStartWith('❌') + ->and(array_unique($messages))->toHaveSameSize(EmpresarialRejectionReason::cases()); +}); + +it('maps each case to its backed slug', function (): void { + expect(EmpresarialRejectionReason::NotInTrackedRoom->value)->toBe('not-in-tracked-room') + ->and(EmpresarialRejectionReason::MissingPartnerRole->value)->toBe('missing-partner-role') + ->and(EmpresarialRejectionReason::UnknownCompany->value)->toBe('unknown-company'); +}); From bed8e4af803cd52a64b4c67deb97813d45247d74 Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:55:33 -0300 Subject: [PATCH 2/5] fix(bot-discord): allow partner before denying everyone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the partner-role allow overwrite before the @everyone deny so a failed partner write leaves the room public instead of fully locked (deny-first could strand everyone out of the room). Also reword ADR-0001: the command leaves MENTION_EVERYONE and VIEW_CHANNEL unchanged rather than guaranteeing MENTION_EVERYONE is denied — it keeps whatever the category inherits. --- .../0001-sala-empresarial-role-gated-private-rooms.md | 5 +++-- .../src/SlashCommands/SalaEmpresarialCommand.php | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md index 3a089f9c8..e4523a42e 100644 --- a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md +++ b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md @@ -13,8 +13,9 @@ separate `/sala-empresarial empresa:` slash command that **converts an exi `/sala`-tracked room** into a private one by stamping Discord permission overwrites: `deny` `@everyone` and `allow` the selected company's **Partner Role** for `CONNECT`, `SPEAK`, `USE_VAD`, `SEND_MESSAGES` and `READ_MESSAGE_HISTORY` (the last two lock the room's built-in -text chat). `MENTION_EVERYONE` stays denied via category -inheritance and `VIEW_CHANNEL` is left untouched, so the room is visible-but-locked. +text chat). `MENTION_EVERYONE` and `VIEW_CHANNEL` are left unchanged by the command — the +former keeps whatever the category inherits (every `/sala` room already blocks it), the +latter stays untouched — so the room is visible-but-locked. ## Considered Options diff --git a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php index 9c98e0380..3f8e8c06d 100644 --- a/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/SalaEmpresarialCommand.php @@ -104,14 +104,16 @@ private function applyOverwrites(Interaction $interaction, string $channelId, Em } try { - /** @var PromiseInterface $denyEveryone */ - $denyEveryone = $voiceChannel->setPermissions($everyoneRole, [], $plan->denyEveryone); - await($denyEveryone); - + // Allow the partner role first: if the @everyone deny below fails, the room + // stays public rather than fully locked (deny-first would strand everyone out). /** @var PromiseInterface $allowPartner */ $allowPartner = $voiceChannel->setPermissions($partnerRole, $plan->allowPartnerRole, []); await($allowPartner); + /** @var PromiseInterface $denyEveryone */ + $denyEveryone = $voiceChannel->setPermissions($everyoneRole, [], $plan->denyEveryone); + await($denyEveryone); + $this->message() ->content(sprintf('✅ Sala configurada como privada para a empresa **%s**!', mb_strtoupper($plan->companySlug))) ->reply($interaction, ephemeral: true); From c3ccccc013983e6f2891f1042b0f29267e628e24 Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:13:57 -0300 Subject: [PATCH 3/5] chore(bot-discord): remove 3 Pontos partner room --- app-modules/bot-discord/config/bot-discord.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app-modules/bot-discord/config/bot-discord.php b/app-modules/bot-discord/config/bot-discord.php index f0971b0fa..6f0931081 100644 --- a/app-modules/bot-discord/config/bot-discord.php +++ b/app-modules/bot-discord/config/bot-discord.php @@ -22,7 +22,6 @@ */ 'partners' => [ 'brd' => env('HE4RT_PARTNER_BRD_ROLE_ID', '1522599722370465904'), - '3p' => env('HE4RT_PARTNER_3P_ROLE_ID', '1405611374998061108'), ], ], ]; From f337064912e6ecbbc83cf56a4c9e5969a74ee6e4 Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:15:40 -0300 Subject: [PATCH 4/5] chore(deps): bump concurrently to 10.0.4 --- package-lock.json | 22 +++++++++++----------- package.json | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index a46e98c73..29ed7abc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", - "concurrently": "10.0.3", + "concurrently": "10.0.4", "husky": "9.1.7", "laravel-vite-plugin": "3.1.3", "lint-staged": "17.2.0", @@ -816,15 +816,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", + "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.4", + "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -1360,9 +1360,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1463,9 +1463,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d28601b85..0ceaf5f33 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", - "concurrently": "10.0.3", + "concurrently": "10.0.4", "husky": "9.1.7", "laravel-vite-plugin": "3.1.3", "lint-staged": "17.2.0", From 10d0058da842fb34436a3617a8af18cef44211ba Mon Sep 17 00:00:00 2001 From: Gabriel do Carmo Vieira <48625433+gvieira18@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:46:37 -0300 Subject: [PATCH 5/5] feat(bot-discord): hide empresarial room from non-members Deny VIEW_CHANNEL to @everyone (and allow it for the Partner Role) so a Sala Empresarial is fully private: non-members no longer see the room or its occupant list, and any non-partner connected at conversion time is force-disconnected by Discord. --- app-modules/bot-discord/CONTEXT.md | 20 +++++++++---------- ...la-empresarial-role-gated-private-rooms.md | 18 +++++++++-------- .../src/DTO/EmpresarialOverwritePlan.php | 10 +++++++--- .../ConfigureEmpresarialRoomActionTest.php | 4 ++-- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/app-modules/bot-discord/CONTEXT.md b/app-modules/bot-discord/CONTEXT.md index 6277a851c..9718cec06 100644 --- a/app-modules/bot-discord/CONTEXT.md +++ b/app-modules/bot-discord/CONTEXT.md @@ -4,16 +4,16 @@ Discord bot runtime powered by Laracord. Handles websocket events, slash command ## Glossary -| Term | Definition | Not to be confused with | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | -| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | -| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | -| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | -| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | -| **Sala Empresarial** | A `/sala`-created voice room that a partner-company member privatized via `/sala-empresarial`: `@everyone` is denied connect/speak/use-voice-activity/send-messages/read-history while one Partner Role is allowed. Stays visible in the channel list, just locked. | A normal `/sala` room (public, permissions inherited from the category) | -| **Empresa Parceira** | An external company in a partnership with He4rt whose members hold a dedicated Discord role. Registered in `bot-discord.roles.partners` (`slug => role_id`); the system supports many, not just one. | A Squad (internal community group, owned by the `squads` module) | -| **Partner Role** | The Discord role that identifies the members of one Empresa Parceira (e.g. `brd`). It is the `allow` target stamped on a room when it becomes a Sala Empresarial; a caller must hold the role of the company they select. | Moderation admin/mod roles (`he4rt.discord.moderation.*`) | +| Term | Definition | Not to be confused with | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| **Event Handler** | A Laracord event class that reacts to Discord websocket events (MESSAGE_CREATE, VOICE_STATE_UPDATE, etc.). Thin orchestrators that delegate to domain modules. | Domain events (like `CaseQueued` from moderation) | +| **Listener** | A Laravel event listener that reacts to domain events emitted by other modules. E.g., `NotifyModerationChannel` listens to `CaseQueued`. | Laracord event handlers (which react to Discord websocket) | +| **DiscordModerationAdapter** | Implements `ModerationPlatformContract`. Orchestrates moderation enforcement on Discord (mute, kick, ban) by delegating HTTP calls to `integration-discord` Transport. | The moderation domain itself (which is platform-agnostic) | +| **ModerationEmbedBuilder** | Formats moderation case data into Discord embed objects for channel notifications. | Transport (which just sends the formatted embed) | +| **AutoExecuteAction** | Listener for `CaseReadyForEnforcement`. Creates a `ModerationAction` and dispatches `ExecuteAction` when moderation domain signals auto-execution is safe. | The execution itself (which is done by `ExecuteAction` job) | +| **Sala Empresarial** | A `/sala`-created voice room that a partner-company member privatized via `/sala-empresarial`: `@everyone` is denied view-channel/connect/speak/use-voice-activity/send-messages/read-history while one Partner Role is allowed. Fully hidden from non-members — they cannot see the room nor who is inside. | A normal `/sala` room (public, permissions inherited from the category) | +| **Empresa Parceira** | An external company in a partnership with He4rt whose members hold a dedicated Discord role. Registered in `bot-discord.roles.partners` (`slug => role_id`); the system supports many, not just one. | A Squad (internal community group, owned by the `squads` module) | +| **Partner Role** | The Discord role that identifies the members of one Empresa Parceira (e.g. `brd`). It is the `allow` target stamped on a room when it becomes a Sala Empresarial; a caller must hold the role of the company they select. | Moderation admin/mod roles (`he4rt.discord.moderation.*`) | ## Module Boundaries diff --git a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md index e4523a42e..44395d51d 100644 --- a/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md +++ b/app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md @@ -11,11 +11,12 @@ date: 2026-07-23 Partner companies (**Empresa Parceira**) need private voice rooms on the guild. We add a separate `/sala-empresarial empresa:` slash command that **converts an existing `/sala`-tracked room** into a private one by stamping Discord permission overwrites: `deny` -`@everyone` and `allow` the selected company's **Partner Role** for `CONNECT`, `SPEAK`, -`USE_VAD`, `SEND_MESSAGES` and `READ_MESSAGE_HISTORY` (the last two lock the room's built-in -text chat). `MENTION_EVERYONE` and `VIEW_CHANNEL` are left unchanged by the command — the -former keeps whatever the category inherits (every `/sala` room already blocks it), the -latter stays untouched — so the room is visible-but-locked. +`@everyone` and `allow` the selected company's **Partner Role** for `VIEW_CHANNEL`, +`CONNECT`, `SPEAK`, `USE_VAD`, `SEND_MESSAGES` and `READ_MESSAGE_HISTORY` (the last two lock +the room's built-in text chat). `MENTION_EVERYONE` is left unchanged by the command — it +keeps whatever the category inherits (every `/sala` room already blocks it). Because +`VIEW_CHANNEL` is denied to `@everyone`, the room is **fully hidden**: non-members cannot see +it in the channel list nor who is connected. ## Considered Options @@ -44,6 +45,7 @@ latter stays untouched — so the room is visible-but-locked. set the channel's parent/user-limit and relied on category inheritance. - Gating is by Partner Role membership, **not** room ownership (unlike `/sala-limite`) — any member of the selected company may privatize the room. -- Because `VIEW_CHANNEL` is not denied, empresarial rooms remain listed to everyone. A - non-member already connected at conversion time is not force-disconnected but is muted - (`deny SPEAK`/`USE_VAD`) until they leave. Both are accepted trade-offs for the MVP. +- Because `VIEW_CHANNEL` is denied to `@everyone`, empresarial rooms disappear from the + channel list for non-members and their occupant list is hidden. Discord force-disconnects + any non-member connected at conversion time (losing view access ejects them from a voice + channel) — accepted, and consistent with a fully private room. diff --git a/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php index cf374c6cf..12b3c1750 100644 --- a/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php +++ b/app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php @@ -8,9 +8,12 @@ * The permission overwrites to stamp on a channel when it becomes a Sala Empresarial: * deny `@everyone` and allow the resolved Partner Role for the same voice-privacy set. * - * `MENTION_EVERYONE` relies on the category's existing inheritance and `VIEW_CHANNEL` - * is intentionally left untouched (the room stays visible-but-locked), so neither - * appears here. + * `VIEW_CHANNEL` is included, so the room is fully private: `@everyone` cannot see it in + * the channel list nor who is connected, while the Partner Role can. Denying view also + * force-disconnects any non-partner member connected at conversion time. + * + * `MENTION_EVERYONE` relies on the category's existing inheritance, so it does not appear + * here. */ final readonly class EmpresarialOverwritePlan { @@ -20,6 +23,7 @@ * @var list */ public const array PRIVATE_ROOM_PERMISSIONS = [ + 'view_channel', 'connect', 'speak', 'use_vad', diff --git a/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php index b6e522f72..fd48fa4ed 100644 --- a/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php +++ b/app-modules/bot-discord/tests/Unit/Actions/VoiceChannel/ConfigureEmpresarialRoomActionTest.php @@ -41,8 +41,8 @@ function trackedRoom(string $channelId, string $ownerId = 'owner-1', array $user ->and($decision->plan)->toBeInstanceOf(EmpresarialOverwritePlan::class) ->and($decision->plan->companySlug)->toBe('brd') ->and($decision->plan->partnerRoleId)->toBe('role-brd') - ->and($decision->plan->denyEveryone)->toBe(['connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']) - ->and($decision->plan->allowPartnerRole)->toBe(['connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']); + ->and($decision->plan->denyEveryone)->toBe(['view_channel', 'connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']) + ->and($decision->plan->allowPartnerRole)->toBe(['view_channel', 'connect', 'speak', 'use_vad', 'send_messages', 'read_message_history']); }); it('approves privatizing a room a teammate created (gate is role, not ownership)', function (): void {