diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c28499f..9584f6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,17 @@ name: ci on: [push, pull_request] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v9 + with: + version: latest + build: strategy: fail-fast: false diff --git a/cmd/root.go b/cmd/root.go index 8824fcc..67e6e36 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,7 +35,7 @@ func Run() error { if err != nil { return fmt.Errorf("failed to load logger: %w", err) } - defer logFile.Close() + defer logFile.Close() //nolint:errcheck cfg, err := config.Load(*configPath) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index f975799..a601713 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -291,7 +291,7 @@ func Load(path string) (*Config, error) { case err != nil: return nil, fmt.Errorf("failed to open config file: %w", err) default: - defer file.Close() + defer file.Close() //nolint:errcheck if _, err := toml.NewDecoder(file).Decode(&cfg); err != nil { return nil, fmt.Errorf("failed to decode config: %w", err) } diff --git a/internal/config/theme.go b/internal/config/theme.go index b2f4d0e..6de26a2 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -9,6 +9,7 @@ import ( "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/tview" "github.com/gdamore/tcell/v3" + "github.com/gdamore/tcell/v3/color" ) var errInvalidType = errors.New("invalid type") @@ -89,7 +90,7 @@ func (sw *StyleWrapper) parseAttr(s string) { } // An unknown attribute parses to no attribute. attr, _ := attributes.parse(s) - sw.Style = sw.Attributes(sw.GetAttributes() | attr) + sw.Style = sw.Attributes(sw.GetAttributes() | attr) //nolint:staticcheck } // MarshalTOML writes the style as the inline table UnmarshalTOML reads. @@ -254,14 +255,14 @@ func defaultTheme() ThemeConfig { dim = StyleWrapper{plain.Dim(true)} bold = plain.Bold(true) italic = plain.Italic(true) - blue = plain.Foreground(tcell.ColorBlue) - focus = StyleThemeConfig{dim, StyleWrapper{bold.Foreground(tcell.ColorGreen)}} + blue = plain.Foreground(color.Blue) + focus = StyleThemeConfig{dim, StyleWrapper{bold.Foreground(color.Green)}} tree = TreeThemeConfig{ GraphicsColor: "default", - OnlineStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)}, - IdleStyle: StyleWrapper{plain.Foreground(tcell.ColorYellow)}, - DNDStyle: StyleWrapper{plain.Foreground(tcell.ColorRed)}, - OfflineStyle: StyleWrapper{plain.Foreground(tcell.ColorGray)}, + OnlineStyle: StyleWrapper{plain.Foreground(color.Green)}, + IdleStyle: StyleWrapper{plain.Foreground(color.Yellow)}, + DNDStyle: StyleWrapper{plain.Foreground(color.Red)}, + OfflineStyle: StyleWrapper{plain.Foreground(color.Gray)}, } ) return ThemeConfig{ @@ -270,16 +271,16 @@ func defaultTheme() ThemeConfig { Border: focus, GuildsTree: GuildsTreeThemeConfig{ TreeThemeConfig: tree, - SpeakingStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)}, - VoiceLimitStyle: StyleWrapper{plain.Foreground(tcell.ColorDodgerBlue)}, + SpeakingStyle: StyleWrapper{plain.Foreground(color.Green)}, + VoiceLimitStyle: StyleWrapper{plain.Foreground(color.DodgerBlue)}, }, MembersTree: tree, ScrollBar: ScrollBarThemeConfig{TrackStyle: dim}, MessagesList: MessagesListThemeConfig{ MentionStyle: StyleWrapper{blue.Bold(true)}, - EmojiStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)}, + EmojiStyle: StyleWrapper{plain.Foreground(color.Green)}, URLStyle: StyleWrapper{blue}, - AttachmentStyle: StyleWrapper{plain.Foreground(tcell.ColorYellow)}, + AttachmentStyle: StyleWrapper{plain.Foreground(color.Yellow)}, ReactionStyle: dim, OwnReactionStyle: StyleWrapper{bold}, SelectedMessageStyle: StyleWrapper{plain.Reverse(true)}, diff --git a/internal/http/transport.go b/internal/http/transport.go index f56222b..52514b9 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -1,3 +1,5 @@ +//go:build no_spoof_tls_fingerprint + package http import ( diff --git a/internal/ui/chat/composer/model.go b/internal/ui/chat/composer/model.go index 39ab461..b042719 100644 --- a/internal/ui/chat/composer/model.go +++ b/internal/ui/chat/composer/model.go @@ -361,7 +361,7 @@ func (m Model) processText(channel *discord.Channel, src []byte) string { canMention = true ) - ast.Walk(md.Parse(src), func(node ast.Node, enter bool) (ast.WalkStatus, error) { + _ = ast.Walk(md.Parse(src), func(node ast.Node, enter bool) (ast.WalkStatus, error) { switch node := node.(type) { case *ast.CodeBlock, *ast.FencedCodeBlock: canMention = !enter @@ -403,7 +403,7 @@ func (m Model) expandMentions(channel *discord.Channel, src []byte) []byte { } return output } - state.MemberStore.Each(channel.GuildID, func(m *discord.Member) bool { + _ = state.MemberStore.Each(channel.GuildID, func(m *discord.Member) bool { if strings.EqualFold(m.User.Username, name) { if channelHasUser(state, channel.ID, m.User.ID) { output = []byte(m.User.ID.Mention()) diff --git a/internal/ui/chat/composer/model_test.go b/internal/ui/chat/composer/model_test.go index 3f7d6ba..1f2d333 100644 --- a/internal/ui/chat/composer/model_test.go +++ b/internal/ui/chat/composer/model_test.go @@ -40,12 +40,12 @@ func TestModelSetChannel(t *testing.T) { channel := discord.Channel{ID: 10, GuildID: 1} thread := discord.Channel{ID: 11, GuildID: 1, ParentID: 10, Type: discord.GuildPublicThread} for _, err := range []error{ - state.Cabinet.MyselfSet(discord.User{ID: 3}, false), - state.Cabinet.GuildSet(&discord.Guild{ID: 1}, false), - state.Cabinet.MemberSet(1, &discord.Member{User: discord.User{ID: 3}}, false), - state.Cabinet.RoleSet(1, &discord.Role{ID: 1, Permissions: discord.PermissionSendMessagesInThreads}, false), - state.Cabinet.ChannelSet(&channel, false), - state.Cabinet.ChannelSet(&thread, false), + state.MyselfSet(discord.User{ID: 3}, false), + state.GuildSet(&discord.Guild{ID: 1}, false), + state.MemberSet(1, &discord.Member{User: discord.User{ID: 3}}, false), + state.RoleSet(1, &discord.Role{ID: 1, Permissions: discord.PermissionSendMessagesInThreads}, false), + state.ChannelSet(&channel, false), + state.ChannelSet(&thread, false), } { if err != nil { t.Fatal(err) @@ -168,13 +168,13 @@ func TestModelSearchMember(t *testing.T) { func TestModelTabSuggest(t *testing.T) { c, _ := newTestModel(t) - if err := c.state.Cabinet.MyselfSet(discord.User{ID: 1, Username: "1"}, false); err != nil { + if err := c.state.MyselfSet(discord.User{ID: 1, Username: "1"}, false); err != nil { t.Fatal(err) } channel := discord.Channel{ID: 1, Type: discord.DirectMessage} for id, author := range []discord.UserID{2, 3, 2, 1} { message := discord.Message{ID: discord.MessageID(id + 1), ChannelID: 1, Author: discord.User{ID: author, Username: author.String()}} - if err := c.state.Cabinet.MessageSet(&message, false); err != nil { + if err := c.state.MessageSet(&message, false); err != nil { t.Fatal(err) } } diff --git a/internal/ui/chat/composer/msg.go b/internal/ui/chat/composer/msg.go index 265442f..91edeb6 100644 --- a/internal/ui/chat/composer/msg.go +++ b/internal/ui/chat/composer/msg.go @@ -97,7 +97,9 @@ func closeFiles(files []sendpart.File) tview.Cmd { return func() tview.Msg { for _, file := range files { if closer, ok := file.Reader.(io.Closer); ok { - closer.Close() + if err := closer.Close(); err != nil { + slog.Error("failed to close file", "err", err) + } } } return nil @@ -121,7 +123,9 @@ func (m *Model) sendTyping() tview.Cmd { } channelID := selectedChannel.ID return func() tview.Msg { - m.state.Typing(channelID) + if err := m.state.Typing(channelID); err != nil { + slog.Error("failed to send typing indicator", "err", err) + } return nil } } @@ -327,7 +331,11 @@ func (m Model) openEditor() tview.Cmd { return nil } name := file.Name() - defer os.Remove(name) + defer func() { + if err := os.Remove(name); err != nil { + slog.Error("failed to remove tmp file", "err", err) + } + }() _, _ = file.WriteString(text) _ = file.Close() diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 1d94f9f..6faec51 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -250,7 +250,7 @@ func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) { // 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) + states, _ := m.state.VoiceStates(guildID) slices.SortFunc(states, func(a, b discord.VoiceState) int { return cmp.Compare(a.UserID, b.UserID) }) channels, _ := m.state.Cabinet.Channels(guildID) @@ -340,7 +340,7 @@ func (m Model) voiceFooter() string { 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 { + if state, _ := m.state.VoiceState(channel.GuildID, me.ID); state != nil { name += m.voiceMarkers(*state) } } diff --git a/internal/ui/chat/memberstree/model_test.go b/internal/ui/chat/memberstree/model_test.go index 90a24ac..d0e2317 100644 --- a/internal/ui/chat/memberstree/model_test.go +++ b/internal/ui/chat/memberstree/model_test.go @@ -29,16 +29,22 @@ func newTestModel(t *testing.T) Model { func setGuildMembers(t *testing.T, m Model, items string) *discord.Channel { t.Helper() channel := &discord.Channel{ID: channelID, GuildID: guildID, Type: discord.GuildText} - m.state.Cabinet.ChannelSet(channel, false) - m.state.Cabinet.RoleSet(guildID, &discord.Role{ID: guildID, Permissions: discord.PermissionViewChannel}, false) - m.state.Cabinet.RoleSet(guildID, &discord.Role{ID: 3, Name: "Mods"}, false) + if err := m.state.ChannelSet(channel, false); err != nil { + t.Fatal(err) + } + if err := m.state.RoleSet(guildID, &discord.Role{ID: guildID, Permissions: discord.PermissionViewChannel}, false); err != nil { + t.Fatal(err) + } + if err := m.state.RoleSet(guildID, &discord.Role{ID: 3, Name: "Mods"}, false); err != nil { + t.Fatal(err) + } event := &gateway.GuildMemberListUpdateEvent{ID: "everyone", GuildID: guildID} event.Ops = []gateway.GuildMemberListOp{{Op: "SYNC", Range: [2]int{0, 99}}} if err := json.Unmarshal([]byte(items), &event.Ops[0].Items); err != nil { t.Fatal(err) } - m.state.State.Handler.Call(event) + m.state.State.Call(event) return channel } @@ -106,8 +112,12 @@ func TestModelRebuild(t *testing.T) { t.Run("group dm recipients", func(t *testing.T) { m := newTestModel(t) - m.state.Cabinet.MyselfSet(discord.User{ID: 13, Username: "me"}, false) - m.state.Cabinet.PresenceSet(discord.NullGuildID, &discord.Presence{User: discord.User{ID: 11}, Status: discord.OnlineStatus}, false) + if err := m.state.MyselfSet(discord.User{ID: 13, Username: "me"}, false); err != nil { + t.Fatal(err) + } + if err := m.state.PresenceSet(discord.NullGuildID, &discord.Presence{User: discord.User{ID: 11}, Status: discord.OnlineStatus}, false); err != nil { + t.Fatal(err) + } m.channel = &discord.Channel{Type: discord.GroupDM, DMRecipients: []discord.User{ {ID: 10, Username: "a"}, {ID: 11, Username: "c"}, diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index d985c8c..977a989 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -1,6 +1,7 @@ package messageslist import ( + "errors" "fmt" "io" "log/slog" @@ -264,7 +265,7 @@ func (m Model) buildItem(index int) tview.Widget { item := &m.items[index] if item.separator { date := m.items[index+1].message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format) - return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)} + return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Dim(true)} } message := &item.message @@ -1234,7 +1235,7 @@ func extractURLs(content string) []string { // src must be the byte slice the node was parsed from (AutoLink resolves its URL against it). func urlsFromAST(node ast.Node, src []byte) []string { var urls []string - ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if entering { switch n := n.(type) { case *ast.AutoLink: @@ -1297,7 +1298,7 @@ func (m Model) get(url string) (*http.Response, error) { if err != nil || resp.StatusCode == http.StatusOK { return resp, err } - resp.Body.Close() + resp.Body.Close() //nolint:errcheck var links struct { URLs []struct { @@ -1313,7 +1314,7 @@ func (m Model) get(url string) (*http.Response, error) { if resp, err = http.Get(links.URLs[0].Refreshed); err != nil || resp.StatusCode == http.StatusOK { return resp, err } - resp.Body.Close() + resp.Body.Close() //nolint:errcheck } return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status) @@ -1324,14 +1325,13 @@ func (m Model) downloadAttachment(attachment discord.Attachment, destination str if err != nil { return err } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck file, err := os.Create(destination) if err != nil { return err } - defer file.Close() _, err = io.Copy(file, resp.Body) - return err + return errors.Join(err, file.Close()) } // SelectLastOwn selects the last message in the selected channel that the user can edit, and returns it. diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 7edd1b7..8597897 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -30,7 +30,7 @@ func testConfig(t *testing.T) *config.Config { func newTestModel(t *testing.T, cfg *config.Config) *Model { t.Helper() ml := NewModel(cfg, ningen.FromState(state.New(""))) - if err := ml.state.Cabinet.MyselfSet(discord.User{ID: 1}, false); err != nil { + if err := ml.state.MyselfSet(discord.User{ID: 1}, false); err != nil { t.Fatal(err) } ml.channel = &discord.Channel{ID: 1} diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 38ccd83..316255c 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -183,7 +183,7 @@ func (m Model) playVoiceMessage(attachment discord.Attachment) error { if err != nil { return err } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // It is read whole, for playing it not to wait for the network. audio, err := io.ReadAll(resp.Body) @@ -305,13 +305,13 @@ func (m Model) openDownloadedAttachment(attachment discord.Attachment) tview.Cmd if err != nil { return ui.ErrorDialog("create attachment file", err) } - defer file.Close() - path := file.Name() + if err := file.Close(); err != nil { + return ui.ErrorDialog("create attachment file", err) + } if err := m.downloadAttachment(attachment, path); err != nil { - os.Remove(path) - return ui.ErrorDialog("download attachment", err) + return ui.ErrorDialog("download attachment", errors.Join(err, os.Remove(path))) } else if err := open.Start(path); err != nil { return ui.ErrorDialog("open attachment file", err) } diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index 2314bf6..995e09a 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -153,7 +153,7 @@ func (m Model) downloadPreview(proxy discord.URL, bounds image.Rectangle) (image if err != nil { return nil, err } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck img, _, err := image.Decode(resp.Body) return img, err } diff --git a/internal/ui/login/qr/conn_test.go b/internal/ui/login/qr/conn_test.go index f10b6a0..00727cd 100644 --- a/internal/ui/login/qr/conn_test.go +++ b/internal/ui/login/qr/conn_test.go @@ -40,7 +40,7 @@ func serveGateway(t *testing.T, acks int) http.HandlerFunc { t.Error(err) return } - defer conn.Close() + defer conn.Close() //nolint:errcheck if err := conn.WriteJSON(map[string]any{"op": "hello", "heartbeat_interval": 1}); err != nil { return @@ -85,7 +85,7 @@ func TestModelGateway(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { conn.Close() }) + t.Cleanup(func() { conn.Close() }) //nolint:errcheck screen, err := tcell.NewTerminfoScreenFromTty(vt.NewMockTerm(vt.MockOptSize{X: 80, Y: 24})) if err != nil { @@ -117,7 +117,7 @@ func TestCloseConn(t *testing.T) { t.Error(err) return } - defer conn.Close() + defer conn.Close() //nolint:errcheck _, _, _ = conn.ReadMessage() })) t.Cleanup(server.Close) diff --git a/internal/voice/player.go b/internal/voice/player.go index 3906467..0b21010 100644 --- a/internal/voice/player.go +++ b/internal/voice/player.go @@ -5,6 +5,7 @@ package voice import ( "bytes" "encoding/binary" + "errors" "io" "sync" "time" @@ -130,7 +131,9 @@ func (p *Player) seek(position time.Duration) error { } // Nothing is left to play if position is past the end. - io.CopyN(io.Discard, decoder, (sample-first)*int64(decoder.Info().Channels)*2) + if _, err := io.CopyN(io.Discard, decoder, (sample-first)*int64(decoder.Info().Channels)*2); err != nil && !errors.Is(err, io.EOF) { + return err + } p.decoder, p.samples = decoder, sample return nil diff --git a/internal/voice/session.go b/internal/voice/session.go index cff2a86..e0e192a 100644 --- a/internal/voice/session.go +++ b/internal/voice/session.go @@ -189,16 +189,18 @@ func (s *Session) setSpeaking(userID discord.UserID, speaking bool) { } // 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) { +func setDevice(device **malgo.Device, on bool, kind malgo.DeviceType, data func(output, input []byte)) error { switch { case on && *device == nil: + var err error *device, err = startDevice(kind, channels, data) + return err case !on && *device != nil: (*device).Uninit() *device = nil } - return err + return nil } // setDevices captures what the user says unless mute, and plays what is said unless deafen. @@ -217,12 +219,14 @@ func (s *Session) setDevices(mute, deafen bool) error { // left stops the devices, in no voice channel. func (s *Session) left() { - s.setDevices(true, true) + if err := s.setDevices(true, true); err != nil { + slog.Error("failed to stop voice devices", "err", err) + } 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) { +func (s *Session) Join(channelID discord.ChannelID) error { if !s.mu.TryLock() { return nil } @@ -230,18 +234,22 @@ func (s *Session) Join(channelID discord.ChannelID) (err error) { s.setStatus(Status{ChannelID: channelID}) - defer func() { + if err := s.join(channelID); err != nil { // Discord may have the user in the voice channel regardless, and joining it again would then do nothing. - if err != nil { - s.leave() - } - }() + return errors.Join(err, s.leave()) + } + s.setStatus(Status{ChannelID: channelID, Joined: true}) + return nil +} + +func (s *Session) join(channelID discord.ChannelID) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() session := s.session.Load() if session == nil { + var err error if session, err = voice.NewSession(s.state, s.state.Handler); err != nil { return err } @@ -267,8 +275,6 @@ func (s *Session) Join(channelID discord.ChannelID) (err error) { return err } - s.setStatus(Status{ChannelID: channelID, Joined: true}) - return nil }