Skip to content

Temporary voice channel functionality - #232

Draft
HWqs wants to merge 1 commit into
7Cav:developfrom
HWqs:feat/100-temp-voice-channels
Draft

Temporary voice channel functionality#232
HWqs wants to merge 1 commit into
7Cav:developfrom
HWqs:feat/100-temp-voice-channels

Conversation

@HWqs

@HWqs HWqs commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes #100. "Join to create" temporary voice channels, plus owner-management controls.

What this does

A member who joins a configured hub voice channel gets a personal voice channel spawned
under that hub's category and is moved into it. The channel is auto-deleted a short grace
period after it empties. Everything runs off gateway events (VOICE_STATE_UPDATE,
GUILD_CREATE, CHANNEL_UPDATE). Nothing is persisted. Discord permission overwrites are the
only durable ownership marker.

Included

  • Multiple hubs, each with its own category and per-hub defaults (user limit, bitrate,
    empty-channel grace period).
  • Owner model matching MEE6: the creator gets a per-channel overwrite granting
    ManageChannels, MoveMembers, and ManageRoles, so they can rename, set a user limit, kick
    or move occupants, and lock, hide, or block members.
  • Interim ownership: while the creator is away from their channel, control passes to the
    highest-priority member present (status role first; e.g. general staff, MP, platoon staff,
    then rank role, then lowest user ID) and is handed back if/when the creator returns.
  • Channel naming built from roles and nickname: the rank comes from the member's rank role
    (authoritative), the name (Last.F) comes from the nickname with the rank token and trailing
    callsigns stripped. It tolerates the 0/O typo (W01 becomes WO1).
  • Numbering and retro-rename, a per-user cap of 4 with a hub-named notice, grace cleanup, and a
    restart orphan sweep that reaps empty temp channels and adopts occupied survivors.
  • Audit log to a dedicated channel (create, join, leave, delete, rename, lock, hide, block,
    interim handoff). Each line is Zulu-timestamped.
  • /voice command group: /voice block @user (deny Connect and disconnect them if present)
    and /voice permit @user.

Decisions already made for now

Area Decision
Config location All IDs hardcoded in the tool (hubs, categories, log channel, status and rank roles), following the precedent that tenant IDs live in code rather than in env.
Per-hub settings user limit, empty-channel grace, bitrate. No different name templates per hub.
Multiple hubs Supported; adding a hub is one struct entry. Two hubs are configured.
Interim election Status role tier first, rank role second, lowest user ID as the final tiebreak. No eligibility gate: anyone present can hold it. Re-evaluated on every join or leave while the creator is away.
Status ladder (most senior first) In order of priority: general staff, battalion staff, company staff, platoon staff, mp department, SL/ASL, active member, ELOA, reservist, retired, discharged, then no status role (pubbies). In each of the aforementioned roles, highest rank is prioritized.
Naming Rank from the rank role (nickname rank is only a fallback); nickname supplies only the name if rank role is available.
Typo tolerance 0/O normalized (W01, C0L); multi-word names kept (only quoted, bracketed, LOA, or emoji trailers are dropped).
Owner controls /voice covers block and permit. Rename, limit, lock, and hide are done through Discord's native channel UI, which the ManageRoles grant enables.

Decisions still open

  1. Hub settings. Hubs use defaults (unlimited, default bitrate, 15s grace). Confirm or set values.
  2. Extend /voice? Optional limit, lock, hide, rename, claim subcommands. Owners can
    already do these natively via their granted perms, so they would be convenience only.
  3. Do we write a formal ADR or rely on code comments? The election policy, /voice, and multi-hub
    config are load-bearing.
  4. /voice registration. It is registered in main.go from StartTempVC's return value (it
    needs the live runtime instance) rather than in NewRegistry(). OK?

Deployment notes

  • Relies on the GuildVoiceStates intent, already covered by IntentsAllWithoutPrivileged.
  • The bot needs Connect, Manage Channels, Manage Roles, View Channel and
    Move Members.

Nickname to channel-name resolution

Rank is taken from the rank role when available; the nickname only supplies the name. When no
rank role is held, the rank parsed from the nickname is used as a fallback, per this table:

Nickname Channel name Behavior
CPL Smith.J CPL Smith.J's Channel canonical form
1LT.Laui.M 1LT Laui.M's Channel dot separator normalized to a space
1LT. Laui.M 1LT Laui.M's Channel dot-space separator normalized
CW3.Rivera.T CW3 Rivera.T's Channel warrant rank
W01.Laui.M WO1 Laui.M's Channel 0/O typo tolerated
C0L.Smith.J COL Smith.J's Channel 0/O typo tolerated
1LT.Laui.M "Bobo" 1LT Laui.M's Channel quoted callsign dropped
1LT.Laui.M LOA 1LT Laui.M's Channel LOA suffix dropped
1LT.Laui.M <cadre> 1LT Laui.M's Channel bracketed tag dropped
CPL Smith.J 🐎 CPL Smith.J's Channel trailing emoji dropped
cpl smith.jw CPL Smith.JW's Channel rank upper-cased, name casing enforced on first letter of last name and initial(s)
CPLSmith.J CPLSmith.J's Channel no separator, rank not detected, raw nickname kept
CPL-Smith.J CPL-Smith.J's Channel hyphen separator not recognized, raw kept
CAPT.Smith.J CAPT.Smith.J's Channel not a valid rank, raw kept
XO Smith.J XO Smith.J's Channel not a rank, raw kept
(empty nickname) Trooper's Channel fallback

Only ., . , or a space work as the rank/name separator; a hyphen, underscore, or missing
separator falls through to the raw nickname. Once rank roles are configured, the rank comes
from the role and the nickname's rank text is ignored entirely.

…ownership, and /voice

First slice of the MEE6 migration (7Cav#100):

- multiple hardcoded hubs, each with per-hub user limit, bitrate, and grace

- owner overwrite grants ManageChannels, MoveMembers, and ManageRoles so owners can rename, limit, lock, hide, and block

- interim ownership by status role, then rank role, then lowest user ID, restored when the creator returns

- role-based naming: rank from the rank role, name from the nickname; tolerates the 0/O typo and keeps multi-word names

- numbering, per-user cap with a hub-named notice, grace cleanup, restart orphan sweep, and an audit log

- /voice block and /voice permit owner commands
@HWqs
HWqs force-pushed the feat/100-temp-voice-channels branch from e48100f to 0edfbb1 Compare July 26, 2026 18:14
@HWqs

HWqs commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Note: this and #229 both touch main.go. This PR only adds an 8-line block right after registry := commands.NewRegistry() (registering the temp-VC handlers and /voice); whichever of the two merges second should be a trivial rebase.

@SyniRon

SyniRon commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@HWqs Status update for you here. I went and did the MEE6 audit that should have happened before you wrote a line of this.

Start with the part you got right. There are 14 hubs live in production. My triage note on #100 called multi-hub YAGNI and put it out of scope, and the review I ran afterwards repeated that back as scope creep. Both wrong. You built the hub table anyway, and it's the piece of the design that turned out to match what the 7Cav discord actually runs.

Now the part that's mine. My triage said the creator gets MANAGE_CHANNELS and MOVE_MEMBERS through a per-channel overwrite, "matching MEE6's default behavior." I never verified that. It's a per-hub toggle, and we have it set both ways across the 14 hubs, so there is no single MEE6 model to match. The permission model in this PR is the one I handed you.

I've sent questions up the chain, covering the permission model, which commands should exist, whether the settings ought to be uniform, and what the channel name actually needs in it.

Four things are genuinely undecided until that comes back, and I'd rather you didn't spend time on any of them:

  • The owner permission model. It decides whether members edit their channel through Discord's own UI or through bot commands, and almost everything else hangs off it.
  • The command set. Worth knowing now: 7Cav has only clean, rename and transfer enabled in MEE6. Block and permit aren't turned on.
  • The config shape. Fourteen hubs of hardcoded settings won't hold, but I don't know the right shape until I know which settings genuinely differ.
  • Whether the rank and nickname parsing is needed at all. Depends entirely on what comes back about the name template.

None of that is a judgement on how you built any of it. The lifecycle engine underneath, spawn on hub join, occupancy tracking, the grace reap, the restart sweep, the manager seam, is the hard part and it's sound. I'd expect all of it to survive whatever we decide.

If you want to work some fixes while we wait, these hold no matter the answers to the above:

:1125 returns silently when the create fails, so the member sits in the hub with no idea anything went wrong. notifyCapReached shows it's reachable.

dg.SyncEvents isn't set, so discordgo runs each handler on its own goroutine and handleVoiceStateUpdate is a sequential diff. Separately, ownedCount reads under the lock at :650, the lock drops at :662, and the create fires at :1107, so two quick joins at three owned both get through. I haven't reproduced that second one against a live bot, but a test firing concurrent joins for one member should catch it.

logEvent:698 sends raw content with no AllowedMentions, and a nickname of @everyone comes through splitNick untouched.

truncateChannelName:1394 and nameWithIndex:1405 cut by byte while the comments say character. Needs a non-ASCII nickname to bite, so it's rare rather than urgent.

classifyDiscordError in warden_errors.go:62 never gets called from this file, and there's no CHANNEL_DELETE handler either. Both want fixing, but read the classifier before you wire it up, because half of it suits you and half actively doesn't.

The status-class split is generic and worth reusing: 403 is missing permissions, 5xx and transport are real system faults, any other 4xx is Discord rejecting the request. classifyNotFound underneath it is not generic. Its own doc says it was written for a role-add, and it routes every 404 code that isn't Unknown Member into SystemFault plus ConfigFault, which pages on-call. A ChannelDelete 404 carries Unknown Channel, 10003, so it lands in exactly that bucket. Wire it straight in and a channel a member deleted by hand would page us, which is the specific thing we are trying to stop happening.

What you want is Unknown Channel untracking the channel quietly, while a 403 keeps it tracked so it still counts against the owner's cap. Either give this file its own 404 branch or add a channel-aware arm to the shared one, whichever you think reads better. And yes, a helper that generic shouldn't be living in a file named after warden, but moving it drags warden's test file along with it, so not in this PR.

How often any of this bites depends on the permission answer, since that decides whether members can hand-delete their own channels at all.

One thing worth building ahead of the answers. Right now /voice is a single command with two flat options: a command string carrying the block and permit choices, and a required user. That holds together because both actions happen to take exactly one user. It comes apart as soon as the actions take different arguments, and the three we actually have enabled are clean which takes nothing, rename which takes a string, and transfer which takes a user.

You can bodge it by making user optional and adding an optional name, but then the Discord client shows every field no matter which action was picked, enforces none of them per action, and all the validation lands in Go. The best option is ApplicationCommandOptionSubCommand, where each action is its own entry carrying its own option list, so the client prompts for the right thing and clean genuinely takes no arguments. Nothing in the repo uses it yet, so there's no local example to crib from.

The handler moves with it. The chosen action becomes data.Options[0].Name, and its arguments sit one level down in data.Options[0].Options, so warden's getOptionString won't reach them as written. It either needs a nested variant or the call sites change.

I'd do this now because block and permit is close to the only command set where the flat shape works at all, and it almost certainly isn't the set we land on.

That's also the moment to settle your open question 4, about registering /voice from main.go. You're half right that it's forced. The gateway handlers genuinely need the live session and genuinely need to be added before Open(), so that much stays in main. The command declaration doesn't. Your handler already receives the session at invocation; what it actually needs is the shared runtime, and we've solved that shape before. LOA() and Awol() are declared in NewRegistry() and read utils.GlobalLOACache, which initLOACache() builds separately over in main.go. StartJoinerReportScheduler at main.go:176 is the same session-taking, returns-nothing call. A package-level tempVC singleton, Voice() in the registry reading it, and StartTempVC going void would put this back inside ADR 0006 without losing anything.

Watch the ordering if you do it. NewRegistry() runs at :105 and StartTempVC at :113, so the command gets declared before the runtime is wired. Nothing can arrive in that gap because Open() comes later, but I'd rather the handler was nil-safe than have us relying on two line numbers staying in that order.

If you'd rather park it till we get the full picture, that is fair too.

@SyniRon
SyniRon marked this pull request as draft August 6, 2026 05:05
@SyniRon

SyniRon commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

The scope of this has changed materially after conversations with genstaff, and in a way that I'm not willing to hand off and I want to instead build myself.

That said, what you have here is good foundational work for it, so instead of closing this I'm going to build off of it to get where we need to be for what is required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MEE6 replacement: temporary voice channels (join a hub, get a personal VC, auto-cleanup)

2 participants