diff --git a/README.md b/README.md index 8e816c5..5ba6181 100644 --- a/README.md +++ b/README.md @@ -34,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 @@ -61,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 ./... ``` diff --git a/keybind_darwin.go b/keybind_darwin.go new file mode 100644 index 0000000..5165bde --- /dev/null +++ b/keybind_darwin.go @@ -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, +} diff --git a/keybind_linux.go b/keybind_linux.go new file mode 100644 index 0000000..980297f --- /dev/null +++ b/keybind_linux.go @@ -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, +} diff --git a/keybind_windows.go b/keybind_windows.go new file mode 100644 index 0000000..d408356 --- /dev/null +++ b/keybind_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package main + +import "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, +} diff --git a/main.go b/main.go index 51deed0..069a0d8 100644 --- a/main.go +++ b/main.go @@ -21,9 +21,12 @@ type Config struct { 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 @@ -37,14 +40,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, diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ddf25b3 --- /dev/null +++ b/main_test.go @@ -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) + } + } + }) + } +}