commit db0c6e4cb49fc1f30c8a1d92ce64b8de5324ef1a Author: Richard Aasa Date: Mon Jun 1 17:48:59 2026 +0300 Initial commit Co-Authored-By: Claude Sonnet 4.6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..071f311 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# discord-lobby-mute + +Runs in the system tray as a console app. Press a configurable global hotkey to toggle server-mute for everyone in a Discord voice channel. + +## Setup + +1. Create a bot at https://discord.com/developers/applications + - Enable **Server Members Intent** under Privileged Gateway Intents + - Grant the **Mute Members** permission when inviting it to your server +2. Place `config.json` next to the `.exe` and fill in your values (see below) +3. Run `discord-lobby-mute.exe` + +## config.json + +```json +{ + "token": "YOUR_BOT_TOKEN", + "guild_id": "YOUR_SERVER_ID", + "channel_id": "YOUR_VOICE_CHANNEL_ID", + "keybind": "ctrl+shift+m" +} +``` + +To get IDs: enable **Developer Mode** in Discord settings, then right-click a server or channel and choose **Copy ID**. + +## Keybind format + +`modifier+modifier+key` — modifiers come first, key last. Case-insensitive. + +Examples: `ctrl+shift+m`, `alt+f9`, `ctrl+alt+delete` + +### Modifiers + +| Value | Key | +|---------|--------------| +| `ctrl` | Control | +| `shift` | Shift | +| `alt` | Alt | +| `win` | Windows key | +| `super` | Windows key | + +### Keys + +| Value | Key | +|-------------------|----------------------| +| `a` – `z` | Letter keys | +| `0` – `9` | Number row | +| `f1` – `f20` | Function keys | +| `space` | Space | +| `enter` / `return`| Enter / Return | +| `escape` / `esc` | Escape | +| `tab` | Tab | +| `delete` | Delete | +| `left` | Left arrow | +| `right` | Right arrow | +| `up` | Up arrow | +| `down` | Down arrow | + +## Build + +Cross-compile for Windows from Linux/macOS: + +```bash +GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o discord-lobby-mute.exe . +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..d4bf7ba --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "token": "YOUR_BOT_TOKEN_HERE", + "guild_id": "YOUR_SERVER_ID", + "channel_id": "YOUR_VOICE_CHANNEL_ID", + "keybind": "ctrl+shift+m" +} diff --git a/discord-lobby-mute.exe b/discord-lobby-mute.exe new file mode 100755 index 0000000..94fcb8e Binary files /dev/null and b/discord-lobby-mute.exe differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..896c51f --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module discord-lobby-mute + +go 1.22 + +require ( + github.com/bwmarrin/discordgo v0.29.0 + golang.design/x/hotkey v0.4.1 + golang.design/x/mainthread v0.3.0 +) + +require ( + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d4f70df --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +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/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= +golang.design/x/hotkey v0.4.1/go.mod h1:M8SGcwFYHnKRa83FpTFQoZvPO5vVT+kWPztFqTQKmXA= +golang.design/x/mainthread v0.3.0 h1:UwFus0lcPodNpMOGoQMe87jSFwbSsEY//CA7yVmu4j8= +golang.design/x/mainthread v0.3.0/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvHeu259L0= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +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/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3706a74 --- /dev/null +++ b/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "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"` +} + +func loadConfig() (*Config, error) { + exe, _ := os.Executable() + for _, dir := range []string{filepath.Dir(exe), "."} { + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + continue + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config.json: %w", err) + } + return &cfg, nil + } + 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, + "i": hotkey.KeyI, "j": hotkey.KeyJ, "k": hotkey.KeyK, "l": hotkey.KeyL, + "m": hotkey.KeyM, "n": hotkey.KeyN, "o": hotkey.KeyO, "p": hotkey.KeyP, + "q": hotkey.KeyQ, "r": hotkey.KeyR, "s": hotkey.KeyS, "t": hotkey.KeyT, + "u": hotkey.KeyU, "v": hotkey.KeyV, "w": hotkey.KeyW, "x": hotkey.KeyX, + "y": hotkey.KeyY, "z": hotkey.KeyZ, + "0": hotkey.Key0, "1": hotkey.Key1, "2": hotkey.Key2, "3": hotkey.Key3, + "4": hotkey.Key4, "5": hotkey.Key5, "6": hotkey.Key6, "7": hotkey.Key7, + "8": hotkey.Key8, "9": hotkey.Key9, + "f1": hotkey.KeyF1, "f2": hotkey.KeyF2, "f3": hotkey.KeyF3, + "f4": hotkey.KeyF4, "f5": hotkey.KeyF5, "f6": hotkey.KeyF6, + "f7": hotkey.KeyF7, "f8": hotkey.KeyF8, "f9": hotkey.KeyF9, + "f10": hotkey.KeyF10, "f11": hotkey.KeyF11, "f12": hotkey.KeyF12, + "f13": hotkey.KeyF13, "f14": hotkey.KeyF14, "f15": hotkey.KeyF15, + "f16": hotkey.KeyF16, "f17": hotkey.KeyF17, "f18": hotkey.KeyF18, + "f19": hotkey.KeyF19, "f20": hotkey.KeyF20, + "space": hotkey.KeySpace, + "return": hotkey.KeyReturn, + "enter": hotkey.KeyReturn, + "escape": hotkey.KeyEscape, + "esc": hotkey.KeyEscape, + "tab": hotkey.KeyTab, + "delete": hotkey.KeyDelete, + "left": hotkey.KeyLeft, + "right": hotkey.KeyRight, + "up": hotkey.KeyUp, + "down": hotkey.KeyDown, +} + +func parseKeybind(s string) ([]hotkey.Modifier, hotkey.Key, error) { + parts := strings.Split(strings.ToLower(strings.TrimSpace(s)), "+") + if len(parts) == 0 { + return nil, 0, fmt.Errorf("empty keybind") + } + var mods []hotkey.Modifier + for _, p := range parts[:len(parts)-1] { + p = strings.TrimSpace(p) + m, ok := modMap[p] + if !ok { + return nil, 0, fmt.Errorf("unknown modifier %q — valid: ctrl, shift, alt, win", p) + } + mods = append(mods, m) + } + keyStr := strings.TrimSpace(parts[len(parts)-1]) + k, ok := keyMap[keyStr] + if !ok { + return nil, 0, fmt.Errorf("unknown key %q — valid: a-z, 0-9, f1-f20, space, enter, escape, tab, delete, left, right, up, down", keyStr) + } + return mods, k, nil +} + +func toggleMute(dg *discordgo.Session, cfg *Config, muted bool) { + guild, err := dg.State.Guild(cfg.GuildID) + if err != nil { + log.Printf("guild not in state cache (is the bot in the server?): %v", err) + return + } + count := 0 + for _, vs := range guild.VoiceStates { + if vs.ChannelID == cfg.ChannelID { + if err := dg.GuildMemberMute(cfg.GuildID, vs.UserID, muted); err != nil { + log.Printf(" warning: user %s: %v", vs.UserID, err) + } + count++ + } + } + if count == 0 { + fmt.Println(" (no users found in channel)") + } else { + fmt.Printf(" %d user(s) affected\n", count) + } +} + +func main() { mainthread.Init(run) } + +func run() { + cfg, err := loadConfig() + if err != nil { + log.Fatal(err) + } + + dg, err := discordgo.New("Bot " + cfg.Token) + if err != nil { + log.Fatal(err) + } + dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates + + ready := make(chan struct{}) + dg.AddHandler(func(_ *discordgo.Session, _ *discordgo.Ready) { + close(ready) + }) + + if err := dg.Open(); err != nil { + log.Fatalf("connect to Discord: %v", err) + } + defer dg.Close() + + <-ready + fmt.Println("Connected to Discord.") + + mods, key, err := parseKeybind(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) + + muted := false + for range hk.Keydown() { + muted = !muted + if muted { + fmt.Print("Muting all users... ") + } else { + fmt.Print("Unmuting all users... ") + } + toggleMute(dg, cfg, muted) + } +}