Add actions menu admin features

This commit is contained in:
Storm Dragon
2026-05-19 01:06:01 -04:00
parent eef7454c0f
commit cc483685ef
10 changed files with 1320 additions and 5 deletions
+20
View File
@@ -194,6 +194,25 @@ When in the treeview, pressing:
* enter on de-selected user selects that user for PM mode.
* enter on selected user de-selects the user
* enter on a channel de-selects any selected users (if any) and moves you to the specified channel.
* f11 opens the actions menu for the focused user or channel.
The actions menu is a plain tree view for screen reader accessibility. It starts with non-privileged information actions, such as requesting user comments, user stats, channel descriptions, and channel permissions. If Barnard has permission information showing that you cannot perform an admin action, that privileged action is hidden. The server still remains the final authority and will reject actions if permissions are missing or stale. Destructive actions such as kick, ban, channel deletion, deregister, and raw ACL edits prompt in the input line before sending. Press Escape to close the menu or cancel an active prompt.
Admin actions are also available through `/admin` commands and the FIFO command interface. Examples:
* `/admin menu`
* `/admin kick Username reason for kick`
* `/admin ban Username reason for ban`
* `/admin mute Username on`
* `/admin deafen Username off`
* `/admin suppress Username toggle`
* `/admin move Username ChannelName`
* `/admin banlist`
* `/admin unban 1`
* `/admin users`
* `/admin acl request ChannelName`
* `/admin acl grant mute_deafen user 123`
* `/admin context action_name user Username`
## Volume
@@ -248,6 +267,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>F9</kbd>: toggle noise suppression
- <kbd>F11</kbd>: open actions menu for the focused tree item
- <kbd>F12</kbd>: cycle through voice effects
- <kbd>Ctrl+R</kbd>: toggle recording
- <kbd>Ctrl+L</kbd>: clear chat log
+1090
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"testing"
"git.stormux.org/storm/barnard/gumble/gumble"
)
func TestParseToggleState(t *testing.T) {
tests := []struct {
name string
text string
current bool
want bool
wantOK bool
}{
{name: "toggle true", text: "toggle", current: true, want: false, wantOK: true},
{name: "empty toggles", text: "", current: false, want: true, wantOK: true},
{name: "on", text: "on", current: false, want: true, wantOK: true},
{name: "off", text: "off", current: true, want: false, wantOK: true},
{name: "bad", text: "maybe", current: true, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseToggleState(tt.text, tt.current)
if ok != tt.wantOK {
t.Fatalf("expected ok %v, got %v", tt.wantOK, ok)
}
if ok && got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestParsePermission(t *testing.T) {
tests := []struct {
text string
want gumble.Permission
}{
{text: "write", want: gumble.PermissionWrite},
{text: "mute_deafen", want: gumble.PermissionMuteDeafen},
{text: "kick", want: gumble.PermissionKick},
{text: "register_self", want: gumble.PermissionRegisterSelf},
}
for _, tt := range tests {
got, ok := parsePermission(tt.text)
if !ok {
t.Fatalf("expected %s to parse", tt.text)
}
if got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
}
if _, ok := parsePermission("bogus"); ok {
t.Fatal("expected bogus permission to fail")
}
}
func TestPermissionList(t *testing.T) {
got := permissionList(gumble.PermissionWrite | gumble.PermissionBan)
if got != "write,ban" {
t.Fatalf("expected write,ban, got %q", got)
}
}
+8
View File
@@ -37,9 +37,12 @@ type Barnard struct {
UiInput uiterm.Textbox
UiStatus uiterm.Label
UiTree uiterm.Tree
UiAdmin uiterm.Tree
UiInputStatus uiterm.Label
SelectedChannel *gumble.Channel
selectedUser *gumble.User
adminTargetUser *gumble.User
adminTargetChan *gumble.Channel
statusText string
statusNotice bool
@@ -66,6 +69,11 @@ type Barnard struct {
Recorder *recording.Recorder
recordingStarting bool
recordingAllowed *bool
pendingAdminPrompt *adminPrompt
adminBanList gumble.BanList
adminUserList gumble.RegisteredUsers
adminACL *gumble.ACL
}
func (b *Barnard) StopTransmission() {
+89 -3
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"net"
"strings"
"time"
"git.stormux.org/storm/barnard/fileplayback"
@@ -215,16 +216,85 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
if e.Type.Has(gumble.UserChangeRecording) {
b.HandleRecordingChange(e)
}
if e.Type.Has(gumble.UserChangeComment) {
comment := strings.TrimSpace(esc(e.User.Comment))
if comment == "" {
comment = "empty"
}
b.AddOutputLine(fmt.Sprintf("User comment for %s: %s", e.User.Name, comment))
}
if e.Type.Has(gumble.UserChangeStats) && e.User.Stats != nil {
b.AddOutputLine(formatUserStats(e.User))
}
b.UiTree.Rebuild()
b.Ui.Refresh()
}
func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name))
if e.Type.Has(gumble.ChannelChangeDescription) {
description := strings.TrimSpace(esc(e.Channel.Description))
if description == "" {
description = "empty"
}
b.AddOutputLine(fmt.Sprintf("Channel description for %s: %s", e.Channel.Name, description))
}
if e.Type.Has(gumble.ChannelChangePermission) {
if permission := e.Channel.Permission(); permission != nil {
b.AddOutputLine(fmt.Sprintf("Channel permissions for %s: %s", e.Channel.Name, permissionList(*permission)))
}
}
b.UiTree.Rebuild()
b.Ui.Refresh()
}
func formatUserStats(user *gumble.User) string {
stats := user.Stats
connected := "unknown"
if !stats.Connected.IsZero() {
connected = time.Since(stats.Connected).Round(time.Second).String()
}
ip := "unknown"
if stats.IP != nil {
ip = stats.IP.String()
}
version := formatUserVersion(stats.Version)
return fmt.Sprintf(
"User stats for %s: version %s, connected %s, idle %s, bandwidth %d, UDP ping %.1f ms, TCP ping %.1f ms, IP %s, Opus %s",
user.Name,
version,
connected,
stats.Idle.Round(time.Second),
stats.Bandwidth,
stats.UDPPingAverage,
stats.TCPPingAverage,
ip,
onOff(stats.Opus),
)
}
func formatUserVersion(version gumble.Version) string {
major, minor, patch := (&version).SemanticVersion()
semantic := fmt.Sprintf("%d.%d.%d", major, minor, patch)
parts := []string{}
if version.Release != "" {
parts = append(parts, version.Release)
}
if version.OS != "" {
parts = append(parts, version.OS)
}
if version.OSVersion != "" {
parts = append(parts, version.OSVersion)
}
if len(parts) == 0 && version.Version == 0 {
return "unknown"
}
if len(parts) == 0 {
return semantic
}
return semantic + " " + strings.Join(parts, " ")
}
func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
var info string
switch e.Type {
@@ -253,18 +323,34 @@ func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
}
func (b *Barnard) OnUserList(e *gumble.UserListEvent) {
//for _,u := range e.UserList {
//b.UserConfig.UpdateUser(u)
//}
b.adminUserList = e.UserList
b.AddOutputLine(fmt.Sprintf("Admin: received %d registered users", len(e.UserList)))
b.UiAdmin.Rebuild()
b.Ui.Refresh()
}
func (b *Barnard) OnACL(e *gumble.ACLEvent) {
b.adminACL = e.ACL
if e.ACL != nil && e.ACL.Channel != nil {
b.AddOutputLine(fmt.Sprintf("Admin: received ACLs for %s", e.ACL.Channel.Name))
}
b.UiAdmin.Rebuild()
b.Ui.Refresh()
}
func (b *Barnard) OnBanList(e *gumble.BanListEvent) {
b.adminBanList = e.BanList
b.AddOutputLine(fmt.Sprintf("Admin: received %d bans", len(e.BanList)))
b.UiAdmin.Rebuild()
b.Ui.Refresh()
}
func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) {
if e.ContextAction != nil {
b.AddOutputLine(fmt.Sprintf("Admin: context action updated: %s", e.ContextAction.Name))
}
b.UiAdmin.Rebuild()
b.Ui.Refresh()
}
func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
+1
View File
@@ -19,6 +19,7 @@ type Hotkeys struct {
ScrollDown *uiterm.Key
ScrollToTop *uiterm.Key
ScrollToBottom *uiterm.Key
AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key
CycleVoiceEffect *uiterm.Key
}
+5
View File
@@ -83,6 +83,7 @@ func (c *Config) LoadConfig() {
SwitchViews: key(uiterm.KeyTab),
ScrollUp: key(uiterm.KeyPgup),
ScrollDown: key(uiterm.KeyPgdn),
AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9),
CycleVoiceEffect: key(uiterm.KeyF12),
}
@@ -169,6 +170,7 @@ func (c *Config) ensureHotkeys() {
SwitchViews: key(uiterm.KeyTab),
ScrollUp: key(uiterm.KeyPgup),
ScrollDown: key(uiterm.KeyPgdn),
AdminMenu: key(uiterm.KeyF11),
NoiseSuppressionToggle: key(uiterm.KeyF9),
CycleVoiceEffect: key(uiterm.KeyF12),
}
@@ -206,6 +208,9 @@ func (c *Config) ensureHotkeys() {
if hotkeys.ScrollDown == nil {
hotkeys.ScrollDown = defaults.ScrollDown
}
if hotkeys.AdminMenu == nil {
hotkeys.AdminMenu = defaults.AdminMenu
}
if hotkeys.NoiseSuppressionToggle == nil {
hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle
}
+6
View File
@@ -29,4 +29,10 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) {
if got := *cfg.GetHotkeys().RecordToggle; got != uiterm.KeyCtrlR {
t.Fatalf("expected record toggle ctrl_r, got %s", got)
}
if cfg.GetHotkeys().AdminMenu == nil {
t.Fatal("expected admin menu hotkey to be backfilled")
}
if got := *cfg.GetHotkeys().AdminMenu; got != uiterm.KeyF11 {
t.Fatalf("expected admin menu f11, got %s", got)
}
}
+28 -1
View File
@@ -21,6 +21,7 @@ const (
uiViewInputStatus = "inputstatus"
uiViewOutput = "output"
uiViewTree = "tree"
uiViewAdmin = "admin"
)
func Beep() {
@@ -359,7 +360,7 @@ func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
active := b.Ui.Active()
if active == uiViewInput {
b.Ui.SetActive(uiViewTree)
} else if active == uiViewTree {
} else if active == uiViewTree || active == uiViewAdmin {
b.Ui.SetActive(uiViewInput)
}
width, height := termbox.Size()
@@ -368,6 +369,9 @@ func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) {
}
func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) {
if b.handleAdminPrompt(text) {
return
}
if text == "" {
return
}
@@ -397,6 +401,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin
b.CommandNoiseSuppressionToggle(ui, cmdArgs)
case "record":
b.CommandRecord(ui, cmdArgs)
case "admin":
b.CommandAdmin(ui, cmdArgs)
case "micup":
b.CommandMicUp(ui, cmdArgs)
case "micdown":
@@ -473,6 +479,15 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
}
ui.Add(uiViewTree, &b.UiTree)
b.UiAdmin = uiterm.Tree{
Generator: b.AdminItemBuild,
KeyListener: b.AdminItemKeyPress,
CharacterListener: b.AdminItemCharacter,
Fg: uiterm.ColorWhite,
Bg: uiterm.ColorBlack,
}
ui.Add(uiViewAdmin, &b.UiAdmin)
b.Ui.AddCommandListener(b.CommandMicUp, "micup")
b.Ui.AddCommandListener(b.CommandMicDown, "micdown")
b.Ui.AddCommandListener(b.CommandTalk, "toggle")
@@ -483,7 +498,9 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddCommandListener(b.CommandPlayFile, "file")
b.Ui.AddCommandListener(b.CommandStopFile, "stop")
b.Ui.AddCommandListener(b.CommandRecord, "record")
b.Ui.AddCommandListener(b.CommandAdmin, "admin")
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnAdminMenuPress, b.Hotkeys.AdminMenu)
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
@@ -494,6 +511,8 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown)
b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop)
b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom)
esc := uiterm.KeyEsc
b.Ui.AddKeyListener(b.OnAdminEscape, &esc)
b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild()
b.Ui.Refresh()
@@ -506,12 +525,20 @@ func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) {
if active == uiViewTree {
treeHeight = height - 4
outputHeight = 0
} else if active == uiViewAdmin {
treeHeight = 0
outputHeight = 0
} else {
treeHeight = 0
outputHeight = height - 4
}
ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1)
ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1)
if active == uiViewAdmin {
ui.SetBounds(uiViewAdmin, 0, 1, width, height-3)
} else {
ui.SetBounds(uiViewAdmin, 0, 1, width, 1)
}
ui.SetBounds(uiViewStatus, 0, height-2, width, height-1)
ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height)
ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height)
+8 -1
View File
@@ -72,7 +72,7 @@ func (t *Tree) Rebuild() {
}
}
t.lines = lines
t.SetActiveLine(0, true)
t.SetActiveLine(0, false)
t.uiDraw()
}
@@ -162,6 +162,13 @@ func (t *Tree) SetActiveLine(num int, relative bool) {
}
}
func (t *Tree) ActiveItem() TreeItem {
if len(t.lines) == 0 {
return nil
}
return t.lines[bounded(t.activeLine, 0, len(t.lines)-1)].Item
}
func (t *Tree) uiKeyEvent(key Key) {
var runHandler = true
switch key {