feat: implement voice support

Closes #166
This commit is contained in:
ayn2op 2026-10-11 16:39:22 -04:00
parent be53bfd21a
commit d0f53d0722
25 changed files with 1073 additions and 97 deletions

View file

@ -36,13 +36,19 @@ jobs:
if: ${{ matrix.os != 'windows-11-arm' }}
run: |
go test -race -v ./...
go test -race -v -tags no_spoof_tls_fingerprint ./...
go test -race -v -tags no_spoof_tls_fingerprint,no_voice ./...
# -race is not supported on windows/arm64.
- name: Test
if: ${{ matrix.os == 'windows-11-arm' }}
run: |
go test -v ./...
go test -v -tags no_spoof_tls_fingerprint ./...
go test -v -tags no_spoof_tls_fingerprint,no_voice ./...
# Without voice, no C compiler is needed.
- name: Build without voice
run: go build -tags no_voice -o ${{ runner.temp }}/discordo .
env:
CGO_ENABLED: 0
- name: Build
run: go build -trimpath -ldflags=-s .

View file

@ -28,15 +28,22 @@ scoop install discordo
### Building from source
```bash
Requires [Go](https://go.dev/dl). Voice support requires a C compiler, and `libpulse` or `libasound` on Linux.
```sh
git clone https://github.com/ayn2op/discordo
cd discordo
go build .
```
#### Tags
- `no_voice` to build without voice support.
- `no_spoof_tls_fingerprint` to build without TLS fingerprint spoofing.
## Usage
### Password (UI, recommended)
### Password (UI)
1. Run the `discordo` executable with no arguments.
@ -72,8 +79,20 @@ The configuration file allows you to configure and customize the behavior, keybi
- Darwin: `$HOME/Library/Application Support/discordo/config.toml`
- Windows: `%AppData%/discordo/config.toml`
Use `-config-path` to change the path.
Discordo uses the default configuration if a configuration file is not found in the aforementioned path; however, the default configuration file is not written to the path. [The configuration reference can be found here](./docs/config.md).
## Logs
The log file is replaced on each run.
- Unix: `$XDG_CACHE_HOME/discordo/logs.txt` or `$HOME/.cache/discordo/logs.txt`
- Darwin: `$HOME/Library/Caches/discordo/logs.txt`
- Windows: `%LocalAppData%/discordo/logs.txt`
Use `-log-path` to change the path, and `-log-level` (`debug`, `info`, `warn`, or `error`) to set the verbosity of what is logged.
## License
Copyright (C) 2025-present ayn2op

View file

@ -406,6 +406,14 @@ Maximum height. 0 makes the list as tall as needed.
Default: `0`
# voice
## voice.sensitivity
How loud the microphone must be for what is said to be sent, in dB below the loudest it can be.
Default: `-40.0`
# icons
## icons.guild_category
@ -448,6 +456,16 @@ Default: `"≡ "`
Default: `"s-"`
## icons.voice_muted
Shown after the members of a voice channel who are muted or deafened.
Default: `" (muted)"`
## icons.voice_deafened
Default: `" (deafened)"`
# keybinds
Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys.
@ -468,6 +486,22 @@ Default: `"alt+m"`
Default: `"ctrl+k"`
## keybinds.toggle_mute
Start or stop sending what the microphone captures to the voice channel joined.
Default: `"alt+v"`
## keybinds.toggle_deafen
Stop or start playing what is said in the voice channel joined. Stopping also mutes.
Default: `"alt+d"`
## keybinds.leave_voice
Default: `"alt+l"`
## keybinds.toggle_help
Hide/show keybind help.
@ -616,6 +650,12 @@ Default: `"K"`
Default: `"J"`
### keybinds.guilds_tree.toggle_voice
Join the highlighted voice channel to listen, or leave it.
Default: `"v"`
## keybinds.members_tree
Only while focusing on the members tree.
@ -948,6 +988,18 @@ Default: `{ foreground = "red" }`
Default: `{ foreground = "gray" }`
### theme.guilds_tree.speaking_style
The style of whoever is speaking in the voice channel joined.
Default: `{ foreground = "green" }`
### theme.guilds_tree.voice_limit_style
The style of how full a voice channel that has a user limit is.
Default: `{ foreground = "dodgerblue" }`
## theme.members_tree
The status styles are those of the dot before each member.

8
go.mod
View file

@ -13,12 +13,13 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/alecthomas/chroma/v2 v2.27.0
github.com/andybalholm/brotli v1.2.6
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0
github.com/gen2brain/malgo v0.11.26
github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.20.1
github.com/mattn/go-shellwords v1.0.16
@ -27,6 +28,8 @@ require (
github.com/sahilm/fuzzy v0.1.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/thomas-vilte/dave-go v0.5.1
github.com/tphakala/go-opus v1.1.0
github.com/yuin/goldmark v1.8.6
github.com/zalando/go-keyring v0.2.8
golang.design/x/clipboard v0.11.0
@ -45,6 +48,7 @@ require (
github.com/cloudflare/circl v1.6.5 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dchest/jsmin v1.0.0 // indirect
github.com/disgoorg/godave v0.3.0 // indirect
github.com/dlclark/regexp2/v2 v2.8.4 // indirect
github.com/ebitengine/purego v0.11.1 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
@ -55,6 +59,8 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/thomas-vilte/mls-go v1.6.0 // indirect
github.com/tphakala/simd v1.11.0 // indirect
github.com/twmb/murmur3 v1.2.1 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.design/x/x11 v0.2.0 // indirect

28
go.sum
View file

@ -10,8 +10,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR/PyYMMKmUHlmAksxaXRFigaTscoxtw=
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE=
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa h1:uBfUPNrKAwrKweO+vzZSaI2mawNhRZ3QFZuH7JKenq4=
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4 h1:ViehR1nggJ7caDHIj9bp2Z8VqS00wur0xrOo1E6YNGs=
@ -38,10 +38,10 @@ github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQ
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI=
github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc=
github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4=
github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
github.com/dlclark/regexp2/v2 v2.8.4 h1:yfDDXTn4oxyLDC10/ieOBNDCCdIVWS9WbT6ddVXsPKI=
github.com/dlclark/regexp2/v2 v2.8.4/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
@ -50,6 +50,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.6.0 h1:jW20qS6Tm38KVDqLHq9KWSvyjNl00FbhbD8rIAnHBws=
github.com/gdamore/tcell/v3 v3.6.0/go.mod h1:CBZxkl2T+XxEyuBfOh4zaahqm27dngvhfBfCCZxJsgc=
github.com/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM=
github.com/gen2brain/malgo v0.11.26/go.mod h1:xLVG3ROA33Bzol1quF3e4ehqcFuqh8QK4B8T6LQUs/M=
github.com/godbus/dbus/v5 v5.2.3 h1:IQNSXULh6Nc4ZH8ETJ5Mdsj8loUFCWSBnsjmJkyjUB8=
github.com/godbus/dbus/v5 v5.2.3/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
@ -70,8 +72,6 @@ github.com/mattn/go-shellwords v1.0.16 h1:RRxAaRzU1YbzOSCj9NJqg2/VIbSWv0dnPoD3Ew
github.com/mattn/go-shellwords v1.0.16/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/ncruces/zenity v0.10.15 h1:UaHekkxwt7QjoZmMWtzhqSEtQT97/Cux6FDBWwfyuqk=
github.com/ncruces/zenity v0.10.15/go.mod h1:45d81tt+vF/UjBjlWE/vvE0oD0hWuHUQld4vPbA4VxE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 h1:m1yKMZwDSXkT5o2MKhd6ihdzb2dYb6eElNE04xjOSEY=
@ -86,10 +86,18 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
github.com/thomas-vilte/dave-go v0.5.1 h1:sc0roOie5Ayn+3eKOUjry9kbBPDvFQCF8t+gy/EOrW4=
github.com/thomas-vilte/dave-go v0.5.1/go.mod h1:h0UvVsCDECUe6W6dlSHEQTbx3TT13pGmHGngG0GXg1o=
github.com/thomas-vilte/mls-go v1.6.0 h1:HGxrRe0kRfEAXu7MtTemNzqGmC/758rfGkarhYGPOYg=
github.com/thomas-vilte/mls-go v1.6.0/go.mod h1:Cxl407ecAW94wwYLOycpQUp0UyfBk6dNmvs1hkw2yLg=
github.com/tphakala/go-opus v1.1.0 h1:SbXQjwI/ZYy6wPXd0fafLTurkUbAhBpaYnvXYtn8uIo=
github.com/tphakala/go-opus v1.1.0/go.mod h1:57hQHipntY2+xK7sycF4CYVSXDLW3V+AiX77iOJlojQ=
github.com/tphakala/simd v1.11.0 h1:3rtUU/6mO76R4rq3hQiEX+DC4yKc/twIGU62bkmIAYA=
github.com/tphakala/simd v1.11.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE=
github.com/twmb/murmur3 v1.2.1 h1:TJzZGygOc1VJ7jiBpy8m7KAAzm67zy1jvkUxxEjJp+g=
github.com/twmb/murmur3 v1.2.1/go.mod h1:LVqs/avIy0flspVtmBd4VSbogDepFl6Wkj0i0ToccHE=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@ -103,6 +111,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.design/x/clipboard v0.11.0 h1:kEKLOBEffOeIBNI7tyKnQzgSkKWX8XTHooVde1C8O7Q=
@ -160,5 +170,3 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -95,6 +95,11 @@ type (
Receive bool `toml:"receive"`
}
Voice struct {
// How loud the microphone must be for what is said to be sent, in dB below the loudest it can be.
Sensitivity float64 `toml:"sensitivity"`
}
Icons struct {
GuildCategory string `toml:"guild_category"`
GuildText string `toml:"guild_text"`
@ -108,6 +113,10 @@ type (
GuildAnnouncement string `toml:"guild_announcement"`
GuildForum string `toml:"guild_forum"`
GuildStore string `toml:"guild_store"`
// Shown after the members of a voice channel who are muted or deafened.
VoiceMuted string `toml:"voice_muted"`
VoiceDeafened string `toml:"voice_deafened"`
}
PickerConfig struct {
@ -206,6 +215,7 @@ type (
// Layout and behavior of the widgets.
UI UIConfig `toml:"ui"`
Voice Voice `toml:"voice"`
Icons Icons `toml:"icons"`
// Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys.
@ -254,7 +264,8 @@ func Default() Config {
MembersTree: pane,
Composer: ComposerConfig{MaxHeight: 10},
UI: defaultUI(),
UI: defaultUI(),
Voice: Voice{Sensitivity: -40},
Icons: Icons{
GuildText: "#",
GuildVoice: "♪ ",
@ -267,6 +278,9 @@ func Default() Config {
GuildAnnouncement: "a-",
GuildForum: "≡ ",
GuildStore: "s-",
VoiceMuted: " (muted)",
VoiceDeafened: " (deafened)",
},
Keybinds: defaultKeybinds(),

View file

@ -96,6 +96,8 @@ type GuildsTreeKeybinds struct {
// Highlight the previous or next unread guild or channel, wrapping around.
SelectPreviousUnread Keybind `toml:"select_previous_unread"`
SelectNextUnread Keybind `toml:"select_next_unread"`
// Join the highlighted voice channel to listen, or leave it.
ToggleVoice Keybind `toml:"toggle_voice"`
}
type MessagesListKeybinds struct {
@ -161,6 +163,11 @@ type Keybinds struct {
// Hide/show the members tree.
ToggleMembersTree Keybind `toml:"toggle_members_tree"`
ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"`
// Start or stop sending what the microphone captures to the voice channel joined.
ToggleMute Keybind `toml:"toggle_mute"`
// Stop or start playing what is said in the voice channel joined. Stopping also mutes.
ToggleDeafen Keybind `toml:"toggle_deafen"`
LeaveVoice Keybind `toml:"leave_voice"`
// Hide/show keybind help.
ToggleHelp Keybind `toml:"toggle_help"`
// Switch between compact and full keybind help.
@ -231,6 +238,9 @@ func defaultKeybinds() Keybinds {
ToggleGuildsTree: key("alt+g", "toggle guilds"),
ToggleMembersTree: key("alt+m", "toggle members"),
ToggleChannelsPicker: key("ctrl+k", "channels picker"),
ToggleMute: key("alt+v", "toggle mute"),
ToggleDeafen: key("alt+d", "toggle deafen"),
LeaveVoice: key("alt+l", "leave voice"),
ToggleHelp: key("alt+.", "toggle help"),
ToggleFullHelp: key("ctrl+.", "full help"),
Suspend: key("ctrl+z", "suspend"),
@ -252,6 +262,7 @@ func defaultKeybinds() Keybinds {
TreeKeybinds: tree,
SelectPreviousUnread: key("K", "prev unread"),
SelectNextUnread: key("J", "next unread"),
ToggleVoice: key("v", "join voice"),
},
MembersTree: tree,
MessagesList: MessagesListKeybinds{

View file

@ -205,7 +205,7 @@ type (
Footer StyleThemeConfig `toml:"footer"`
Border StyleThemeConfig `toml:"border"`
// The status styles are those of direct-message recipients.
GuildsTree TreeThemeConfig `toml:"guilds_tree"`
GuildsTree GuildsTreeThemeConfig `toml:"guilds_tree"`
// The status styles are those of the dot before each member.
MembersTree TreeThemeConfig `toml:"members_tree"`
ScrollBar ScrollBarThemeConfig `toml:"scroll_bar"`
@ -216,6 +216,14 @@ type (
}
)
type GuildsTreeThemeConfig struct {
TreeThemeConfig
// The style of whoever is speaking in the voice channel joined.
SpeakingStyle StyleWrapper `toml:"speaking_style"`
// The style of how full a voice channel that has a user limit is.
VoiceLimitStyle StyleWrapper `toml:"voice_limit_style"`
}
type TreeThemeConfig struct {
// The color of the lines that give the tree its shape.
GraphicsColor string `toml:"graphics_color"`
@ -257,10 +265,14 @@ func defaultTheme() ThemeConfig {
}
)
return ThemeConfig{
Title: focus,
Footer: focus,
Border: focus,
GuildsTree: tree,
Title: focus,
Footer: focus,
Border: focus,
GuildsTree: GuildsTreeThemeConfig{
TreeThemeConfig: tree,
SpeakingStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)},
VoiceLimitStyle: StyleWrapper{plain.Foreground(tcell.ColorDodgerBlue)},
},
MembersTree: tree,
ScrollBar: ScrollBarThemeConfig{TrackStyle: dim},
MessagesList: MessagesListThemeConfig{

View file

@ -17,7 +17,7 @@ func (m Model) selectCurrentKeybind() keybind.Keybind {
selectHelp := selectCurrent.Help()
selectDesc := selectHelp.Desc
if node := m.selectionState.CurrentNode(); node != nil {
if len(node.Children()) > 0 {
if m.expandable(node) {
if node.Expanded() {
selectDesc = "collapse"
} else {
@ -33,9 +33,25 @@ func (m Model) selectCurrentKeybind() keybind.Keybind {
return selectCurrent.WithHelp(selectHelp.Key, selectDesc)
}
// toggleVoiceKeybinds returns the keybind that joins or leaves the highlighted voice channel, if it is one.
func (m Model) toggleVoiceKeybinds() []keybind.Keybind {
channel := m.voiceChannel(m.selectionState.CurrentNode())
if channel == nil {
return nil
}
toggleVoice := m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind
if m.voiceSession.Status().ChannelID == channel.ID {
toggleVoice = toggleVoice.WithHelp(toggleVoice.Help().Key, "leave voice")
}
return []keybind.Keybind{toggleVoice}
}
func (m Model) ShortHelp() []keybind.Keybind {
cfg := m.cfg.Keybinds.GuildsTree
shortHelp := append(uitree.SelectHelp(m.root, m.selectionState.CurrentNode(), cfg.TreeKeybinds), m.selectCurrentKeybind())
shortHelp = append(shortHelp, m.toggleVoiceKeybinds()...)
if m.canCollapseParent(m.selectionState.CurrentNode()) {
shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind)
}
@ -52,7 +68,7 @@ func (m Model) FullHelp() [][]keybind.Keybind {
{cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind, cfg.ScrollLeft.Keybind, cfg.ScrollRight.Keybind},
selectGroup,
{cfg.SelectPreviousUnread.Keybind, cfg.SelectNextUnread.Keybind},
{cfg.YankID.Keybind},
append([]keybind.Keybind{cfg.YankID.Keybind}, m.toggleVoiceKeybinds()...),
}
}

View file

@ -1,18 +1,24 @@
package guildstree
import (
"cmp"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/ui"
uitree "github.com/ayn2op/discordo/internal/ui/tree"
"github.com/ayn2op/discordo/internal/voice"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/ningen/v3/states/read"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/richtext"
"github.com/ayn2op/tview/tree"
"github.com/ayn2op/tview/viewport"
"github.com/gdamore/tcell/v3"
@ -25,8 +31,9 @@ type Model struct {
selectionState tree.SelectionState
scrollState viewport.ScrollState
cfg *config.Config
state *ningen.State
cfg *config.Config
state *ningen.State
voiceSession *voice.Session
// nodes indexes the guild and channel nodes for frequent event handlers (read updates, picker navigation).
// It mirrors the current rendered tree and is rebuilt on READY before nodes are added.
@ -36,10 +43,11 @@ type Model struct {
func NewModel(cfg *config.Config, state *ningen.State) Model {
return Model{
root: tree.NewNode(""),
cfg: cfg,
state: state,
nodes: make(map[discord.Snowflake]*tree.Node),
root: tree.NewNode(""),
cfg: cfg,
state: state,
voiceSession: voice.New(state.State, cfg.Voice.Sensitivity),
nodes: make(map[discord.Snowflake]*tree.Node),
}
}
@ -57,13 +65,15 @@ func (m Model) View(focused bool) tview.Widget {
m.selectionState,
m.scrollState,
m.cfg,
m.cfg.Theme.GuildsTree,
m.cfg.Theme.GuildsTree.TreeThemeConfig,
m.cfg.UI.GuildsTree.Graphics,
m.cfg.Keybinds.GuildsTree.TreeKeybinds,
focused,
onChange,
onSelect,
).Title("Guilds")
).
Title("Guilds").
Footer(m.voiceFooter())
}
func (m *Model) reset() *tree.Node {
@ -238,11 +248,196 @@ func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) {
m.nodes[discord.Snowflake(channel.ID)] = channelNode
}
// showVoiceMembers lists who is in each voice channel of the guild under it.
func (m *Model) showVoiceMembers(guildID discord.GuildID) {
states, _ := m.state.Cabinet.VoiceStates(guildID)
slices.SortFunc(states, func(a, b discord.VoiceState) int { return cmp.Compare(a.UserID, b.UserID) })
channels, _ := m.state.Cabinet.Channels(guildID)
for _, channel := range channels {
node := m.nodes[discord.Snowflake(channel.ID)]
if node == nil || !isVoice(channel.Type) {
continue
}
// The members are replaced, so what is selected cannot stay one of them.
if slices.Contains(node.Children(), m.selectionState.CurrentNode()) {
m.selectionState.SetCurrentNode(node)
}
node.ClearChildren()
for _, state := range states {
if state.ChannelID == channel.ID {
node.AddChild(tree.NewNode(m.voiceMemberText(guildID, state)).SetReference(state.UserID))
}
}
// After its name, how full it is, if it can be.
line := node.Line()[:1]
if channel.VoiceUserLimit > 0 {
label := fmt.Sprintf("%d/%d", len(node.Children()), channel.VoiceUserLimit)
line = append(line, richtext.NewSegment(" ", tview.Style{}), richtext.NewSegment(label, m.cfg.Theme.GuildsTree.VoiceLimitStyle.Style))
}
node.SetLine(line)
}
m.styleVoiceMembers()
}
// voiceMemberText returns the name of the member that state is of, and whether they are muted or deafened.
func (m Model) voiceMemberText(guildID discord.GuildID, state discord.VoiceState) string {
text := state.UserID.String()
member := state.Member
if member == nil {
member, _ = m.state.Cabinet.Member(guildID, state.UserID)
}
if member != nil {
text = cmp.Or(member.Nick, member.User.DisplayName, member.User.Username)
}
return text + m.voiceMarkers(state)
}
// voiceMarkers returns what shows that the user that state is of is muted or deafened.
func (m Model) voiceMarkers(state discord.VoiceState) (markers string) {
if state.Mute || state.SelfMute {
markers += m.cfg.Icons.VoiceMuted
}
if state.Deaf || state.SelfDeaf {
markers += m.cfg.Icons.VoiceDeafened
}
return markers
}
// styleVoiceMembers shows who is speaking in the voice channel joined.
func (m Model) styleVoiceMembers() {
node := m.nodes[discord.Snowflake(m.voiceSession.Status().ChannelID)]
if node == nil {
return
}
for _, member := range node.Children() {
var style tview.Style
if userID, _ := member.Reference().(discord.UserID); m.voiceSession.Speaking(userID) {
style = m.cfg.Theme.GuildsTree.SpeakingStyle.Style
}
m.setNodeLineStyle(member, style)
}
}
// voiceFooter returns the state of the voice channel joined or being joined and of the user in it, or "" if there is none.
func (m Model) voiceFooter() string {
status := m.voiceSession.Status()
channel, err := m.state.Cabinet.Channel(status.ChannelID)
if err != nil {
return ""
}
name := ui.ChannelToString(*channel, m.cfg.Icons, m.state)
if me, _ := m.state.Cabinet.Me(); me != nil {
if state, _ := m.state.Cabinet.VoiceState(channel.GuildID, me.ID); state != nil {
name += m.voiceMarkers(*state)
}
}
switch {
case !status.Joined:
return "Joining " + name + "..."
case m.voiceSession.Latency() == 0:
return "In " + name
default:
return "In " + name + " (" + m.voiceSession.Latency().Round(time.Millisecond).String() + ")"
}
}
// voiceChannel returns the voice channel of node, or nil if it is not one.
func (m Model) voiceChannel(node *tree.Node) *discord.Channel {
if node == nil {
return nil
}
channelID, _ := node.Reference().(discord.ChannelID)
channel, err := m.state.Cabinet.Channel(channelID)
if err != nil || !isVoice(channel.Type) {
return nil
}
return channel
}
// joinable returns why the voice channel of node cannot be joined, or nil if it can be.
func (m Model) joinable(node *tree.Node, channel discord.Channel) error {
switch {
case !m.state.HasPermissions(channel.ID, discord.PermissionConnect):
return errors.New("you are not permitted to connect")
// Discord does not answer a request to join a full voice channel.
case channel.VoiceUserLimit > 0 && len(node.Children()) >= int(channel.VoiceUserLimit) && !m.state.HasPermissions(channel.ID, discord.PermissionMoveMembers):
return errors.New("it is full")
}
return nil
}
// toggleVoice joins the voice channel of node, or leaves it if it is joined.
func (m Model) toggleVoice(node *tree.Node) tview.Cmd {
channel := m.voiceChannel(node)
switch {
case channel == nil:
return nil
case m.voiceSession.Status().ChannelID == channel.ID:
return voiceCmd("leave "+channel.Name, m.voiceSession.Leave)
}
err := m.joinable(node, *channel)
return voiceCmd("join "+channel.Name, func() error {
if err != nil {
return err
}
return m.voiceSession.Join(channel.ID)
})
}
// toggleMute starts or stops sending what the user says to the voice channel joined, if they are permitted to speak in it without holding a key.
func (m Model) toggleMute() tview.Cmd {
return voiceCmd("toggle mute", func() error {
if channelID := m.voiceSession.Status().ChannelID; channelID.IsValid() && !m.state.HasPermissions(channelID, discord.PermissionSpeak|discord.PermissionUseVAD) {
return errors.New("you are not permitted to speak by voice activity")
}
return m.voiceSession.Toggle(false)
})
}
// voiceCmd returns a command that does f, and shows why it failed to do what if it did.
func voiceCmd(what string, f func() error) tview.Cmd {
return func() tview.Msg {
if err := f(); err != nil {
return ui.ErrorDialog(what, err)
}
return nil
}
}
// expandable reports whether selecting node expands or collapses it, which the members of a voice channel do not make it.
func (m Model) expandable(node *tree.Node) bool {
return len(node.Children()) > 0 && m.voiceChannel(node) == nil
}
func isVoice(t discord.ChannelType) bool {
return t == discord.GuildVoice || t == discord.GuildStageVoice
}
// setNodeLineStyle styles the name of node, which is the first segment of its line.
func (m Model) setNodeLineStyle(node *tree.Node, style tview.Style) {
line := node.Line()
for i := range line {
line[i].Style = style
}
line[0].Style = style
node.SetLine(line)
}
@ -327,6 +522,12 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
case *read.UpdateEvent:
m.refreshReadStyles(msg)
return m, nil
case *gateway.VoiceStateUpdateEvent:
m.showVoiceMembers(msg.GuildID)
return m, nil
case VoiceMsg:
m.styleVoiceMembers()
return m, m.Init()
case NavigateMsg:
return m, m.navigate(msg.ChannelID)
@ -369,6 +570,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
return m, nil
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind):
return m, uitree.YankID(m.selectionState.CurrentNode())
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind):
return m, m.toggleVoice(m.selectionState.CurrentNode())
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind):
return m, m.toggleMute()
case keybind.Matches(msg, m.cfg.Keybinds.ToggleDeafen.Keybind):
return m, voiceCmd("toggle deafen", func() error { return m.voiceSession.Toggle(true) })
case keybind.Matches(msg, m.cfg.Keybinds.LeaveVoice.Keybind):
return m, voiceCmd("leave voice", m.voiceSession.Leave)
}
}
return m, nil

View file

@ -25,6 +25,17 @@ type ChannelLoadedMsg struct {
type NavigateMsg struct{ ChannelID discord.ChannelID }
// VoiceMsg is sent when who is speaking or the state of the voice channel joined changes.
type VoiceMsg struct{}
// Init waits for that.
func (m Model) Init() tview.Cmd {
return func() tview.Msg {
<-m.voiceSession.Updates()
return VoiceMsg{}
}
}
func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd {
channel, err := m.state.Cabinet.Channel(channelID)
if err != nil {
@ -45,7 +56,7 @@ func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd {
}
func (m Model) selectNode(node *tree.Node) tview.Cmd {
if len(node.Children()) != 0 {
if m.expandable(node) {
node.SetExpanded(!node.Expanded())
return nil
}
@ -60,6 +71,7 @@ func (m Model) selectNode(node *tree.Node) tview.Cmd {
}
ui.SortGuildChannels(channels)
m.createChannelNodes(node, channels)
m.showVoiceMembers(ref)
node.Expand()
case discord.ChannelID:
channel, err := m.state.Cabinet.Channel(ref)

View file

@ -57,6 +57,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind {
m.focusHelp(),
{cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind},
{cfg.ToggleGuildsTree.Keybind, cfg.ToggleMembersTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
{cfg.ToggleMute.Keybind, cfg.ToggleDeafen.Keybind, cfg.LeaveVoice.Keybind},
{cfg.Logout.Keybind},
}
}

View file

@ -1034,11 +1034,6 @@ func messageURLs(msg discord.Message) []string {
return urls
}
func attachmentErr(what string, err error) tview.Msg {
slog.Error("failed to "+what, "err", err)
return ui.ModalMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []ui.ModalButton{{Label: "OK"}}}
}
func downloadAttachment(attachment discord.Attachment, destination string) error {
resp, err := http.Get(attachment.URL)
if err != nil {

View file

@ -201,10 +201,10 @@ func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd {
func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
return ui.ShowModal(
return ui.ShowDialog(
"This attachment type is not allowed and may be unsafe. Continue anyway?",
ui.ModalButton{Label: "No"},
ui.ModalButton{Label: "Yes", Cmd: action},
ui.DialogButton{Label: "No"},
ui.DialogButton{Label: "Yes", Cmd: action},
)
}
return action
@ -222,12 +222,12 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
dir := filepath.Join(consts.CacheDir(), "attachments")
if err := os.MkdirAll(dir, 0o700); err != nil {
return attachmentErr("create attachments directory", err)
return ui.ErrorDialog("create attachments directory", err)
}
file, err := os.CreateTemp(dir, "attachment-*"+extension)
if err != nil {
return attachmentErr("create attachment file", err)
return ui.ErrorDialog("create attachment file", err)
}
defer file.Close()
@ -235,9 +235,9 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
if err := downloadAttachment(attachment, path); err != nil {
os.Remove(path)
return attachmentErr("download attachment", err)
return ui.ErrorDialog("download attachment", err)
} else if err := open.Start(path); err != nil {
return attachmentErr("open attachment file", err)
return ui.ErrorDialog("open attachment file", err)
}
return nil
@ -251,11 +251,11 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd {
return nil
}
if err != nil {
return attachmentErr("select attachment destination", err)
return ui.ErrorDialog("select attachment destination", err)
}
if err := downloadAttachment(attachment, destination); err != nil {
return attachmentErr("download attachment", err)
return ui.ErrorDialog("download attachment", err)
}
return nil
}
@ -264,7 +264,7 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd {
func openURL(url string) tview.Cmd {
return func() tview.Msg {
if err := open.Start(url); err != nil {
return attachmentErr("open URL", err)
return ui.ErrorDialog("open URL", err)
}
return nil
}
@ -306,10 +306,10 @@ func (m Model) confirmDelete() tview.Cmd {
return nil
}
message := *selectedMessage
return ui.ShowModal(
return ui.ShowDialog(
"Are you sure you want to delete this message?",
ui.ModalButton{Label: "Yes", Cmd: m.requestDelete(message)},
ui.ModalButton{Label: "No"},
ui.DialogButton{Label: "Yes", Cmd: m.requestDelete(message)},
ui.DialogButton{Label: "No"},
)
}

View file

@ -190,7 +190,7 @@ func (m *Model) cycleFocus(step pane) {
}
func (m Model) Init() tview.Cmd {
return tview.Batch(openState(m.state), listen(m.events), tview.RequestTerminalInfo())
return tview.Batch(openState(m.state), listen(m.events), m.guildsTree.Init(), tview.RequestTerminalInfo())
}
// Update returns m changed in response to msg and a command to run, or nil.
@ -198,6 +198,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
switch msg := msg.(type) {
case gateway.Event:
return m, tview.Batch(m.applyEvent(msg), listen(m.events))
case guildstree.VoiceMsg:
return m, m.updatePane(paneGuildsTree, msg)
case tview.FocusMsg:
m.windowUnfocused = !msg.Focused
return m, nil
@ -290,6 +292,9 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind):
m.togglePicker()
return m, nil
// The guilds tree is what the voice channel joined is in.
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind, m.cfg.Keybinds.ToggleDeafen.Keybind, m.cfg.Keybinds.LeaveVoice.Keybind):
return m, m.updatePane(paneGuildsTree, msg)
case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind):
return m, tview.Sequence(closeState(m.state), logout())

View file

@ -138,6 +138,8 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
case *read.UpdateEvent:
return m.updatePane(paneGuildsTree, event)
case *gateway.VoiceStateUpdateEvent:
return m.updatePane(paneGuildsTree, event)
}
return nil
}

28
internal/ui/dialog.go Normal file
View file

@ -0,0 +1,28 @@
package ui
import (
"log/slog"
"github.com/ayn2op/tview"
)
type DialogButton struct {
Label string
Cmd tview.Cmd
KeepOpen bool
}
type DialogMsg struct {
Text string
Buttons []DialogButton
}
func ShowDialog(text string, buttons ...DialogButton) tview.Cmd {
return func() tview.Msg { return DialogMsg{Text: text, Buttons: buttons} }
}
// ErrorDialog logs that what failed with err and returns the dialog that says so.
func ErrorDialog(what string, err error) DialogMsg {
slog.Error("failed to "+what, "err", err)
return DialogMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []DialogButton{{Label: "OK"}}}
}

View file

@ -24,8 +24,8 @@ type selectTabMsg int
func showErrorDialog(err error) tview.Cmd {
slog.Error("failed to login", "err", err)
message := err.Error()
return ui.ShowModal(message,
ui.ModalButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true},
ui.ModalButton{Label: "Close"},
return ui.ShowDialog(message,
ui.DialogButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true},
ui.DialogButton{Label: "Close"},
)
}

View file

@ -1,18 +0,0 @@
package ui
import "github.com/ayn2op/tview"
type ModalButton struct {
Label string
Cmd tview.Cmd
KeepOpen bool
}
type ModalMsg struct {
Text string
Buttons []ModalButton
}
func ShowModal(text string, buttons ...ModalButton) tview.Cmd {
return func() tview.Msg { return ModalMsg{Text: text, Buttons: buttons} }
}

View file

@ -42,8 +42,8 @@ type Model struct {
chat chat.Model
// helpVisible reports whether help is shown below the inner model, and helpShowAll whether it shows every keybind.
helpVisible, helpShowAll bool
// modal is the request shown in a dialog, or nil when none is open, and dialogFocus its focused button.
modal *ui.ModalMsg
// dialog is the dialog shown, or nil when none is open, and dialogFocus its focused button.
dialog *ui.DialogMsg
dialogFocus int
// resize is the last size of the window, or nil before the first, kept for the models shown later.
resize tview.ResizeMsg
@ -91,20 +91,20 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.showLogin(),
deleteToken(),
)
case ui.ModalMsg:
if m.modal == nil {
m.modal, m.dialogFocus = &msg, 0
case ui.DialogMsg:
if m.dialog == nil {
m.dialog, m.dialogFocus = &msg, 0
}
return m, nil
case dialogFocusMsg:
m.dialogFocus = int(msg)
return m, nil
case dialogDoneMsg:
return m, m.finishModal(int(msg))
return m, m.finishDialog(int(msg))
case tview.KeyMsg:
// The dialog takes all input while it is open, so nothing behind it reacts.
if m.modal != nil {
if m.dialog != nil {
return m, nil
}
switch {
@ -119,10 +119,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
case keybind.Matches(msg, m.cfg.Keybinds.Suspend.Keybind):
return m, suspend()
case keybind.Matches(msg, m.cfg.Keybinds.Quit.Keybind):
return m, tview.Batch(m.updateScreen(chat.QuitMsg{}), tview.Quit())
return m, tview.Sequence(m.updateScreen(chat.QuitMsg{}), tview.Quit())
}
case tview.MouseMsg, tview.PasteMsg:
if m.modal != nil {
if m.dialog != nil {
return m, nil
}
case tview.ResizeMsg:
@ -149,7 +149,7 @@ func (m Model) helpHeight() int {
return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height
}
// View shows the inner model above help, with the modal dialog on top of both when one is open.
// View shows the inner model above help, with the dialog on top of both when one is open.
func (m Model) View() tview.Widget {
var innerView, helpView tview.Widget
switch m.screen {
@ -163,7 +163,7 @@ func (m Model) View() tview.Widget {
helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1)))
}
content := column.New(innerView, helpView)
if m.modal == nil {
if m.dialog == nil {
return content
}
return stack.New(inert.New(content), backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style), m.dialogView())
@ -187,15 +187,15 @@ type (
dialogDoneMsg int
)
// dialogView returns the dialog for the open modal request.
// dialogView returns the view of the open dialog.
func (m Model) dialogView() tview.Widget {
labels := make([]string, len(m.modal.Buttons))
for i, button := range m.modal.Buttons {
labels := make([]string, len(m.dialog.Buttons))
for i, button := range m.dialog.Buttons {
labels[i] = button.Label
}
style := m.cfg.Theme.Dialog.Style.Style
d := dialog.New().
Text(m.modal.Text).
Text(m.dialog.Text).
Buttons(labels...).
Focus(m.dialogFocus).
ButtonStyle(style).

View file

@ -78,20 +78,20 @@ func (m *Model) showChat(token string) tview.Cmd {
return m.show(init)
}
// show closes any open modal after the shown model changed and returns what it needs done, with init its first command.
// show closes any open dialog after the shown model changed and returns what it needs done, with init its first command.
func (m *Model) show(init tview.Cmd) tview.Cmd {
m.modal = nil
m.dialog = nil
return tview.Batch(tview.SetTitle(consts.Name), init)
}
func (m *Model) finishModal(index int) tview.Cmd {
if m.modal == nil || index < 0 || index >= len(m.modal.Buttons) {
m.modal = nil
func (m *Model) finishDialog(index int) tview.Cmd {
if m.dialog == nil || index < 0 || index >= len(m.dialog.Buttons) {
m.dialog = nil
return nil
}
button := m.modal.Buttons[index]
button := m.dialog.Buttons[index]
if !button.KeepOpen {
m.modal = nil
m.dialog = nil
}
return button.Cmd
}

440
internal/voice/session.go Normal file
View file

@ -0,0 +1,440 @@
//go:build !no_voice
package voice
import (
"bytes"
"context"
"encoding/binary"
"errors"
"log/slog"
"math"
"sync"
"sync/atomic"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/ws"
"github.com/ayn2op/arikawa/v3/voice"
"github.com/ayn2op/arikawa/v3/voice/udp"
"github.com/ayn2op/arikawa/v3/voice/voicegateway"
"github.com/gen2brain/malgo"
dave "github.com/thomas-vilte/dave-go/session"
"github.com/tphakala/go-opus/opus"
)
const (
timeout = 15 * time.Second
// hangover is how many frames are still sent after the last that was loud enough, for the ends of words not to be cut off.
hangover = 15
)
// audio is what the audio devices are opened with. It is of the first backend that works but the null one, which malgo would otherwise silently use when the system has no audio library.
var audio = sync.OnceValues(func() (*malgo.AllocatedContext, error) {
return malgo.InitContext([]malgo.Backend{
malgo.BackendWasapi, malgo.BackendDsound, malgo.BackendWinmm,
malgo.BackendCoreaudio,
malgo.BackendSndio, malgo.BackendAudio4, malgo.BackendOss,
malgo.BackendPulseaudio, malgo.BackendAlsa, malgo.BackendJack,
}, malgo.ContextConfig{}, nil)
})
// startDevice starts a playback or capture device, which calls data with what to fill or with what was captured.
func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo.Device, error) {
ctx, err := audio()
if err != nil {
return nil, err
}
config := malgo.DefaultDeviceConfig(kind)
config.SampleRate = sampleRate
config.PeriodSizeInMilliseconds = 10
config.Playback.Format, config.Playback.Channels = malgo.FormatS16, channels
config.Capture.Format, config.Capture.Channels = malgo.FormatS16, channels
device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{
Data: func(output, input []byte, _ uint32) { data(output, input) },
})
if err != nil {
return nil, err
}
if err := device.Start(); err != nil {
device.Uninit()
return nil, err
}
return device, nil
}
// Session is the voice channel that the user is in, if any.
type Session struct {
state *state.State
// threshold is how loud a sample of the microphone must be for what is said to be sent.
threshold int
// updates is sent to when the status or who is speaking changes.
updates chan struct{}
// mic is sent what the microphone captures.
mic chan []byte
// mu is held while joining, leaving and muting, which are not done while another is.
mu sync.Mutex
// session is nil until the first voice channel is joined.
session atomic.Pointer[voice.Session]
// playback plays while a voice channel is joined, and capture captures while the user is not muted in it.
playback, capture *malgo.Device
stateMu sync.Mutex
status Status
speaking map[discord.UserID]bool
speakers map[uint32]*speaker
}
// New returns a Session that sends what the microphone captures louder than sensitivity, in dB.
func New(state *state.State, sensitivity float64) *Session {
s := &Session{
state: state,
threshold: int(math.Pow(10, sensitivity/20) * math.MaxInt16),
updates: make(chan struct{}, 1),
mic: make(chan []byte, 4),
speaking: make(map[discord.UserID]bool),
speakers: make(map[uint32]*speaker),
}
state.AddHandler(s.follow)
return s
}
// follow keeps up with the user being moved to another voice channel or disconnected, by someone else or by connecting from another client.
func (s *Session) follow(event *gateway.VoiceStateUpdateEvent) {
me, _ := s.state.Cabinet.Me()
if me == nil || event.UserID != me.ID {
return
}
s.mu.Lock()
defer s.mu.Unlock()
status, own := s.Status(), event.SessionID == s.state.Ready().SessionID
switch {
case !status.Joined || own && event.ChannelID == status.ChannelID:
case own && event.ChannelID.IsValid():
s.setStatus(Status{ChannelID: event.ChannelID, Joined: true})
default:
s.left()
}
}
// Updates is sent to when the status or who is speaking changes.
func (s *Session) Updates() <-chan struct{} {
return s.updates
}
func (s *Session) Status() Status {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return s.status
}
// Speaking reports whether the user is speaking in the voice channel joined.
func (s *Session) Speaking(userID discord.UserID) bool {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return s.speaking[userID]
}
// Latency returns how long the voice server of the voice channel joined takes to answer, or 0 if it is not known.
func (s *Session) Latency() time.Duration {
if session := s.session.Load(); session != nil {
return session.Latency()
}
return 0
}
// update changes the state with f and tells Updates if f reports that it did.
func (s *Session) update(f func() bool) {
s.stateMu.Lock()
changed := f()
s.stateMu.Unlock()
if changed {
select {
case s.updates <- struct{}{}:
default:
}
}
}
// setStatus changes the status, which whoever spoke and was heard does not outlast.
func (s *Session) setStatus(status Status) {
s.update(func() bool {
s.status = status
clear(s.speaking)
clear(s.speakers)
return true
})
}
func (s *Session) setSpeaking(userID discord.UserID, speaking bool) {
s.update(func() bool {
changed := s.speaking[userID] != speaking
s.speaking[userID] = speaking
return changed
})
}
// setDevice starts device, which then calls data, if on, and stops it otherwise.
func setDevice(device **malgo.Device, on bool, kind malgo.DeviceType, data func(output, input []byte)) (err error) {
switch {
case on && *device == nil:
*device, err = startDevice(kind, data)
case !on && *device != nil:
(*device).Uninit()
*device = nil
}
return err
}
// setDevices captures what the user says unless mute, and plays what is said unless deafen.
func (s *Session) setDevices(mute, deafen bool) error {
err := errors.Join(
setDevice(&s.capture, !mute, malgo.Capture, func(_, input []byte) { s.queue(bytes.Clone(input)) }),
setDevice(&s.playback, !deafen, malgo.Playback, s.mix),
)
if mute {
// Silence makes what is being said end.
s.queue(make([]byte, 2*hangover*frameBytes))
}
return err
}
// left stops the devices, in no voice channel.
func (s *Session) left() {
s.setDevices(true, true)
s.setStatus(Status{})
}
// Join joins the voice channel, muted, leaving the one that the user is in.
func (s *Session) Join(channelID discord.ChannelID) (err error) {
if !s.mu.TryLock() {
return nil
}
defer s.mu.Unlock()
s.setStatus(Status{ChannelID: channelID})
defer func() {
// Discord may have the user in the voice channel regardless, and joining it again would then do nothing.
if err != nil {
s.leave()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
session := s.session.Load()
if session == nil {
if session, err = voice.NewSession(s.state, s.state.Handler); err != nil {
return err
}
session.DAVE = dave.CreateFunc()
session.AddHandler(func(event *ws.BackgroundErrorEvent) { slog.Debug("voice gateway error", "err", event.Err) })
s.session.Store(session)
go s.receive(session)
go s.send(session)
}
if err := s.setDevices(true, false); err != nil {
return err
}
if err := session.JoinChannel(ctx, channelID, true, false); err != nil {
return err
}
// The voice server sends nothing before it is told this.
if err := session.Speaking(ctx, voicegateway.Microphone); err != nil {
return err
}
s.setStatus(Status{ChannelID: channelID, Joined: true})
return nil
}
// Leave leaves the voice channel that the user is in, if any.
func (s *Session) Leave() error {
if !s.mu.TryLock() {
return nil
}
defer s.mu.Unlock()
return s.leave()
}
func (s *Session) leave() error {
defer s.left()
session := s.session.Load()
if session == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return session.Leave(ctx)
}
// Toggle mutes or unmutes the user in the voice channel joined, if any, or deafens or undeafens them if deafen. Deafening also mutes, and unmuting also undeafens.
func (s *Session) Toggle(deafen bool) error {
if !s.mu.TryLock() {
return nil
}
defer s.mu.Unlock()
status := s.Status()
channel, err := s.state.Cabinet.Channel(status.ChannelID)
if err != nil || !status.Joined {
return nil
}
mute, deafen := deafen || s.capture != nil, deafen && s.playback != nil
if err := s.setDevices(mute, deafen); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return s.state.SendGateway(ctx, &gateway.UpdateVoiceStateCommand{
GuildID: channel.GuildID,
ChannelID: channel.ID,
SelfMute: mute,
SelfDeaf: deafen,
})
}
// queue queues what was captured to be sent, in place of the oldest if what is queued is not being sent fast enough, for what is said not to be sent ever later.
func (s *Session) queue(pcm []byte) {
for {
select {
case s.mic <- pcm:
return
default:
}
select {
case <-s.mic:
default:
}
}
}
// mix fills output with what everyone says. It is what the playback device calls.
func (s *Session) mix(output, _ []byte) {
s.stateMu.Lock()
defer s.stateMu.Unlock()
for _, speaker := range s.speakers {
speaker.mix(output)
}
}
// receive buffers what each user says in the voice channels that session joins.
func (s *Session) receive(session *voice.Session) {
for {
packet, err := session.ReadPacket()
if errors.Is(err, udp.ErrDecryptionFailed) {
continue
}
if err != nil {
// In no voice channel, or joining one.
time.Sleep(100 * time.Millisecond)
continue
}
s.stateMu.Lock()
speaker := s.speakers[packet.SSRC()]
if speaker == nil {
if speaker, err = newSpeaker(packet.Sequence()); err != nil {
s.stateMu.Unlock()
slog.Error("failed to create Opus decoder", "err", err)
return
}
s.speakers[packet.SSRC()] = speaker
}
s.stateMu.Unlock()
if err := speaker.write(packet.Sequence(), packet.Opus); err != nil {
slog.Debug("failed to decode voice packet", "err", err, "ssrc", packet.SSRC())
}
s.setSpeaking(session.User(packet.SSRC()), string(packet.Opus) != voice.Silence)
}
}
// send sends what the microphone captures to the voice channels that session joins, while it is loud enough.
func (s *Session) send(session *voice.Session) {
encoder, err := opus.NewEncoder(opus.EncoderConfig{SampleRate: sampleRate, Channels: channels})
if err != nil {
slog.Error("failed to create Opus encoder", "err", err)
return
}
me, _ := s.state.Cabinet.Me()
var (
pcm []byte
samples = make([]int16, frameSamples)
packet = make([]byte, 4000)
silence = []byte(voice.Silence)
// quiet is how many frames ago the last that was loud enough was.
quiet = hangover
)
for captured := range s.mic {
for pcm = append(pcm, captured...); len(pcm) >= frameBytes; pcm = pcm[frameBytes:] {
quiet++
for i := range samples {
samples[i] = int16(binary.LittleEndian.Uint16(pcm[2*i:]))
if int(samples[i]) >= s.threshold || -int(samples[i]) >= s.threshold {
quiet = 0
}
}
var err error
switch {
case quiet < hangover:
var n int
if n, err = encoder.Encode(samples, packet); err == nil {
_, err = session.Write(packet[:n])
}
case quiet < hangover+5:
// A few frames of silence are for what was said not to be interpolated with what is said next.
_, err = session.Write(silence)
}
if err != nil {
slog.Debug("failed to send voice packet", "err", err)
}
if me != nil {
s.setSpeaking(me.ID, quiet < hangover)
}
}
}
}

View file

@ -0,0 +1,27 @@
//go:build no_voice
package voice
import (
"errors"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/state"
)
// Session joins no voice channel.
type Session struct{}
func New(*state.State, float64) *Session { return &Session{} }
func (*Session) Updates() <-chan struct{} { return nil }
func (*Session) Status() Status { return Status{} }
func (*Session) Speaking(discord.UserID) bool { return false }
func (*Session) Latency() time.Duration { return 0 }
func (*Session) Leave() error { return nil }
func (*Session) Toggle(bool) error { return nil }
func (*Session) Join(discord.ChannelID) error {
return errors.New("discordo was built without voice")
}

120
internal/voice/speaker.go Normal file
View file

@ -0,0 +1,120 @@
//go:build !no_voice
package voice
import (
"encoding/binary"
"sync"
"time"
"github.com/tphakala/go-opus/opus"
)
const (
sampleRate = 48000
channels = 2
// frameSamples and frameBytes are the sizes of 20ms of PCM.
frameSamples = sampleRate / 50 * channels
frameBytes = frameSamples * 2
// cushion is how much is buffered before it is played, so that packets arriving unevenly do not make gaps.
cushion = 3 * frameBytes
// maxBuffered is how much is buffered before the oldest is dropped rather than fall behind.
maxBuffered = 10 * frameBytes
// maxLost is how many packets in a row are made up for.
maxLost = 3
)
// speaker decodes and buffers what one user says, to be mixed into what is played.
type speaker struct {
mu sync.Mutex
decoder *opus.Decoder
pcm []int16
buf []byte
// playing is whether the cushion was buffered and what is left of it is being played.
playing bool
// seq is the sequence number of the last packet, and last when it arrived.
seq uint16
last time.Time
}
// newSpeaker returns a speaker whose first packet has the sequence number seq.
func newSpeaker(seq uint16) (*speaker, error) {
decoder, err := opus.NewDecoder(sampleRate, channels)
if err != nil {
return nil, err
}
// 120ms is the longest an Opus packet can be.
return &speaker{decoder: decoder, pcm: make([]int16, 6*frameSamples), seq: seq - 1}, nil
}
// decode appends what packet decodes to, or a made up frame if it is nil.
func (s *speaker) decode(packet []byte) error {
pcm := s.pcm
if packet == nil {
pcm = pcm[:frameSamples]
}
n, err := s.decoder.Decode(packet, pcm)
if err != nil {
return err
}
for _, sample := range pcm[:n*channels] {
s.buf = binary.LittleEndian.AppendUint16(s.buf, uint16(sample))
}
return nil
}
// write buffers the packet with the sequence number seq.
func (s *speaker) write(seq uint16, packet []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
// Make up for the few packets that never arrived. One that arrives late or twice is not worth playing.
gap := seq - s.seq
if gap == 0 || gap > 1<<15 && time.Since(s.last) < time.Second {
return nil
}
if gap > 1 && gap <= maxLost+1 {
for range gap - 1 {
if err := s.decode(nil); err != nil {
return err
}
}
}
s.seq, s.last = seq, time.Now()
if err := s.decode(packet); err != nil {
return err
}
if len(s.buf) > maxBuffered {
s.buf = s.buf[len(s.buf)-cushion:]
}
return nil
}
// mix adds what is buffered to output, once the cushion is.
func (s *speaker) mix(output []byte) {
s.mu.Lock()
defer s.mu.Unlock()
if s.playing = s.playing || len(s.buf) >= cushion; !s.playing {
return
}
n := min(len(output), len(s.buf))
for i := 0; i+1 < n; i += 2 {
sum := int32(int16(binary.LittleEndian.Uint16(output[i:]))) + int32(int16(binary.LittleEndian.Uint16(s.buf[i:])))
binary.LittleEndian.PutUint16(output[i:], uint16(max(-1<<15, min(1<<15-1, sum))))
}
s.buf = s.buf[n:]
s.playing = len(s.buf) > 0
}

11
internal/voice/voice.go Normal file
View file

@ -0,0 +1,11 @@
// Package voice joins voice channels, plays what is said in them and sends what the user says. Built with the no_voice tag, it does none of that and needs no C compiler.
package voice
import "github.com/ayn2op/arikawa/v3/discord"
// Status is what a Session is doing.
type Status struct {
// ChannelID is the voice channel joined or being joined, if any.
ChannelID discord.ChannelID
Joined bool
}