Compare commits
2 Commits
c4160603e9
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f94c4f20b | |||
| cecd50361c |
@@ -17,11 +17,14 @@ Runs in the system tray as a console app. Press a configurable global hotkey to
|
|||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"guild_id": "YOUR_SERVER_ID",
|
"guild_id": "YOUR_SERVER_ID",
|
||||||
"channel_id": "YOUR_VOICE_CHANNEL_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
|
## Keybind format
|
||||||
|
|
||||||
@@ -31,13 +34,14 @@ Examples: `ctrl+shift+m`, `alt+f9`, `ctrl+alt+delete`
|
|||||||
|
|
||||||
### Modifiers
|
### Modifiers
|
||||||
|
|
||||||
| Value | Key |
|
| Value | Windows | Linux | macOS |
|
||||||
|---------|--------------|
|
|---------|-------------|-------------|-------------|
|
||||||
| `ctrl` | Control |
|
| `ctrl` | Control | Control | Control |
|
||||||
| `shift` | Shift |
|
| `shift` | Shift | Shift | Shift |
|
||||||
| `alt` | Alt |
|
| `alt` | Alt | Alt (Mod1) | Option |
|
||||||
| `win` | Windows key |
|
| `win` | Windows key | Super (Mod4)| Cmd |
|
||||||
| `super` | Windows key |
|
| `super` | Windows key | Super (Mod4)| Cmd |
|
||||||
|
| `cmd` | — | — | Cmd |
|
||||||
|
|
||||||
### Keys
|
### Keys
|
||||||
|
|
||||||
@@ -58,8 +62,23 @@ Examples: `ctrl+shift+m`, `alt+f9`, `ctrl+alt+delete`
|
|||||||
|
|
||||||
## Build
|
## 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
|
```bash
|
||||||
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o discord-lobby-mute.exe .
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -2,5 +2,6 @@
|
|||||||
"token": "YOUR_BOT_TOKEN_HERE",
|
"token": "YOUR_BOT_TOKEN_HERE",
|
||||||
"guild_id": "YOUR_SERVER_ID",
|
"guild_id": "YOUR_SERVER_ID",
|
||||||
"channel_id": "YOUR_VOICE_CHANNEL_ID",
|
"channel_id": "YOUR_VOICE_CHANNEL_ID",
|
||||||
"keybind": "ctrl+shift+m"
|
"keybind": "ctrl+shift+m",
|
||||||
|
"exempt_users": ["YOUR_DISCORD_USER_ID"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -18,11 +18,15 @@ type Config struct {
|
|||||||
GuildID string `json:"guild_id"`
|
GuildID string `json:"guild_id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
Keybind string `json:"keybind"`
|
Keybind string `json:"keybind"`
|
||||||
|
ExemptUsers []string `json:"exempt_users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() (*Config, error) {
|
func loadConfig(dirs ...string) (*Config, error) {
|
||||||
|
if len(dirs) == 0 {
|
||||||
exe, _ := os.Executable()
|
exe, _ := os.Executable()
|
||||||
for _, dir := range []string{filepath.Dir(exe), "."} {
|
dirs = []string{filepath.Dir(exe), "."}
|
||||||
|
}
|
||||||
|
for _, dir := range dirs {
|
||||||
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -36,14 +40,6 @@ func loadConfig() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("config.json not found next to executable or in working directory")
|
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{
|
var keyMap = map[string]hotkey.Key{
|
||||||
"a": hotkey.KeyA, "b": hotkey.KeyB, "c": hotkey.KeyC, "d": hotkey.KeyD,
|
"a": hotkey.KeyA, "b": hotkey.KeyB, "c": hotkey.KeyC, "d": hotkey.KeyD,
|
||||||
"e": hotkey.KeyE, "f": hotkey.KeyF, "g": hotkey.KeyG, "h": hotkey.KeyH,
|
"e": hotkey.KeyE, "f": hotkey.KeyF, "g": hotkey.KeyG, "h": hotkey.KeyH,
|
||||||
@@ -103,9 +99,13 @@ func toggleMute(dg *discordgo.Session, cfg *Config, muted bool) {
|
|||||||
log.Printf("guild not in state cache (is the bot in the server?): %v", err)
|
log.Printf("guild not in state cache (is the bot in the server?): %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
exempt := make(map[string]bool, len(cfg.ExemptUsers))
|
||||||
|
for _, id := range cfg.ExemptUsers {
|
||||||
|
exempt[id] = true
|
||||||
|
}
|
||||||
count := 0
|
count := 0
|
||||||
for _, vs := range guild.VoiceStates {
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+268
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user