Compare commits
6 Commits
c4160603e9
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| fe74d20a3b | |||
| 72f3ddde5f | |||
| 6c56192141 | |||
| 75c113909d | |||
| 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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 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-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 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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/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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build !windows && !linux
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,354 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
dbus "github.com/godbus/dbus/v5"
|
||||||
|
"golang.design/x/hotkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
// modMap is used by parseKeybind (called from main.go and tests).
|
||||||
|
var modMap = map[string]hotkey.Modifier{
|
||||||
|
"ctrl": hotkey.ModCtrl,
|
||||||
|
"shift": hotkey.ModShift,
|
||||||
|
"alt": hotkey.Mod1,
|
||||||
|
"win": hotkey.Mod4,
|
||||||
|
"super": hotkey.Mod4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Portal (xdg-desktop-portal GlobalShortcuts) ──────────────────────────────
|
||||||
|
|
||||||
|
// toPortalTrigger converts "ctrl+alt+m" → "<Control><Alt>m".
|
||||||
|
func toPortalTrigger(keybind string) string {
|
||||||
|
parts := strings.Split(strings.ToLower(strings.TrimSpace(keybind)), "+")
|
||||||
|
mods := map[string]string{
|
||||||
|
"ctrl": "<Control>", "shift": "<Shift>",
|
||||||
|
"alt": "<Alt>", "win": "<Super>", "super": "<Super>",
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, p := range parts[:len(parts)-1] {
|
||||||
|
if m, ok := mods[strings.TrimSpace(p)]; ok {
|
||||||
|
sb.WriteString(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(strings.TrimSpace(parts[len(parts)-1]))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHotkeyPortal(keybind string) (<-chan struct{}, error) {
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||||
|
return nil, fmt.Errorf("not a Wayland session")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dbus.SessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("D-Bus session bus: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ":1.123" → "1_123"
|
||||||
|
sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_")
|
||||||
|
portal := conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")
|
||||||
|
pid := os.Getpid()
|
||||||
|
|
||||||
|
awaitResponse := func(token string) (map[string]dbus.Variant, error) {
|
||||||
|
reqPath := dbus.ObjectPath(fmt.Sprintf(
|
||||||
|
"/org/freedesktop/portal/desktop/request/%s/%s", sender, token))
|
||||||
|
ch := make(chan *dbus.Signal, 8)
|
||||||
|
conn.Signal(ch)
|
||||||
|
defer conn.RemoveSignal(ch)
|
||||||
|
opts := []dbus.MatchOption{
|
||||||
|
dbus.WithMatchInterface("org.freedesktop.portal.Request"),
|
||||||
|
dbus.WithMatchMember("Response"),
|
||||||
|
dbus.WithMatchObjectPath(reqPath),
|
||||||
|
}
|
||||||
|
if err := conn.AddMatchSignal(opts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.RemoveMatchSignal(opts...) //nolint:errcheck
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case sig := <-ch:
|
||||||
|
if sig == nil || sig.Path != reqPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code, _ := sig.Body[0].(uint32); code != 0 {
|
||||||
|
return nil, fmt.Errorf("portal declined (code %d)", code)
|
||||||
|
}
|
||||||
|
if results, ok := sig.Body[1].(map[string]dbus.Variant); ok {
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
return map[string]dbus.Variant{}, nil
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
return nil, fmt.Errorf("portal response timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession
|
||||||
|
reqToken1 := fmt.Sprintf("dlm_r1_%d", pid)
|
||||||
|
sessToken := fmt.Sprintf("dlm_s_%d", pid)
|
||||||
|
if err := portal.Call("org.freedesktop.portal.GlobalShortcuts.CreateSession", 0,
|
||||||
|
map[string]dbus.Variant{
|
||||||
|
"handle_token": dbus.MakeVariant(reqToken1),
|
||||||
|
"session_handle_token": dbus.MakeVariant(sessToken),
|
||||||
|
}).Err; err != nil {
|
||||||
|
return nil, fmt.Errorf("GlobalShortcuts portal unavailable: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := awaitResponse(reqToken1); err != nil {
|
||||||
|
return nil, fmt.Errorf("CreateSession: %w", err)
|
||||||
|
}
|
||||||
|
// xdg-desktop-portal-hyprland doesn't populate session_handle in the response
|
||||||
|
// (spec bug). The path is always predictable from sender + session token.
|
||||||
|
sessionHandle := dbus.ObjectPath(fmt.Sprintf(
|
||||||
|
"/org/freedesktop/portal/desktop/session/%s/%s", sender, sessToken))
|
||||||
|
|
||||||
|
// BindShortcuts — portal expects a(sa{sv}): array of (id, props) structs.
|
||||||
|
type shortcutEntry struct {
|
||||||
|
ID string
|
||||||
|
Props map[string]dbus.Variant
|
||||||
|
}
|
||||||
|
reqToken2 := fmt.Sprintf("dlm_r2_%d", pid)
|
||||||
|
shortcuts := []shortcutEntry{{
|
||||||
|
ID: "mute-toggle",
|
||||||
|
Props: map[string]dbus.Variant{
|
||||||
|
"description": dbus.MakeVariant("Toggle Discord Lobby Mute"),
|
||||||
|
"preferred_trigger": dbus.MakeVariant(toPortalTrigger(keybind)),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if err := portal.Call("org.freedesktop.portal.GlobalShortcuts.BindShortcuts", 0,
|
||||||
|
sessionHandle, shortcuts, "", map[string]dbus.Variant{
|
||||||
|
"handle_token": dbus.MakeVariant(reqToken2),
|
||||||
|
}).Err; err != nil {
|
||||||
|
return nil, fmt.Errorf("BindShortcuts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := awaitResponse(reqToken2); err != nil {
|
||||||
|
return nil, fmt.Errorf("BindShortcuts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activated signals are emitted from /org/freedesktop/portal/desktop,
|
||||||
|
// not from the session handle path.
|
||||||
|
const portalDesktopPath = dbus.ObjectPath("/org/freedesktop/portal/desktop")
|
||||||
|
out := make(chan struct{}, 1)
|
||||||
|
sigCh := make(chan *dbus.Signal, 8)
|
||||||
|
conn.Signal(sigCh)
|
||||||
|
conn.AddMatchSignal( //nolint:errcheck
|
||||||
|
dbus.WithMatchInterface("org.freedesktop.portal.GlobalShortcuts"),
|
||||||
|
dbus.WithMatchMember("Activated"),
|
||||||
|
dbus.WithMatchObjectPath(portalDesktopPath),
|
||||||
|
)
|
||||||
|
go func() {
|
||||||
|
for sig := range sigCh {
|
||||||
|
if sig.Name != "org.freedesktop.portal.GlobalShortcuts.Activated" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sig.Path != portalDesktopPath || len(sig.Body) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Body: (o session_handle, s shortcut_id, t timestamp, a{sv} options)
|
||||||
|
if sh, _ := sig.Body[0].(dbus.ObjectPath); sh != sessionHandle {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, _ := sig.Body[1].(string); id == "mute-toggle" {
|
||||||
|
select {
|
||||||
|
case out <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── evdev ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// evdev key codes from linux/input-event-codes.h
|
||||||
|
var evdevKeyMap = map[string]uint16{
|
||||||
|
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33, "g": 34, "h": 35,
|
||||||
|
"i": 23, "j": 36, "k": 37, "l": 38, "m": 50, "n": 49, "o": 24, "p": 25,
|
||||||
|
"q": 16, "r": 19, "s": 31, "t": 20, "u": 22, "v": 47, "w": 17, "x": 45,
|
||||||
|
"y": 21, "z": 44,
|
||||||
|
"0": 11, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10,
|
||||||
|
"f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, "f7": 65, "f8": 66,
|
||||||
|
"f9": 67, "f10": 68, "f11": 87, "f12": 88,
|
||||||
|
"f13": 183, "f14": 184, "f15": 185, "f16": 186,
|
||||||
|
"f17": 187, "f18": 188, "f19": 189, "f20": 190,
|
||||||
|
"space": 57, "return": 28, "enter": 28, "escape": 1, "esc": 1,
|
||||||
|
"tab": 15, "delete": 111, "left": 105, "right": 106, "up": 103, "down": 108,
|
||||||
|
}
|
||||||
|
|
||||||
|
// evdevModGroups maps each modifier to its [leftKey, rightKey] evdev codes.
|
||||||
|
var evdevModGroups = map[string][]uint16{
|
||||||
|
"ctrl": {29, 97},
|
||||||
|
"shift": {42, 54},
|
||||||
|
"alt": {56, 100},
|
||||||
|
"win": {125, 126},
|
||||||
|
"super": {125, 126},
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEvdevKeybind(s string) (modGroups [][]uint16, mainKey uint16, err error) {
|
||||||
|
parts := strings.Split(strings.ToLower(strings.TrimSpace(s)), "+")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return nil, 0, fmt.Errorf("empty keybind")
|
||||||
|
}
|
||||||
|
for _, p := range parts[:len(parts)-1] {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
g, ok := evdevModGroups[p]
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, fmt.Errorf("unknown modifier %q", p)
|
||||||
|
}
|
||||||
|
modGroups = append(modGroups, g)
|
||||||
|
}
|
||||||
|
keyStr := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
k, ok := evdevKeyMap[keyStr]
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, fmt.Errorf("unknown key %q", keyStr)
|
||||||
|
}
|
||||||
|
return modGroups, k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eviocgbitKey = EVIOCGBIT(EV_KEY=1, 32): 256-bit mask of supported key codes.
|
||||||
|
// _IOC(_IOC_READ=2, 'E', 0x21, 32) on Linux x86-64.
|
||||||
|
const eviocgbitKey = uintptr(0x80204521)
|
||||||
|
|
||||||
|
func hasKey(f *os.File, code uint16) bool {
|
||||||
|
if code >= 256 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var bits [32]byte
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), eviocgbitKey, uintptr(unsafe.Pointer(&bits[0])))
|
||||||
|
return errno == 0 && bits[code/8]&(1<<(code%8)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) bool {
|
||||||
|
if !held[mainKey] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, group := range modGroups {
|
||||||
|
found := false
|
||||||
|
for _, code := range group {
|
||||||
|
if held[code] {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
||||||
|
modGroups, mainKey, err := parseEvdevKeybind(keybind)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths, _ := filepath.Glob("/dev/input/event*")
|
||||||
|
var devices []*os.File
|
||||||
|
for _, p := range paths {
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasKey(f, mainKey) {
|
||||||
|
devices = append(devices, f)
|
||||||
|
} else {
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return nil, fmt.Errorf("no accessible keyboard devices in /dev/input (add user to 'input' group)")
|
||||||
|
}
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
var mu sync.Mutex
|
||||||
|
held := make(map[uint16]bool)
|
||||||
|
for _, dev := range devices {
|
||||||
|
go func(f *os.File) {
|
||||||
|
defer f.Close()
|
||||||
|
// input_event on 64-bit Linux: 8+8 bytes timeval + 2+2+4 = 24 bytes
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if binary.LittleEndian.Uint16(buf[16:18]) != 1 { // EV_KEY
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := binary.LittleEndian.Uint16(buf[18:20])
|
||||||
|
value := int32(binary.LittleEndian.Uint32(buf[20:24]))
|
||||||
|
mu.Lock()
|
||||||
|
switch value {
|
||||||
|
case 1:
|
||||||
|
held[code] = true
|
||||||
|
if comboActive(held, modGroups, mainKey) {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 0:
|
||||||
|
delete(held, code)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(dev)
|
||||||
|
}
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── startHotkey: portal → evdev → X11 ────────────────────────────────────────
|
||||||
|
|
||||||
|
func startHotkey(keybind string) (<-chan struct{}, error) {
|
||||||
|
// On Wayland, try xdg-desktop-portal GlobalShortcuts first.
|
||||||
|
// Works on GNOME, KDE, and Hyprland (via xdg-desktop-portal-hyprland)
|
||||||
|
// without any privilege setup.
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||||
|
ch, err := startHotkeyPortal(keybind)
|
||||||
|
if err == nil {
|
||||||
|
log.Println("hotkey backend: xdg-desktop-portal")
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
log.Printf("portal unavailable (%v), trying evdev", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evdev: works on both X11 and Wayland if the user is in the 'input'
|
||||||
|
// group or systemd-logind grants seat ACLs (default on GNOME/KDE).
|
||||||
|
ch, err := startHotkeyEvdev(keybind)
|
||||||
|
if err == nil {
|
||||||
|
log.Println("hotkey backend: evdev")
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
log.Printf("evdev unavailable (%v), falling back to X11", err)
|
||||||
|
|
||||||
|
// X11 XGrabKey: works on plain X11 sessions.
|
||||||
|
mods, key, err2 := parseKeybind(keybind)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, err2
|
||||||
|
}
|
||||||
|
hk := hotkey.New(mods, key)
|
||||||
|
if err2 := hk.Register(); err2 != nil {
|
||||||
|
return nil, fmt.Errorf("all hotkey backends failed — on Wayland without xdg-desktop-portal support, add user to 'input' group: %v", err2)
|
||||||
|
}
|
||||||
|
log.Println("hotkey backend: X11")
|
||||||
|
out := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for range hk.Keydown() {
|
||||||
|
out <- struct{}{}
|
||||||
|
}
|
||||||
|
close(out)
|
||||||
|
}()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,14 +3,16 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/systray"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"golang.design/x/hotkey"
|
"golang.design/x/hotkey"
|
||||||
"golang.design/x/mainthread"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -18,11 +20,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 +42,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,29 +101,54 @@ 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)
|
||||||
}
|
}
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
fmt.Println(" (no users found in channel)")
|
log.Println("(no users found in channel)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %d user(s) affected\n", count)
|
log.Printf("%d user(s) affected", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() { mainthread.Init(run) }
|
func setupLogging() {
|
||||||
|
// Try exe directory first, fall back to temp dir (e.g. if installed in Program Files).
|
||||||
|
var dirs []string
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dirs = append(dirs, filepath.Dir(exe))
|
||||||
|
}
|
||||||
|
dirs = append(dirs, os.TempDir())
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
logPath := filepath.Join(dir, "discord-lobby-mute.log")
|
||||||
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stderr, f))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() {
|
func run() {
|
||||||
|
setupLogging()
|
||||||
|
log.Println("starting")
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
log.Println("config loaded")
|
||||||
|
|
||||||
dg, err := discordgo.New("Bot " + cfg.Token)
|
dg, err := discordgo.New("Bot " + cfg.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,35 +161,49 @@ func run() {
|
|||||||
close(ready)
|
close(ready)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
log.Println("connecting to Discord...")
|
||||||
if err := dg.Open(); err != nil {
|
if err := dg.Open(); err != nil {
|
||||||
log.Fatalf("connect to Discord: %v", err)
|
log.Fatalf("connect to Discord: %v", err)
|
||||||
}
|
}
|
||||||
defer dg.Close()
|
defer dg.Close()
|
||||||
|
|
||||||
<-ready
|
select {
|
||||||
fmt.Println("Connected to Discord.")
|
case <-ready:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
mods, key, err := parseKeybind(cfg.Keybind)
|
log.Fatal("timed out waiting for Discord ready — check token and network connection")
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("keybind: %v", err)
|
|
||||||
}
|
}
|
||||||
|
log.Println("connected to Discord")
|
||||||
|
|
||||||
hk := hotkey.New(mods, key)
|
keydownCh, err := startHotkey(cfg.Keybind)
|
||||||
if err := hk.Register(); err != nil {
|
if err != nil {
|
||||||
log.Fatalf("register hotkey %q: %v", cfg.Keybind, err)
|
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)
|
||||||
|
|
||||||
|
go func() {
|
||||||
muted := false
|
muted := false
|
||||||
for range hk.Keydown() {
|
for range keydownCh {
|
||||||
muted = !muted
|
muted = !muted
|
||||||
if muted {
|
if muted {
|
||||||
fmt.Print("Muting all users... ")
|
log.Print("Muting all users...")
|
||||||
} else {
|
} else {
|
||||||
fmt.Print("Unmuting all users... ")
|
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() {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "golang.design/x/mainthread"
|
||||||
|
|
||||||
|
func main() { mainthread.Init(run) }
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func main() { run() }
|
||||||
Reference in New Issue
Block a user