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
17 changes: 10 additions & 7 deletions app-modules/bot-discord/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

Expand Down
9 changes: 9 additions & 0 deletions app-modules/bot-discord/config/bot-discord.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,14 @@
'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:<slug>` 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'),
],
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
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:<slug>` 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 `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.
Comment thread
gvieira18 marked this conversation as resolved.

## 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 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.
Comment thread
gvieira18 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Actions\VoiceChannel;

use He4rt\BotDiscord\DTO\EmpresarialOverwritePlan;
use He4rt\BotDiscord\DTO\EmpresarialRoomDecision;
use He4rt\BotDiscord\DTO\VoiceChannelDTO;
use He4rt\BotDiscord\Enums\EmpresarialRejectionReason;

/**
* Decides whether the caller may turn the voice room they are in into a Sala Empresarial
* for the selected company, and — if so — what permission overwrites to stamp.
*
* Pure decision: no Discord I/O. The command edge applies the returned plan.
*/
final class ConfigureEmpresarialRoomAction
{
/**
* @param list<string> $callerRoleIds Discord role ids held by the caller.
* @param list<VoiceChannelDTO> $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<VoiceChannelDTO> $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<string, string> $partners */
$partners = config('bot-discord.roles.partners', []);

if (!array_key_exists($companySlug, $partners)) {
return null;
}

return $partners[$companySlug];
}
}
54 changes: 54 additions & 0 deletions app-modules/bot-discord/src/DTO/EmpresarialOverwritePlan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\DTO;

/**
* 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.
*
* `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
{
/**
* Discord channel-permission keys that gate a private voice room.
*
* @var list<string>
*/
public const array PRIVATE_ROOM_PERMISSIONS = [
'view_channel',
'connect',
'speak',
'use_vad',
'send_messages',
'read_message_history',
];

/**
* @param list<string> $denyEveryone Permissions denied to `@everyone`.
* @param list<string> $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,
);
}
}
34 changes: 34 additions & 0 deletions app-modules/bot-discord/src/DTO/EmpresarialRoomDecision.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 He4rt\BotDiscord\Enums\EmpresarialRejectionReason;

/**
* Outcome of the Sala Empresarial decision seam: either a rejection reason or an
* approved overwrite plan. Exactly one of {@see $rejection}/{@see $plan} is set.
*/
final readonly class EmpresarialRoomDecision
{
private function __construct(
public ?EmpresarialRejectionReason $rejection,
public ?EmpresarialOverwritePlan $plan,
) {}

public static function reject(EmpresarialRejectionReason $reason): self
{
return new self(rejection: $reason, plan: null);
}

public static function approve(EmpresarialOverwritePlan $plan): self
{
return new self(rejection: null, plan: $plan);
}

public function isApproved(): bool
{
return $this->plan instanceof EmpresarialOverwritePlan;
}
}
27 changes: 27 additions & 0 deletions app-modules/bot-discord/src/Enums/EmpresarialRejectionReason.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace He4rt\BotDiscord\Enums;

/**
* Why a `/sala-empresarial` privatization request was rejected by the decision seam.
*/
enum EmpresarialRejectionReason: string
{
case NotInTrackedRoom = 'not-in-tracked-room';
case MissingPartnerRole = 'missing-partner-role';
case UnknownCompany = 'unknown-company';

/**
* Human-readable, ephemeral-friendly message shown to the caller.
*/
public function message(): string
{
return match ($this) {
self::NotInTrackedRoom => '❌ 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.',
};
}
}
Loading