Files
discord-lobby-mute/main.go
T
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

189 lines
5.2 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"fyne.io/systray"
"github.com/bwmarrin/discordgo"
"golang.design/x/hotkey"
)
type Config struct {
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(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
}
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 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
}
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 && !exempt[vs.UserID] {
if err := dg.GuildMemberMute(cfg.GuildID, vs.UserID, muted); err != nil {
log.Printf("warning: user %s: %v", vs.UserID, err)
}
count++
}
}
if count == 0 {
log.Println("(no users found in channel)")
} else {
log.Printf("%d user(s) affected", count)
}
}
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)
}
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
log.Println("Connected to Discord.")
keydownCh, err := startHotkey(cfg.Keybind)
if err != nil {
log.Fatalf("register hotkey %q: %v", cfg.Keybind, err)
}
log.Printf("Listening on %s — toggles server mute for channel %s", cfg.Keybind, cfg.ChannelID)
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)
}
}()
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() {},
)
}