3 Commits

Author SHA1 Message Date
richard 75c113909d Add system tray icon, file logging, and fix Windows hotkey conflict
- Adds fyne.io/systray with a programmatic monkey face icon and Quit menu item
- Logs to discord-lobby-mute.log next to the exe so startup errors are visible
  without a console window
- Windows now uses raw Win32 RegisterHotKey in a locked goroutine instead of
  golang.design/x/hotkey, avoiding a deadlock where systray and the hotkey
  library both try to own the main thread message loop
- Build with: GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui" -o discord-lobby-mute.exe .

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 19:24:20 +03:00
richard 4f94c4f20b Add tests and multi-platform keybind support (Linux, macOS, Windows)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:11:12 +03:00
richard cecd50361c Add exempt_users config to skip muting specific users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:02:30 +03:00
13 changed files with 585 additions and 53 deletions
+30 -11
View File
@@ -17,11 +17,14 @@ Runs in the system tray as a console app. Press a configurable global hotkey to
"token": "YOUR_BOT_TOKEN",
"guild_id": "YOUR_SERVER_ID",
"channel_id": "YOUR_VOICE_CHANNEL_ID",
"keybind": "ctrl+shift+m"
"keybind": "ctrl+shift+m",
"exempt_users": ["YOUR_DISCORD_USER_ID"]
}
```
To get IDs: enable **Developer Mode** in Discord settings, then right-click a server or channel and choose **Copy ID**.
`exempt_users` is optional. Any user IDs listed there will never be muted. Add your own ID to exclude yourself when pressing the keybind.
To get IDs: enable **Developer Mode** in Discord settings, then right-click a server, channel, or user and choose **Copy ID**.
## Keybind format
@@ -31,13 +34,14 @@ Examples: `ctrl+shift+m`, `alt+f9`, `ctrl+alt+delete`
### Modifiers
| Value | Key |
|---------|--------------|
| `ctrl` | Control |
| `shift` | Shift |
| `alt` | Alt |
| `win` | Windows key |
| `super` | Windows key |
| Value | Windows | Linux | macOS |
|---------|-------------|-------------|-------------|
| `ctrl` | Control | Control | Control |
| `shift` | Shift | Shift | Shift |
| `alt` | Alt | Alt (Mod1) | Option |
| `win` | Windows key | Super (Mod4)| Cmd |
| `super` | Windows key | Super (Mod4)| Cmd |
| `cmd` | — | — | Cmd |
### Keys
@@ -58,8 +62,23 @@ Examples: `ctrl+shift+m`, `alt+f9`, `ctrl+alt+delete`
## Build
Cross-compile for Windows from Linux/macOS:
```bash
# Windows
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o discord-lobby-mute.exe .
# Linux
go build -ldflags="-s -w" -o discord-lobby-mute .
# macOS
go build -ldflags="-s -w" -o discord-lobby-mute .
```
> **macOS**: the app needs Accessibility permission to register global hotkeys. Grant it in System Settings → Privacy & Security → Accessibility.
> **Linux**: requires X11 (`libx11-dev`) and CGO enabled.
## Tests
```bash
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o discord-lobby-mute.exe .
go test ./...
```
+2 -1
View File
@@ -2,5 +2,6 @@
"token": "YOUR_BOT_TOKEN_HERE",
"guild_id": "YOUR_SERVER_ID",
"channel_id": "YOUR_VOICE_CHANNEL_ID",
"keybind": "ctrl+shift+m"
"keybind": "ctrl+shift+m",
"exempt_users": ["YOUR_DISCORD_USER_ID"]
}
+3 -1
View File
@@ -9,7 +9,9 @@ require (
)
require (
fyne.io/systray v1.12.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
golang.org/x/sys v0.15.0 // indirect
)
+6
View File
@@ -1,5 +1,9 @@
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.design/x/hotkey v0.4.1 h1:zLP/2Pztl4WjyxURdW84GoZ5LUrr6hr69CzJFJ5U1go=
@@ -12,6 +16,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows
package main
import "golang.design/x/hotkey"
func startHotkey(keybind string) (<-chan struct{}, error) {
mods, key, err := parseKeybind(keybind)
if err != nil {
return nil, err
}
hk := hotkey.New(mods, key)
if err := hk.Register(); err != nil {
return nil, err
}
out := make(chan struct{})
go func() {
for range hk.Keydown() {
out <- struct{}{}
}
close(out)
}()
return out, nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"bytes"
"image"
"image/color"
"image/png"
)
func monkeyIcon() []byte {
const size = 32
img := image.NewRGBA(image.Rect(0, 0, size, size))
brown := color.RGBA{110, 72, 38, 255}
tan := color.RGBA{210, 175, 130, 255}
pink := color.RGBA{190, 130, 110, 255}
dark := color.RGBA{15, 8, 3, 255}
white := color.RGBA{255, 255, 255, 255}
// Ears drawn first so the head circle overlaps the inner edge naturally.
fillCircle(img, 4, 13, 5, brown)
fillCircle(img, 28, 13, 5, brown)
fillCircle(img, 4, 13, 3, pink)
fillCircle(img, 28, 13, 3, pink)
fillCircle(img, 16, 15, 13, brown) // head
fillEllipse(img, 16, 21, 7, 4, tan) // muzzle
// Eyes: light surround, dark iris, white highlight.
fillCircle(img, 11, 12, 3, tan)
fillCircle(img, 21, 12, 3, tan)
fillCircle(img, 11, 12, 2, dark)
fillCircle(img, 21, 12, 2, dark)
fillCircle(img, 12, 11, 1, white)
fillCircle(img, 22, 11, 1, white)
// Nostrils.
fillCircle(img, 14, 21, 1, dark)
fillCircle(img, 18, 21, 1, dark)
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func fillCircle(img *image.RGBA, cx, cy, r int, c color.RGBA) {
for y := cy - r; y <= cy+r; y++ {
for x := cx - r; x <= cx+r; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy <= r*r {
img.SetRGBA(x, y, c)
}
}
}
}
func fillEllipse(img *image.RGBA, cx, cy, rx, ry int, c color.RGBA) {
for y := cy - ry; y <= cy+ry; y++ {
for x := cx - rx; x <= cx+rx; x++ {
dx := float64(x-cx) / float64(rx)
dy := float64(y-cy) / float64(ry)
if dx*dx+dy*dy <= 1.0 {
img.SetRGBA(x, y, c)
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build darwin
package main
import "golang.design/x/hotkey"
var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift,
"alt": hotkey.ModOption,
"cmd": hotkey.ModCmd,
"win": hotkey.ModCmd,
"super": hotkey.ModCmd,
}
+14
View File
@@ -0,0 +1,14 @@
//go:build linux
package main
import "golang.design/x/hotkey"
// Mod1 = Alt, Mod4 = Super/Win on most Linux desktop environments.
var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift,
"alt": hotkey.Mod1,
"win": hotkey.Mod4,
"super": hotkey.Mod4,
}
+88
View File
@@ -0,0 +1,88 @@
//go:build windows
package main
import (
"fmt"
"runtime"
"syscall"
"unsafe"
"golang.design/x/hotkey"
)
var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift,
"alt": hotkey.ModAlt,
"win": hotkey.ModWin,
"super": hotkey.ModWin,
}
var (
user32 = syscall.NewLazyDLL("user32.dll")
procRegisterHotKey = user32.NewProc("RegisterHotKey")
procGetMessageW = user32.NewProc("GetMessageW")
)
// winMsg mirrors the Win32 MSG struct.
type winMsg struct {
hwnd uintptr
message uint32
wParam uintptr
lParam uintptr
time uint32
ptX int32
ptY int32
}
func startHotkey(keybind string) (<-chan struct{}, error) {
mods, key, err := parseKeybind(keybind)
if err != nil {
return nil, err
}
// hotkey.Modifier and hotkey.Key values on Windows are the native Win32
// MOD_* and VK_* constants, so they can be passed directly to RegisterHotKey.
var modFlags uintptr
for _, m := range mods {
modFlags |= uintptr(m)
}
vk := uintptr(key)
ch := make(chan struct{}, 1)
ready := make(chan error, 1)
go func() {
runtime.LockOSThread()
// Intentionally no defer UnlockOSThread — this goroutine owns its OS
// thread for the lifetime of the app to keep the hotkey message queue alive.
ret, _, e := procRegisterHotKey.Call(0, 1, modFlags, vk)
if ret == 0 {
ready <- fmt.Errorf("RegisterHotKey: %w", e)
return
}
close(ready)
const wmHotkey = 0x0312
var msg winMsg
for {
r, _, _ := procGetMessageW.Call(
uintptr(unsafe.Pointer(&msg)), 0, wmHotkey, wmHotkey,
)
if r == 0 || r == ^uintptr(0) { // WM_QUIT or error
return
}
select {
case ch <- struct{}{}:
default:
}
}
}()
if err := <-ready; err != nil {
return nil, err
}
return ch, nil
}
+56 -40
View File
@@ -3,26 +3,31 @@ package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"fyne.io/systray"
"github.com/bwmarrin/discordgo"
"golang.design/x/hotkey"
"golang.design/x/mainthread"
)
type Config struct {
Token string `json:"token"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Keybind string `json:"keybind"`
Token string `json:"token"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Keybind string `json:"keybind"`
ExemptUsers []string `json:"exempt_users"`
}
func loadConfig() (*Config, error) {
exe, _ := os.Executable()
for _, dir := range []string{filepath.Dir(exe), "."} {
func loadConfig(dirs ...string) (*Config, error) {
if len(dirs) == 0 {
exe, _ := os.Executable()
dirs = []string{filepath.Dir(exe), "."}
}
for _, dir := range dirs {
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
continue
@@ -36,14 +41,6 @@ func loadConfig() (*Config, error) {
return nil, fmt.Errorf("config.json not found next to executable or in working directory")
}
var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift,
"alt": hotkey.ModAlt,
"win": hotkey.ModWin,
"super": hotkey.ModWin,
}
var keyMap = map[string]hotkey.Key{
"a": hotkey.KeyA, "b": hotkey.KeyB, "c": hotkey.KeyC, "d": hotkey.KeyD,
"e": hotkey.KeyE, "f": hotkey.KeyF, "g": hotkey.KeyG, "h": hotkey.KeyH,
@@ -103,25 +100,35 @@ func toggleMute(dg *discordgo.Session, cfg *Config, muted bool) {
log.Printf("guild not in state cache (is the bot in the server?): %v", err)
return
}
exempt := make(map[string]bool, len(cfg.ExemptUsers))
for _, id := range cfg.ExemptUsers {
exempt[id] = true
}
count := 0
for _, vs := range guild.VoiceStates {
if vs.ChannelID == cfg.ChannelID {
if vs.ChannelID == cfg.ChannelID && !exempt[vs.UserID] {
if err := dg.GuildMemberMute(cfg.GuildID, vs.UserID, muted); err != nil {
log.Printf(" warning: user %s: %v", vs.UserID, err)
log.Printf("warning: user %s: %v", vs.UserID, err)
}
count++
}
}
if count == 0 {
fmt.Println(" (no users found in channel)")
log.Println("(no users found in channel)")
} else {
fmt.Printf(" %d user(s) affected\n", count)
log.Printf("%d user(s) affected", count)
}
}
func main() { mainthread.Init(run) }
func run() {
// Log to file next to the executable so errors are visible even without a console.
if exe, err := os.Executable(); err == nil {
logPath := filepath.Join(filepath.Dir(exe), "discord-lobby-mute.log")
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); err == nil {
log.SetOutput(io.MultiWriter(os.Stderr, f))
}
}
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
@@ -144,29 +151,38 @@ func run() {
defer dg.Close()
<-ready
fmt.Println("Connected to Discord.")
log.Println("Connected to Discord.")
mods, key, err := parseKeybind(cfg.Keybind)
keydownCh, err := startHotkey(cfg.Keybind)
if err != nil {
log.Fatalf("keybind: %v", err)
}
hk := hotkey.New(mods, key)
if err := hk.Register(); err != nil {
log.Fatalf("register hotkey %q: %v", cfg.Keybind, err)
}
defer hk.Unregister()
fmt.Printf("Listening on %s — toggles server mute for channel %s\n", cfg.Keybind, cfg.ChannelID)
log.Printf("Listening on %s — toggles server mute for channel %s", cfg.Keybind, cfg.ChannelID)
muted := false
for range hk.Keydown() {
muted = !muted
if muted {
fmt.Print("Muting all users... ")
} else {
fmt.Print("Unmuting all users... ")
go func() {
muted := false
for range keydownCh {
muted = !muted
if muted {
log.Print("Muting all users...")
} else {
log.Print("Unmuting all users...")
}
toggleMute(dg, cfg, muted)
}
toggleMute(dg, cfg, muted)
}
}()
systray.Run(
func() {
systray.SetIcon(monkeyIcon())
systray.SetTooltip("Discord Lobby Mute — " + cfg.Keybind)
mQuit := systray.AddMenuItem("Quit", "Quit Discord Lobby Mute")
go func() {
<-mQuit.ClickedCh
systray.Quit()
}()
},
func() {},
)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package main
import "golang.design/x/mainthread"
func main() { mainthread.Init(run) }
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/bwmarrin/discordgo"
"golang.design/x/hotkey" //nolint:typecheck — key constants are platform-specific but names are identical
)
// --- parseKeybind ---
func TestParseKeybind(t *testing.T) {
tests := []struct {
input string
numMods int
key hotkey.Key
wantErr bool
}{
{"m", 0, hotkey.KeyM, false},
{"f9", 0, hotkey.KeyF9, false},
{"ctrl+m", 1, hotkey.KeyM, false},
{"ctrl+shift+m", 2, hotkey.KeyM, false},
{"ctrl+alt+delete", 2, hotkey.KeyDelete, false},
{"alt+f4", 1, hotkey.KeyF4, false},
{"CTRL+SHIFT+M", 2, hotkey.KeyM, false},
{"ctrl+enter", 1, hotkey.KeyReturn, false},
{"ctrl+return", 1, hotkey.KeyReturn, false},
{"ctrl+esc", 1, hotkey.KeyEscape, false},
{"ctrl+escape", 1, hotkey.KeyEscape, false},
{"win+l", 1, hotkey.KeyL, false},
{"super+l", 1, hotkey.KeyL, false},
{"ctrl+shift+badkey", 0, 0, true},
{"badmod+m", 0, 0, true},
{"", 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
mods, key, err := parseKeybind(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("parseKeybind(%q) error = %v, wantErr = %v", tt.input, err, tt.wantErr)
}
if tt.wantErr {
return
}
if len(mods) != tt.numMods {
t.Errorf("got %d modifier(s), want %d", len(mods), tt.numMods)
}
if key != tt.key {
t.Errorf("key = %v, want %v", key, tt.key)
}
})
}
}
// --- loadConfig ---
func TestLoadConfig(t *testing.T) {
t.Run("valid full config", func(t *testing.T) {
dir := t.TempDir()
want := Config{
Token: "tok", GuildID: "g1", ChannelID: "c1",
Keybind: "ctrl+shift+m", ExemptUsers: []string{"u1", "u2"},
}
write(t, dir, want)
got, err := loadConfig(dir)
if err != nil {
t.Fatal(err)
}
if got.Token != want.Token || got.GuildID != want.GuildID ||
got.ChannelID != want.ChannelID || got.Keybind != want.Keybind {
t.Errorf("got %+v, want %+v", got, want)
}
if len(got.ExemptUsers) != 2 || got.ExemptUsers[0] != "u1" || got.ExemptUsers[1] != "u2" {
t.Errorf("exempt_users = %v, want [u1 u2]", got.ExemptUsers)
}
})
t.Run("no exempt_users field", func(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "config.json"),
[]byte(`{"token":"t","guild_id":"g","channel_id":"c","keybind":"a"}`), 0644)
got, err := loadConfig(dir)
if err != nil {
t.Fatal(err)
}
if len(got.ExemptUsers) != 0 {
t.Errorf("expected empty exempt_users, got %v", got.ExemptUsers)
}
})
t.Run("invalid json", func(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{bad`), 0644)
if _, err := loadConfig(dir); err == nil {
t.Fatal("expected error for invalid JSON")
}
})
t.Run("file not found", func(t *testing.T) {
if _, err := loadConfig(t.TempDir()); err == nil {
t.Fatal("expected error when config.json is missing")
}
})
t.Run("falls back to second dir", func(t *testing.T) {
empty := t.TempDir()
dir := t.TempDir()
write(t, dir, Config{Token: "fallback"})
got, err := loadConfig(empty, dir)
if err != nil {
t.Fatal(err)
}
if got.Token != "fallback" {
t.Errorf("token = %q, want \"fallback\"", got.Token)
}
})
}
func write(t *testing.T, dir string, cfg Config) {
t.Helper()
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil {
t.Fatal(err)
}
}
// --- toggleMute ---
type muteCall struct {
userID string
muted bool
}
func TestToggleMute(t *testing.T) {
var mu sync.Mutex
var calls []muteCall
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// path: /guilds/{guildID}/members/{userID}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 4 {
w.WriteHeader(http.StatusBadRequest)
return
}
userID := parts[3]
var body struct {
Mute bool `json:"mute"`
}
json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
calls = append(calls, muteCall{userID, body.Mute})
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
orig := discordgo.EndpointGuilds
discordgo.EndpointGuilds = srv.URL + "/guilds/"
defer func() { discordgo.EndpointGuilds = orig }()
tests := []struct {
name string
voices []*discordgo.VoiceState
exempt []string
muted bool
wantHit []string
wantSkipped []string
}{
{
name: "mutes all users in channel",
voices: []*discordgo.VoiceState{
{UserID: "u1", ChannelID: "chan1"},
{UserID: "u2", ChannelID: "chan1"},
},
muted: true,
wantHit: []string{"u1", "u2"},
},
{
name: "ignores users in other channels",
voices: []*discordgo.VoiceState{
{UserID: "u1", ChannelID: "chan1"},
{UserID: "u2", ChannelID: "other"},
},
muted: true,
wantHit: []string{"u1"},
wantSkipped: []string{"u2"},
},
{
name: "skips exempt users",
voices: []*discordgo.VoiceState{
{UserID: "u1", ChannelID: "chan1"},
{UserID: "exempt", ChannelID: "chan1"},
},
exempt: []string{"exempt"},
muted: true,
wantHit: []string{"u1"},
wantSkipped: []string{"exempt"},
},
{
name: "sends mute=false when unmuting",
voices: []*discordgo.VoiceState{
{UserID: "u1", ChannelID: "chan1"},
},
muted: false,
wantHit: []string{"u1"},
},
{
name: "empty channel",
voices: []*discordgo.VoiceState{},
muted: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mu.Lock()
calls = nil
mu.Unlock()
dg, _ := discordgo.New("Bot test")
dg.State.GuildAdd(&discordgo.Guild{
ID: "guild1",
VoiceStates: tt.voices,
})
toggleMute(dg, &Config{
GuildID: "guild1",
ChannelID: "chan1",
ExemptUsers: tt.exempt,
}, tt.muted)
mu.Lock()
defer mu.Unlock()
hit := make(map[string]bool, len(calls))
for _, c := range calls {
hit[c.userID] = true
if c.muted != tt.muted {
t.Errorf("user %s: mute flag = %v, want %v", c.userID, c.muted, tt.muted)
}
}
for _, uid := range tt.wantHit {
if !hit[uid] {
t.Errorf("expected request for user %s, got none", uid)
}
}
for _, uid := range tt.wantSkipped {
if hit[uid] {
t.Errorf("user %s should have been skipped but was called", uid)
}
}
})
}
}
+5
View File
@@ -0,0 +1,5 @@
//go:build windows
package main
func main() { run() }