Add actions menu admin features
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user