Skip to content

feat(bot-discord): sala empresarial — role-gated private voice rooms #448

Description

@gvieira18

Problem Statement

Partner companies (Empresa Parceira) have members with a dedicated Discord role who come to the He4rt guild and spin up a work room with /sala. That room is created as a normal, public voice channel — anyone on the server can join, listen, and speak. There is currently no way for a partner-company member to make that room private for their company. They need any member of their company to be able to "configure" an already-created room so it becomes an isolated, company-only space.

Solution

Add a dedicated /sala-empresalial-style convert command — /sala-empresarial empresa:<company> — that a partner-company member runs while inside a /sala-created room to turn it into a Sala Empresarial: a private room where @everyone is blocked and only the selected company's Partner Role may participate.

When invoked, the command stamps Discord permission overwrites on the current channel:

  • deny @everyone: CONNECT, SPEAK, USE_VAD (use voice activity), READ_MESSAGE_HISTORY
  • allow the selected company's Partner Role: CONNECT, SPEAK, USE_VAD, READ_MESSAGE_HISTORY
  • MENTION_EVERYONE is left unset — it stays denied via the category's existing inheritance (every /sala room already blocks it).
  • VIEW_CHANNEL is left untouched — the room stays visible-but-locked in the channel list.

The command is gated on Partner Role membership, not room ownership: any member of the selected company (not just the room's creator) can privatize it. The design supports many partner companies from day one via a config registry, so it does not break when a second company is onboarded.

User Stories

  1. As a partner-company member, I want to convert a room I created with /sala into a private company room, so that only my colleagues can join our work session.
  2. As a partner-company member, I want to run a single command while inside the room to privatize it, so that I don't have to manually edit Discord channel permissions.
  3. As a partner-company member who belongs to more than one partner company, I want to explicitly choose which company the room is for via a parameter, so that the correct role is granted access.
  4. As a partner-company member, I want the command to reject me if I pick a company whose role I don't hold, so that nobody can privatize a room on behalf of a company they don't belong to.
  5. As any member of a partner company, I want to be able to privatize the room even if a teammate created it, so that we aren't blocked when the original creator steps away.
  6. As a He4rt admin, I want a new partner company to be enabled by adding one config entry (slug + role id), so that onboarding a partner requires no code change.
  7. As a He4rt admin, I want the brd company to work out of the box with its production role id as the default, so that the first partner is usable immediately on deploy.
  8. As a guild member outside the partner company, I want to be unable to connect, speak, use voice activity, or read the history of a Sala Empresarial, so that the company's session stays private.
  9. As a guild member outside the partner company, I want the Sala Empresarial to still be visible in the channel list, so that the server layout is unchanged (visible-but-locked, by design).
  10. As a partner-company member, I want the command to reject me if I'm not currently inside a /sala-created room, so that I can't accidentally privatize a permanent/static channel.
  11. As a partner-company member, I want an ephemeral confirmation when the room is successfully configured, so that only I see the result and the channel isn't spammed.
  12. As a partner-company member, I want ephemeral, human-readable error messages when a guard fails, so that I understand why the command didn't work.
  13. As a He4rt admin, I want empresarial rooms to auto-clean up like any /sala room (deleted when empty), so that no permanently-private orphan channels accumulate.
  14. As a partner-company member, I want re-running the command on an already-configured room to be harmless (a re-stamp), so that a double invocation doesn't error or corrupt state.
  15. As a He4rt admin, I want the command choices to list all registered partner companies, so that members can pick their company from a dropdown.
  16. As a developer, I want the privatization decision (guards + role resolution + overwrite plan) to be a single unit-testable domain Action, so that behavior is verified without mocking the Discord interaction.

Acceptance Scenarios

Expressed as Gherkin against the decision seam (ConfigureEmpresarialRoomAction). "Overwrite plan" = deny @everyone and allow the resolved Partner Role for CONNECT, SPEAK, USE_VAD, READ_MESSAGE_HISTORY.

Feature: Configure a Sala Empresarial

  Background:
    Given the partner registry contains "brd" and "acme"
    And "brd" maps to a Partner Role on the guild

  Scenario: Member privatizes their own /sala room
    Given a member holding the "brd" Partner Role
    And they are inside a /sala-tracked room
    When they run /sala-empresarial empresa:brd
    Then the room is configured as empresarial for "brd"
    And @everyone is denied CONNECT, SPEAK, USE_VAD and READ_MESSAGE_HISTORY
    And the "brd" Partner Role is allowed CONNECT, SPEAK, USE_VAD and READ_MESSAGE_HISTORY
    And VIEW_CHANNEL and MENTION_EVERYONE are left unchanged
    And the member receives an ephemeral success confirmation

  Scenario: Member privatizes a room a teammate created
    Given a member holding the "brd" Partner Role
    And they are inside a /sala-tracked room created by another "brd" member
    When they run /sala-empresarial empresa:brd
    Then the room is configured as empresarial for "brd"

  Scenario: Caller picks a company whose role they do not hold
    Given a member holding only the "brd" Partner Role
    And they are inside a /sala-tracked room
    When they run /sala-empresarial empresa:acme
    Then the decision is rejected with reason "missing-partner-role"
    And no permission overwrites are stamped on the channel
    And the member receives an ephemeral error

  Scenario: Caller is not inside a /sala-tracked room
    Given a member holding the "brd" Partner Role
    And they are inside a permanent voice channel (not /sala-tracked)
    When they run /sala-empresarial empresa:brd
    Then the decision is rejected with reason "not-in-tracked-room"
    And no permission overwrites are stamped on the channel

  Scenario: Caller is in no voice channel at all
    Given a member holding the "brd" Partner Role
    And they are not connected to any voice channel
    When they run /sala-empresarial empresa:brd
    Then the decision is rejected with reason "not-in-tracked-room"

  Scenario: Selected company is not registered
    Given a member inside a /sala-tracked room
    When they run /sala-empresarial with an unregistered company slug
    Then the decision is rejected with reason "unknown-company"

  Scenario: Member of multiple partner companies selects one
    Given a member holding both the "brd" and "acme" Partner Roles
    And they are inside a /sala-tracked room
    When they run /sala-empresarial empresa:acme
    Then the room is configured as empresarial for "acme"
    And only the "acme" Partner Role is allowed
    And the "brd" Partner Role is not allowed

  Scenario: Re-running on an already-configured room is a harmless re-stamp
    Given a room already configured as empresarial for "brd"
    And a member holding the "brd" Partner Role inside it
    When they run /sala-empresarial empresa:brd again
    Then the same overwrite plan is produced
    And the command does not error

  Scenario: Empresarial room stays visible but locked to outsiders
    Given a room configured as empresarial for "brd"
    When a member without the "brd" Partner Role views the guild
    Then the room remains visible in the channel list
    But they cannot connect, speak, use voice activity, or read its history

  Scenario: Empty empresarial room is auto-cleaned like any /sala room
    Given a room configured as empresarial for "brd"
    When the room has been empty beyond the cleanup threshold
    Then DynamicVoiceTask deletes it
    And its permission overwrites are discarded with the channel

Implementation Decisions

  • Module: bot-discord. New slash command + one new domain Action; no other module is modified.
  • Trigger model — convert, not create-flag. A separate command converts an existing room. /sala (DynamicVoiceCommand) is left untouched; we do not add an empresarial flag to it. Rationale: /sala is shared by the whole guild — branching it and gating a flag by role would couple a general command to partner concerns. Mirrors the existing /sala-limite pattern.
  • Command type — slash command. New SalaEmpresarialCommand extends AbstractSlashCommand ($name = 'sala-empresarial'), reusing the base class's member resolution, guild guard, and ephemeral replies. Consistent with every other room command (/sala, /sala-limite).
  • Company selection — explicit empresa parameter. The command takes a required empresa choice option. A caller may belong to more than one Empresa Parceira, so the company is chosen explicitly rather than inferred by intersecting the caller's roles with the registry. Discord slash choices are static (same list for everyone), built from the config registry; per-user validation happens at runtime.
  • Config registry — flat slug => role_id map. New bot-discord.roles.partners config entry, matching the existing flat bot-discord.roles style. Seeded with 'brd' => env('HE4RT_PARTNER_BRD_ROLE_ID', '1522599722370465904'). Adding a partner company = one env + one config line. Choice value = slug, name = slug uppercased (e.g. BRD).
  • Guards (in order):
    1. Caller is inside a voice channel that is tracked in the voice_channels cache (active_voice_channels_keys) — i.e. a /sala-created room. Reject otherwise.
    2. Caller actually holds the Partner Role of the selected company. Reject (ephemeral) otherwise.
    • Gating is by Partner Role membership, not room ownership (unlike /sala-limite, which is owner-gated).
  • Permission overwrites: deny @everyone and allow the selected Partner Role for CONNECT, SPEAK, USE_VAD, READ_MESSAGE_HISTORY. MENTION_EVERYONE relies on category inheritance; VIEW_CHANNEL is not touched (visible-but-locked).
  • Stateless — Discord is the source of truth. VoiceChannelDTO and the cache shape are not changed. The overwrites live only on the Discord channel. Because only /sala-tracked rooms are converted and DynamicVoiceTask auto-deletes them when empty, the overwrites die with the channel. Re-running is a harmless re-stamp.
  • No revert command. A room un-privatizes by emptying out and being auto-deleted. There is no "un-empresarial" command in scope.
  • Decision/IO split (the seam). A single domain Action (e.g. ConfigureEmpresarialRoomAction) owns the decision: given the selected company slug, the caller's role IDs, the caller's current channel id, and the active-channels cache, it returns either a rejection reason (not-in-tracked-room | missing-partner-role | unknown-company) or an overwrite plan (the deny/allow role→permission sets). The Discord setPermissions I/O stays at the thin command edge, exactly as DeleteVoiceChannelAction holds the channels->delete() call. First permission-overwrite code in the module.
  • Docs already in repo: app-modules/bot-discord/CONTEXT.md (glossary: Sala Empresarial, Empresa Parceira, Partner Role) and app-modules/bot-discord/docs/adr/0001-sala-empresarial-role-gated-private-rooms.md.

Testing Decisions

  • Good test = external behavior, not implementation. Assert on the Action's output — the rejection reason or the computed overwrite plan (which role gets allowed, which permissions are denied on @everyone) — given plain inputs. Do not assert on how the plan is built internally, and do not mock the Discord interaction to test the decision.
  • Module under test: bot-discord, tests/Unit/Actions/VoiceChannel/ (new).
  • Prior art: JoiningChannelActionTest and LeftChannelActionTest — pure unit tests over Actions/VoiceChannel/* that pass plain inputs (channel id, active-channels list, user id) and assert on the resulting cache/DTO state, with no Discord mocking. The new Action test mirrors this: feed (empresa slug, caller role ids, current channel id, active channels) and assert the returned rejection reason or overwrite plan.
  • Cases to cover: success (caller in tracked room, holds selected role) → overwrite plan with correct deny/allow sets; not in a tracked room → not-in-tracked-room; holds no role for the selected company → missing-partner-role; unknown/unregistered company slug → unknown-company; caller belonging to multiple partner companies picks one → only that company's role is allowed; re-stamp on an already-configured room → same plan, no error.
  • Slash-command wiring (parameter choices, ephemeral replies) is thin and follows existing command prior art (EditProfileCommandTest, IntroductionCommandTest) if a feature-level test is desired, but the decision coverage lives in the Action unit test.

Out of Scope

  • A revert / "make public again" command. Rooms de-privatize by emptying and being auto-deleted.
  • Converting any voice channel — only /sala-tracked rooms are eligible. Permanent/static channels are explicitly excluded.
  • Hiding the room (deny VIEW_CHANNEL). Rooms stay visible-but-locked by design.
  • Force-disconnecting non-members already connected at conversion time. Discord's deny CONNECT does not evict current occupants; deny SPEAK/USE_VAD mutes them until they leave. Accepted for MVP.
  • Persisting empresarial state (which company a room belongs to) anywhere in the app; no VoiceChannelDTO/cache-shape changes.
  • A /sala creation-time empresarial flag.
  • Any per-company customization beyond the role→permission overwrite set (e.g. custom names, limits, categories).

Further Notes

  • brd production Partner Role id: 1522599722370465904 (guild 452926217558163456); default seed via HE4RT_PARTNER_BRD_ROLE_ID.
  • Edge case accepted: a non-member sitting in the room at conversion time stays connected but muted until they leave.
  • Follow module conventions: run Pint + PHPStan + Rector (dry-run) over the whole project before finalizing, and add the Action unit test before wiring the command.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions