From c65c9bbe5a1339c3cba9bd704970be22f95eb741 Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 20 Feb 2026 02:57:16 -0800 Subject: [PATCH 01/22] fix: resolve typecheck errors by removing duplicate db methods and updating handlers to use models-based API --- internal/db/queries.go | 440 +++++++++++++++++---------------------- internal/handlers/api.go | 405 ++++++++++++++++++++++------------- 2 files changed, 450 insertions(+), 395 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index 974f4db..ce63057 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -2,88 +2,52 @@ package db import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "time" ) -func generateToken() string { - b := make([]byte, 32) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -// CreateUser registers a new user with the given nick and returns the user with token. -func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) { - token := generateToken() - now := time.Now() - res, err := s.db.ExecContext(ctx, - "INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)", - nick, token, now, now) - if err != nil { - return 0, "", fmt.Errorf("create user: %w", err) - } - id, _ := res.LastInsertId() - return id, token, nil -} - -// GetUserByToken returns user id and nick for a given auth token. -func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) { - var id int64 - var nick string - err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick) - if err != nil { - return 0, "", err - } - // Update last_seen - _, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id) - return id, nick, nil -} - -// GetUserByNick returns user id for a given nick. -func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) { - var id int64 - err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id) - return id, err -} - // GetOrCreateChannel returns the channel id, creating it if needed. -func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) { - var id int64 +func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (string, error) { + var id string + err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id) if err == nil { return id, nil } + now := time.Now() - res, err := s.db.ExecContext(ctx, - "INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)", - name, now, now) + id = fmt.Sprintf("ch-%d", now.UnixNano()) + + _, err = s.db.ExecContext(ctx, + "INSERT INTO channels (id, name, topic, modes, created_at, updated_at) VALUES (?, ?, '', '', ?, ?)", + id, name, now, now) if err != nil { - return 0, fmt.Errorf("create channel: %w", err) + return "", fmt.Errorf("create channel: %w", err) } - id, _ = res.LastInsertId() + return id, nil } // JoinChannel adds a user to a channel. -func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error { +func (s *Database) JoinChannel(ctx context.Context, channelID, userID string) error { _, err := s.db.ExecContext(ctx, - "INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)", + "INSERT OR IGNORE INTO channel_members (channel_id, user_id, modes, joined_at) VALUES (?, ?, '', ?)", channelID, userID, time.Now()) + return err } // PartChannel removes a user from a channel. -func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error { +func (s *Database) PartChannel(ctx context.Context, channelID, userID string) error { _, err := s.db.ExecContext(ctx, "DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?", channelID, userID) + return err } // ListChannels returns all channels the user has joined. -func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) { +func (s *Database) ListChannels(ctx context.Context, userID string) ([]ChannelInfo, error) { rows, err := s.db.QueryContext(ctx, `SELECT c.id, c.name, c.topic FROM channels c INNER JOIN channel_members cm ON cm.channel_id = c.id @@ -91,62 +55,66 @@ func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInf if err != nil { return nil, err } - defer rows.Close() - var channels []ChannelInfo + + defer func() { _ = rows.Close() }() + + channels := []ChannelInfo{} + for rows.Next() { var ch ChannelInfo if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { return nil, err } + channels = append(channels, ch) } - if channels == nil { - channels = []ChannelInfo{} - } - return channels, nil + + return channels, rows.Err() } // ChannelInfo is a lightweight channel representation. type ChannelInfo struct { - ID int64 `json:"id"` + ID string `json:"id"` Name string `json:"name"` Topic string `json:"topic"` } // ChannelMembers returns all members of a channel. -func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) { +func (s *Database) ChannelMembers(ctx context.Context, channelID string) ([]MemberInfo, error) { rows, err := s.db.QueryContext(ctx, - `SELECT u.id, u.nick, u.last_seen FROM users u + `SELECT u.id, u.nick, u.last_seen_at FROM users u INNER JOIN channel_members cm ON cm.user_id = u.id WHERE cm.channel_id = ? ORDER BY u.nick`, channelID) if err != nil { return nil, err } - defer rows.Close() - var members []MemberInfo + + defer func() { _ = rows.Close() }() + + members := []MemberInfo{} + for rows.Next() { var m MemberInfo if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil { return nil, err } + members = append(members, m) } - if members == nil { - members = []MemberInfo{} - } - return members, nil + + return members, rows.Err() } // MemberInfo represents a channel member. type MemberInfo struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - LastSeen time.Time `json:"lastSeen"` + ID string `json:"id"` + Nick string `json:"nick"` + LastSeen *time.Time `json:"lastSeen"` } // MessageInfo represents a chat message. type MessageInfo struct { - ID int64 `json:"id"` + ID string `json:"id"` Channel string `json:"channel,omitempty"` Nick string `json:"nick"` Content string `json:"content"` @@ -155,234 +123,202 @@ type MessageInfo struct { CreatedAt time.Time `json:"createdAt"` } -// GetMessages returns messages for a channel, optionally after a given ID. -func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) { - if limit <= 0 { - limit = 50 - } - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, c.name, u.nick, m.content, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN channels c ON c.id = m.channel_id - WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ? - ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var msgs []MessageInfo - for rows.Next() { - var m MessageInfo - if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { - return nil, err - } - msgs = append(msgs, m) - } - if msgs == nil { - msgs = []MessageInfo{} - } - return msgs, nil -} - // SendMessage inserts a channel message. -func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) { - res, err := s.db.ExecContext(ctx, - "INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)", - channelID, userID, content, time.Now()) +func (s *Database) SendMessage(ctx context.Context, channelID, userID, nick, content string) (string, error) { + now := time.Now() + id := fmt.Sprintf("msg-%d", now.UnixNano()) + + _, err := s.db.ExecContext(ctx, + `INSERT INTO messages (id, ts, from_user_id, from_nick, target, type, body, meta, created_at) + VALUES (?, ?, ?, ?, ?, 'message', ?, '{}', ?)`, + id, now, userID, nick, channelID, content, now) if err != nil { - return 0, err + return "", err } - return res.LastInsertId() + + return id, nil } // SendDM inserts a direct message. -func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) { - res, err := s.db.ExecContext(ctx, - "INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)", - fromID, content, toID, time.Now()) +func (s *Database) SendDM(ctx context.Context, fromID, fromNick, toID, content string) (string, error) { + now := time.Now() + id := fmt.Sprintf("msg-%d", now.UnixNano()) + + _, err := s.db.ExecContext(ctx, + `INSERT INTO messages (id, ts, from_user_id, from_nick, target, type, body, meta, created_at) + VALUES (?, ?, ?, ?, ?, 'message', ?, '{}', ?)`, + id, now, fromID, fromNick, toID, content, now) if err != nil { - return 0, err + return "", err } - return res.LastInsertId() + + return id, nil } -// GetDMs returns direct messages between two users after a given ID. -func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) { - if limit <= 0 { - limit = 50 - } - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, u.nick, m.content, t.nick, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN users t ON t.id = m.dm_target_id - WHERE m.is_dm = 1 AND m.id > ? - AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) - ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var msgs []MessageInfo - for rows.Next() { - var m MessageInfo - if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { - return nil, err - } - m.IsDM = true - msgs = append(msgs, m) - } - if msgs == nil { - msgs = []MessageInfo{} - } - return msgs, nil -} - -// PollMessages returns all new messages (channel + DM) for a user after a given ID. -func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) { +// PollMessages returns all new messages for a user's joined channels, ordered by timestamp. +func (s *Database) PollMessages(ctx context.Context, userID string, afterTS string, limit int) ([]MessageInfo, error) { if limit <= 0 { limit = 100 } - rows, err := s.db.QueryContext(ctx, - `SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, m.is_dm, COALESCE(t.nick, ''), m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - LEFT JOIN channels c ON c.id = m.channel_id - LEFT JOIN users t ON t.id = m.dm_target_id - WHERE m.id > ? AND ( - (m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?)) - OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?)) - ) - ORDER BY m.id ASC LIMIT ?`, afterID, userID, userID, userID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var msgs []MessageInfo - for rows.Next() { - var m MessageInfo - var isDM int - if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil { - return nil, err - } - m.IsDM = isDM == 1 - msgs = append(msgs, m) - } - if msgs == nil { - msgs = []MessageInfo{} - } - return msgs, nil -} -// GetMessagesBefore returns channel messages before a given ID (for history scrollback). -func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) { - if limit <= 0 { - limit = 50 - } - var query string - var args []any - if beforeID > 0 { - query = `SELECT m.id, c.name, u.nick, m.content, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN channels c ON c.id = m.channel_id - WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id < ? - ORDER BY m.id DESC LIMIT ?` - args = []any{channelID, beforeID, limit} - } else { - query = `SELECT m.id, c.name, u.nick, m.content, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN channels c ON c.id = m.channel_id - WHERE m.channel_id = ? AND m.is_dm = 0 - ORDER BY m.id DESC LIMIT ?` - args = []any{channelID, limit} - } - rows, err := s.db.QueryContext(ctx, query, args...) + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, m.target, m.from_nick, m.body, m.created_at + FROM messages m + WHERE m.created_at > COALESCE(NULLIF(?, ''), '1970-01-01') + AND ( + m.target IN (SELECT cm.channel_id FROM channel_members cm WHERE cm.user_id = ?) + OR m.target = ? + OR m.from_user_id = ? + ) + ORDER BY m.created_at ASC LIMIT ?`, + afterTS, userID, userID, userID, limit) if err != nil { return nil, err } - defer rows.Close() - var msgs []MessageInfo + + defer func() { _ = rows.Close() }() + + msgs := []MessageInfo{} + for rows.Next() { var m MessageInfo if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { return nil, err } + msgs = append(msgs, m) } - if msgs == nil { - msgs = []MessageInfo{} - } - // Reverse to ascending order - for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { - msgs[i], msgs[j] = msgs[j], msgs[i] - } - return msgs, nil + + return msgs, rows.Err() } -// GetDMsBefore returns DMs between two users before a given ID (for history scrollback). -func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) { +// GetMessagesBefore returns channel messages before a given timestamp (for history scrollback). +func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeTS string, limit int) ([]MessageInfo, error) { if limit <= 0 { limit = 50 } - var query string - var args []any - if beforeID > 0 { - query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN users t ON t.id = m.dm_target_id - WHERE m.is_dm = 1 AND m.id < ? - AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) - ORDER BY m.id DESC LIMIT ?` - args = []any{beforeID, userA, userB, userB, userA, limit} - } else { - query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at - FROM messages m - INNER JOIN users u ON u.id = m.user_id - INNER JOIN users t ON t.id = m.dm_target_id - WHERE m.is_dm = 1 - AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) - ORDER BY m.id DESC LIMIT ?` - args = []any{userA, userB, userB, userA, limit} + + var rows interface { + Next() bool + Scan(dest ...interface{}) error + Close() error + Err() error } - rows, err := s.db.QueryContext(ctx, query, args...) + + var err error + + if beforeTS != "" { + rows, err = s.db.QueryContext(ctx, + `SELECT m.id, m.target, m.from_nick, m.body, m.created_at + FROM messages m + WHERE m.target = ? AND m.created_at < ? + ORDER BY m.created_at DESC LIMIT ?`, + target, beforeTS, limit) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT m.id, m.target, m.from_nick, m.body, m.created_at + FROM messages m + WHERE m.target = ? + ORDER BY m.created_at DESC LIMIT ?`, + target, limit) + } + if err != nil { return nil, err } - defer rows.Close() - var msgs []MessageInfo + + defer func() { _ = rows.Close() }() + + msgs := []MessageInfo{} + + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + + msgs = append(msgs, m) + } + + // Reverse to ascending order. + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + + return msgs, rows.Err() +} + +// GetDMsBefore returns DMs between two users before a given timestamp. +func (s *Database) GetDMsBefore(ctx context.Context, userA, userB string, beforeTS string, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + + var rows interface { + Next() bool + Scan(dest ...interface{}) error + Close() error + Err() error + } + + var err error + + if beforeTS != "" { + rows, err = s.db.QueryContext(ctx, + `SELECT m.id, m.from_nick, m.body, m.target, m.created_at + FROM messages m + WHERE m.created_at < ? + AND ((m.from_user_id = ? AND m.target = ?) OR (m.from_user_id = ? AND m.target = ?)) + ORDER BY m.created_at DESC LIMIT ?`, + beforeTS, userA, userB, userB, userA, limit) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT m.id, m.from_nick, m.body, m.target, m.created_at + FROM messages m + WHERE (m.from_user_id = ? AND m.target = ?) OR (m.from_user_id = ? AND m.target = ?) + ORDER BY m.created_at DESC LIMIT ?`, + userA, userB, userB, userA, limit) + } + + if err != nil { + return nil, err + } + + defer func() { _ = rows.Close() }() + + msgs := []MessageInfo{} + for rows.Next() { var m MessageInfo if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { return nil, err } + m.IsDM = true msgs = append(msgs, m) } - if msgs == nil { - msgs = []MessageInfo{} - } - // Reverse to ascending order + + // Reverse to ascending order. for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { msgs[i], msgs[j] = msgs[j], msgs[i] } - return msgs, nil + + return msgs, rows.Err() } // ChangeNick updates a user's nickname. -func (s *Database) ChangeNick(ctx context.Context, userID int64, newNick string) error { +func (s *Database) ChangeNick(ctx context.Context, userID string, newNick string) error { _, err := s.db.ExecContext(ctx, "UPDATE users SET nick = ? WHERE id = ?", newNick, userID) + return err } // SetTopic sets the topic for a channel. -func (s *Database) SetTopic(ctx context.Context, channelName string, _ int64, topic string) error { +func (s *Database) SetTopic(ctx context.Context, channelName string, _ string, topic string) error { _, err := s.db.ExecContext(ctx, "UPDATE channels SET topic = ? WHERE name = ?", topic, channelName) + return err } @@ -398,17 +334,19 @@ func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) { if err != nil { return nil, err } - defer rows.Close() - var channels []ChannelInfo + + defer func() { _ = rows.Close() }() + + channels := []ChannelInfo{} + for rows.Next() { var ch ChannelInfo if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { return nil, err } + channels = append(channels, ch) } - if channels == nil { - channels = []ChannelInfo{} - } - return channels, nil + + return channels, rows.Err() } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 975b7a1..de53a92 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1,7 +1,9 @@ package handlers import ( + "crypto/rand" "database/sql" + "encoding/hex" "encoding/json" "net/http" "strconv" @@ -12,77 +14,114 @@ import ( ) // authUser extracts the user from the Authorization header (Bearer token). -func (s *Handlers) authUser(r *http.Request) (int64, string, error) { +func (s *Handlers) authUser(r *http.Request) (string, string, error) { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { - return 0, "", sql.ErrNoRows + return "", "", sql.ErrNoRows } + token := strings.TrimPrefix(auth, "Bearer ") - return s.params.Database.GetUserByToken(r.Context(), token) + + u, err := s.params.Database.GetUserByToken(r.Context(), token) + if err != nil { + return "", "", err + } + + return u.ID, u.Nick, nil } -func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, string, bool) { +func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (string, string, bool) { uid, nick, err := s.authUser(r) if err != nil { s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) - return 0, "", false + return "", "", false } + return uid, nick, true } +func generateID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + + return hex.EncodeToString(b) +} + // HandleCreateSession creates a new user session and returns the auth token. func (s *Handlers) HandleCreateSession() http.HandlerFunc { type request struct { Nick string `json:"nick"` } type response struct { - ID int64 `json:"id"` + ID string `json:"id"` Nick string `json:"nick"` Token string `json:"token"` } + return func(w http.ResponseWriter, r *http.Request) { var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) return } + req.Nick = strings.TrimSpace(req.Nick) if req.Nick == "" || len(req.Nick) > 32 { s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) return } - id, token, err := s.params.Database.CreateUser(r.Context(), req.Nick) + + id := generateID() + + u, err := s.params.Database.CreateUser(r.Context(), id, req.Nick, "") if err != nil { if strings.Contains(err.Error(), "UNIQUE") { s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict) return } + s.log.Error("create user failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } - s.respondJSON(w, r, &response{ID: id, Nick: req.Nick, Token: token}, http.StatusCreated) + + tokenStr := generateID() + + _, err = s.params.Database.CreateAuthToken(r.Context(), tokenStr, u.ID) + if err != nil { + s.log.Error("create auth token failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, &response{ID: u.ID, Nick: req.Nick, Token: tokenStr}, http.StatusCreated) } } // HandleState returns the current user's info and joined channels. func (s *Handlers) HandleState() http.HandlerFunc { type response struct { - ID int64 `json:"id"` + ID string `json:"id"` Nick string `json:"nick"` Channels []db.ChannelInfo `json:"channels"` } + return func(w http.ResponseWriter, r *http.Request) { uid, nick, ok := s.requireAuth(w, r) if !ok { return } + channels, err := s.params.Database.ListChannels(r.Context(), uid) if err != nil { s.log.Error("list channels failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } + s.respondJSON(w, r, &response{ID: uid, Nick: nick, Channels: channels}, http.StatusOK) } } @@ -94,12 +133,15 @@ func (s *Handlers) HandleListAllChannels() http.HandlerFunc { if !ok { return } + channels, err := s.params.Database.ListAllChannels(r.Context()) if err != nil { s.log.Error("list all channels failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } + s.respondJSON(w, r, channels, http.StatusOK) } } @@ -111,20 +153,26 @@ func (s *Handlers) HandleChannelMembers() http.HandlerFunc { if !ok { return } + name := "#" + chi.URLParam(r, "channel") - var chID int64 + + var chID string + err := s.params.Database.GetDB().QueryRowContext(r.Context(), "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) return } + members, err := s.params.Database.ChannelMembers(r.Context(), chID) if err != nil { s.log.Error("channel members failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } + s.respondJSON(w, r, members, http.StatusOK) } } @@ -137,14 +185,18 @@ func (s *Handlers) HandleGetMessages() http.HandlerFunc { if !ok { return } - afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + + afterTS := r.URL.Query().Get("after") limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit) + + msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterTS, limit) if err != nil { s.log.Error("get messages failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } + s.respondJSON(w, r, msgs, http.StatusOK) } } @@ -158,16 +210,19 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { Params []string `json:"params,omitempty"` Body interface{} `json:"body,omitempty"` } + return func(w http.ResponseWriter, r *http.Request) { uid, nick, ok := s.requireAuth(w, r) if !ok { return } + var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) return } + req.Command = strings.ToUpper(strings.TrimSpace(req.Command)) req.To = strings.TrimSpace(req.To) @@ -176,11 +231,13 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { switch v := req.Body.(type) { case []interface{}: lines := make([]string, 0, len(v)) + for _, item := range v { - if s, ok := item.(string); ok { - lines = append(lines, s) + if str, ok := item.(string); ok { + lines = append(lines, str) } } + return lines case []string: return v @@ -191,137 +248,19 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { switch req.Command { case "PRIVMSG", "NOTICE": - if req.To == "" { - s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) - return - } - lines := bodyLines() - if len(lines) == 0 { - s.respondJSON(w, r, map[string]string{"error": "body required"}, http.StatusBadRequest) - return - } - content := strings.Join(lines, "\n") - - if strings.HasPrefix(req.To, "#") { - // Channel message - var chID int64 - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", req.To).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - return - } - msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, content) - if err != nil { - s.log.Error("send message failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) - } else { - // DM - targetID, err := s.params.Database.GetUserByNick(r.Context(), req.To) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) - return - } - msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, content) - if err != nil { - s.log.Error("send dm failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) - } + s.handlePrivmsg(w, r, uid, nick, req.To, bodyLines()) case "JOIN": - if req.To == "" { - s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) - return - } - channel := req.To - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - chID, err := s.params.Database.GetOrCreateChannel(r.Context(), channel) - if err != nil { - s.log.Error("get/create channel failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { - s.log.Error("join channel failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]string{"status": "joined", "channel": channel}, http.StatusOK) + s.handleJoin(w, r, uid, req.To) case "PART": - if req.To == "" { - s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) - return - } - channel := req.To - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - var chID int64 - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - return - } - if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { - s.log.Error("part channel failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]string{"status": "parted", "channel": channel}, http.StatusOK) + s.handlePart(w, r, uid, req.To) case "NICK": - lines := bodyLines() - if len(lines) == 0 { - s.respondJSON(w, r, map[string]string{"error": "body required (new nick)"}, http.StatusBadRequest) - return - } - newNick := strings.TrimSpace(lines[0]) - if newNick == "" || len(newNick) > 32 { - s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) - return - } - if err := s.params.Database.ChangeNick(r.Context(), uid, newNick); err != nil { - if strings.Contains(err.Error(), "UNIQUE") { - s.respondJSON(w, r, map[string]string{"error": "nick already in use"}, http.StatusConflict) - return - } - s.log.Error("change nick failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]string{"status": "ok", "nick": newNick}, http.StatusOK) + s.handleNick(w, r, uid, bodyLines()) case "TOPIC": - if req.To == "" { - s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) - return - } - lines := bodyLines() - if len(lines) == 0 { - s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest) - return - } - topic := strings.Join(lines, " ") - channel := req.To - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - if err := s.params.Database.SetTopic(r.Context(), channel, uid, topic); err != nil { - s.log.Error("set topic failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]string{"status": "ok", "topic": topic}, http.StatusOK) + s.handleTopic(w, r, uid, req.To, bodyLines()) case "PING": s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) @@ -333,6 +272,173 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { } } +func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, nick, to string, lines []string) { + if to == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required"}, http.StatusBadRequest) + return + } + + content := strings.Join(lines, "\n") + + if strings.HasPrefix(to, "#") { + // Channel message. + var chID string + + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", to).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, nick, content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } else { + // DM. + targetUser, err := s.params.Database.GetUserByNick(r.Context(), to) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + + msgID, err := s.params.Database.SendDM(r.Context(), uid, nick, targetUser.ID, content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } +} + +func (s *Handlers) handleJoin(w http.ResponseWriter, r *http.Request, uid, to string) { + if to == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + + channel := to + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + chID, err := s.params.Database.GetOrCreateChannel(r.Context(), channel) + if err != nil { + s.log.Error("get/create channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { + s.log.Error("join channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]string{"status": "joined", "channel": channel}, http.StatusOK) +} + +func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to string) { + if to == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + + channel := to + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + var chID string + + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + + if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { + s.log.Error("part channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]string{"status": "parted", "channel": channel}, http.StatusOK) +} + +func (s *Handlers) handleNick(w http.ResponseWriter, r *http.Request, uid string, lines []string) { + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required (new nick)"}, http.StatusBadRequest) + return + } + + newNick := strings.TrimSpace(lines[0]) + if newNick == "" || len(newNick) > 32 { + s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return + } + + if err := s.params.Database.ChangeNick(r.Context(), uid, newNick); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.respondJSON(w, r, map[string]string{"error": "nick already in use"}, http.StatusConflict) + return + } + + s.log.Error("change nick failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]string{"status": "ok", "nick": newNick}, http.StatusOK) +} + +func (s *Handlers) handleTopic(w http.ResponseWriter, r *http.Request, uid, to string, lines []string) { + if to == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest) + return + } + + topic := strings.Join(lines, " ") + + channel := to + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + if err := s.params.Database.SetTopic(r.Context(), channel, uid, topic); err != nil { + s.log.Error("set topic failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]string{"status": "ok", "topic": topic}, http.StatusOK) +} + // HandleGetHistory returns message history for a specific target (channel or DM). func (s *Handlers) HandleGetHistory() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -340,46 +446,56 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc { if !ok { return } + target := r.URL.Query().Get("target") if target == "" { s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest) return } - beforeID, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + + beforeTS := r.URL.Query().Get("before") limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { limit = 50 } if strings.HasPrefix(target, "#") { - // Channel history - var chID int64 + // Channel history — look up channel by name to get its ID for target matching. + var chID string + err := s.params.Database.GetDB().QueryRowContext(r.Context(), "SELECT id FROM channels WHERE name = ?", target).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) return } - msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeID, limit) + + msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeTS, limit) if err != nil { s.log.Error("get history failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } + s.respondJSON(w, r, msgs, http.StatusOK) } else { - // DM history - targetID, err := s.params.Database.GetUserByNick(r.Context(), target) + // DM history. + targetUser, err := s.params.Database.GetUserByNick(r.Context(), target) if err != nil { s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) return } - msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetID, beforeID, limit) + + msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetUser.ID, beforeTS, limit) if err != nil { s.log.Error("get dm history failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return } + s.respondJSON(w, r, msgs, http.StatusOK) } } @@ -391,6 +507,7 @@ func (s *Handlers) HandleServerInfo() http.HandlerFunc { Name string `json:"name"` MOTD string `json:"motd"` } + return func(w http.ResponseWriter, r *http.Request) { s.respondJSON(w, r, &response{ Name: s.params.Config.ServerName, -- 2.49.1 From c1040ff69d5e7a016dd95c9f392ca32ce36e4556 Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 20 Feb 2026 02:59:15 -0800 Subject: [PATCH 02/22] fix: resolve nlreturn, modernize, perfsprint, wsl_v5, and partial err113 lint issues --- cmd/chat-cli/api/client.go | 34 ++++++++++++--- cmd/chat-cli/api/types.go | 20 +++++---- cmd/chat-cli/main.go | 72 ++++++++++++++++++++++++++++--- cmd/chat-cli/ui.go | 19 ++++++++ internal/db/db.go | 6 ++- internal/db/queries.go | 22 ++++++---- internal/handlers/api.go | 42 ++++++++++++++---- internal/models/auth_token.go | 4 +- internal/models/channel_member.go | 6 +-- internal/models/session.go | 4 +- internal/server/routes.go | 4 ++ 11 files changed, 189 insertions(+), 44 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index a20298c..f7c96b3 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "strconv" "net/http" "net/url" "time" @@ -27,13 +28,15 @@ func NewClient(baseURL string) *Client { } } -func (c *Client) do(method, path string, body interface{}) ([]byte, error) { +func (c *Client) do(method, path string, body any) ([]byte, error) { var bodyReader io.Reader + if body != nil { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal: %w", err) } + bodyReader = bytes.NewReader(data) } @@ -41,7 +44,9 @@ func (c *Client) do(method, path string, body interface{}) ([]byte, error) { if err != nil { return nil, fmt.Errorf("request: %w", err) } + req.Header.Set("Content-Type", "application/json") + if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } @@ -70,11 +75,14 @@ func (c *Client) CreateSession(nick string) (*SessionResponse, error) { if err != nil { return nil, err } + var resp SessionResponse if err := json.Unmarshal(data, &resp); err != nil { return nil, fmt.Errorf("decode session: %w", err) } + c.Token = resp.Token + return &resp, nil } @@ -84,16 +92,19 @@ func (c *Client) GetState() (*StateResponse, error) { if err != nil { return nil, err } + var resp StateResponse if err := json.Unmarshal(data, &resp); err != nil { return nil, fmt.Errorf("decode state: %w", err) } + return &resp, nil } // SendMessage sends a message (any IRC command). func (c *Client) SendMessage(msg *Message) error { _, err := c.do("POST", "/api/v1/messages", msg) + return err } @@ -106,17 +117,19 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { if afterID != "" { params.Set("after", afterID) } - params.Set("timeout", fmt.Sprintf("%d", timeout)) + + params.Set("timeout", strconv.Itoa(timeout)) path := "/api/v1/messages" if len(params) > 0 { path += "?" + params.Encode() } - req, err := http.NewRequest("GET", c.BaseURL+path, nil) + req, err := http.NewRequest(http.MethodGet, c.BaseURL+path, nil) if err != nil { return nil, err } + req.Header.Set("Authorization", "Bearer "+c.Token) resp, err := client.Do(req) @@ -139,9 +152,11 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { if err := json.Unmarshal(data, &msgs); err != nil { // Try wrapped format. var wrapped MessagesResponse - if err2 := json.Unmarshal(data, &wrapped); err2 != nil { + err2 := json.Unmarshal(data, &wrapped) + if err2 != nil { return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data)) } + msgs = wrapped.Messages } @@ -164,10 +179,12 @@ func (c *Client) ListChannels() ([]Channel, error) { if err != nil { return nil, err } + var channels []Channel if err := json.Unmarshal(data, &channels); err != nil { return nil, err } + return channels, nil } @@ -177,16 +194,19 @@ func (c *Client) GetMembers(channel string) ([]string, error) { if err != nil { return nil, err } + var members []string if err := json.Unmarshal(data, &members); err != nil { // Try object format. - var obj map[string]interface{} - if err2 := json.Unmarshal(data, &obj); err2 != nil { + var obj map[string]any + err2 := json.Unmarshal(data, &obj) + if err2 != nil { return nil, err } // Extract member names from whatever format. return nil, fmt.Errorf("unexpected members format: %s", string(data)) } + return members, nil } @@ -196,9 +216,11 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) { if err != nil { return nil, err } + var info ServerInfo if err := json.Unmarshal(data, &info); err != nil { return nil, err } + return &info, nil } diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go index 1655d79..335b7d3 100644 --- a/cmd/chat-cli/api/types.go +++ b/cmd/chat-cli/api/types.go @@ -25,26 +25,27 @@ type StateResponse struct { // Message represents a chat message envelope. type Message struct { - Command string `json:"command"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Params []string `json:"params,omitempty"` - Body interface{} `json:"body,omitempty"` - ID string `json:"id,omitempty"` - TS string `json:"ts,omitempty"` - Meta interface{} `json:"meta,omitempty"` + Command string `json:"command"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Params []string `json:"params,omitempty"` + Body any `json:"body,omitempty"` + ID string `json:"id,omitempty"` + TS string `json:"ts,omitempty"` + Meta any `json:"meta,omitempty"` } // BodyLines returns the body as a slice of strings (for text messages). func (m *Message) BodyLines() []string { switch v := m.Body.(type) { - case []interface{}: + case []any: lines := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { lines = append(lines, s) } } + return lines case []string: return v @@ -79,5 +80,6 @@ func (m *Message) ParseTS() time.Time { if err != nil { return time.Now() } + return t } diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index 6dfa1f3..f4594d3 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -35,7 +35,8 @@ func main() { app.ui.AddStatus("Welcome to chat-cli — an IRC-style client") app.ui.AddStatus("Type [yellow]/connect [white] to begin, or [yellow]/help[white] for commands") - if err := app.ui.Run(); err != nil { + err := app.ui.Run() + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -44,6 +45,7 @@ func main() { func (a *App) handleInput(text string) { if strings.HasPrefix(text, "/") { a.handleCommand(text) + return } @@ -55,10 +57,13 @@ func (a *App) handleInput(text string) { if !connected { a.ui.AddStatus("[red]Not connected. Use /connect ") + return } + if target == "" { a.ui.AddStatus("[red]No target. Use /join #channel or /query nick") + return } @@ -69,11 +74,13 @@ func (a *App) handleInput(text string) { }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err)) + return } // Echo locally. ts := time.Now().Format("15:04") + a.mu.Lock() nick := a.nick a.mu.Unlock() @@ -83,6 +90,7 @@ func (a *App) handleInput(text string) { func (a *App) handleCommand(text string) { parts := strings.SplitN(text, " ", 2) cmd := strings.ToLower(parts[0]) + args := "" if len(parts) > 1 { args = parts[1] @@ -114,15 +122,17 @@ func (a *App) handleCommand(text string) { case "/help": a.cmdHelp() default: - a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd)) + a.ui.AddStatus("[red]Unknown command: " + cmd) } } func (a *App) cmdConnect(serverURL string) { if serverURL == "" { a.ui.AddStatus("[red]Usage: /connect ") + return } + serverURL = strings.TrimRight(serverURL, "/") a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL)) @@ -132,9 +142,11 @@ func (a *App) cmdConnect(serverURL string) { a.mu.Unlock() client := api.NewClient(serverURL) + resp, err := client.CreateSession(nick) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err)) + return } @@ -156,8 +168,10 @@ func (a *App) cmdConnect(serverURL string) { func (a *App) cmdNick(nick string) { if nick == "" { a.ui.AddStatus("[red]Usage: /nick ") + return } + a.mu.Lock() connected := a.connected a.mu.Unlock() @@ -167,6 +181,7 @@ func (a *App) cmdNick(nick string) { a.nick = nick a.mu.Unlock() a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick)) + return } @@ -176,6 +191,7 @@ func (a *App) cmdNick(nick string) { }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err)) + return } @@ -184,14 +200,16 @@ func (a *App) cmdNick(nick string) { target := a.target a.mu.Unlock() a.ui.SetStatus(nick, target, "connected") - a.ui.AddStatus(fmt.Sprintf("Nick changed to %s", nick)) + a.ui.AddStatus("Nick changed to " + nick) } func (a *App) cmdJoin(channel string) { if channel == "" { a.ui.AddStatus("[red]Usage: /join #channel") + return } + if !strings.HasPrefix(channel, "#") { channel = "#" + channel } @@ -199,14 +217,17 @@ func (a *App) cmdJoin(channel string) { a.mu.Lock() connected := a.connected a.mu.Unlock() + if !connected { a.ui.AddStatus("[red]Not connected") + return } err := a.client.JoinChannel(channel) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err)) + return } @@ -216,7 +237,7 @@ func (a *App) cmdJoin(channel string) { a.mu.Unlock() a.ui.SwitchToBuffer(channel) - a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Joined %s", channel)) + a.ui.AddLine(channel, "[yellow]*** Joined "+channel) a.ui.SetStatus(nick, channel, "connected") } @@ -225,30 +246,36 @@ func (a *App) cmdPart(channel string) { if channel == "" { channel = a.target } + connected := a.connected a.mu.Unlock() if channel == "" || !strings.HasPrefix(channel, "#") { a.ui.AddStatus("[red]No channel to part") + return } + if !connected { a.ui.AddStatus("[red]Not connected") + return } err := a.client.PartChannel(channel) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err)) + return } - a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", channel)) + a.ui.AddLine(channel, "[yellow]*** Left "+channel) a.mu.Lock() if a.target == channel { a.target = "" } + nick := a.nick a.mu.Unlock() @@ -260,16 +287,20 @@ func (a *App) cmdMsg(args string) { parts := strings.SplitN(args, " ", 2) if len(parts) < 2 { a.ui.AddStatus("[red]Usage: /msg ") + return } + target, text := parts[0], parts[1] a.mu.Lock() connected := a.connected nick := a.nick a.mu.Unlock() + if !connected { a.ui.AddStatus("[red]Not connected") + return } @@ -280,6 +311,7 @@ func (a *App) cmdMsg(args string) { }) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err)) + return } @@ -290,6 +322,7 @@ func (a *App) cmdMsg(args string) { func (a *App) cmdQuery(nick string) { if nick == "" { a.ui.AddStatus("[red]Usage: /query ") + return } @@ -310,10 +343,13 @@ func (a *App) cmdTopic(args string) { if !connected { a.ui.AddStatus("[red]Not connected") + return } + if !strings.HasPrefix(target, "#") { a.ui.AddStatus("[red]Not in a channel") + return } @@ -326,6 +362,7 @@ func (a *App) cmdTopic(args string) { if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err)) } + return } @@ -347,16 +384,20 @@ func (a *App) cmdNames() { if !connected { a.ui.AddStatus("[red]Not connected") + return } + if !strings.HasPrefix(target, "#") { a.ui.AddStatus("[red]Not in a channel") + return } members, err := a.client.GetMembers(target) if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err)) + return } @@ -370,27 +411,33 @@ func (a *App) cmdList() { if !connected { a.ui.AddStatus("[red]Not connected") + return } channels, err := a.client.ListChannels() if err != nil { a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err)) + return } a.ui.AddStatus("[cyan]*** Channel list:") + for _, ch := range channels { a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic)) } + a.ui.AddStatus("[cyan]*** End of channel list") } func (a *App) cmdWindow(args string) { if args == "" { a.ui.AddStatus("[red]Usage: /window ") + return } + n := 0 fmt.Sscanf(args, "%d", &n) a.ui.SwitchBuffer(n) @@ -400,6 +447,7 @@ func (a *App) cmdWindow(args string) { // Update target to the buffer name. // Needs to be done carefully. } + nick := a.nick a.mu.Unlock() @@ -422,6 +470,7 @@ func (a *App) cmdQuit() { if a.connected && a.client != nil { _ = a.client.SendMessage(&api.Message{Command: "QUIT"}) } + if a.stopPoll != nil { close(a.stopPoll) } @@ -473,11 +522,13 @@ func (a *App) pollLoop() { if err != nil { // Transient error — retry after delay. time.Sleep(2 * time.Second) + continue } for _, msg := range msgs { a.handleServerMessage(&msg) + if msg.ID != "" { a.mu.Lock() a.lastMsgID = msg.ID @@ -489,6 +540,7 @@ func (a *App) pollLoop() { func (a *App) handleServerMessage(msg *api.Message) { ts := "" + if msg.TS != "" { t := msg.ParseTS() ts = t.Local().Format("15:04") @@ -504,15 +556,18 @@ func (a *App) handleServerMessage(msg *api.Message) { case "PRIVMSG": lines := msg.BodyLines() text := strings.Join(lines, " ") + if msg.From == myNick { // Skip our own echoed messages (already displayed locally). return } + target := msg.To if !strings.HasPrefix(target, "#") { // DM — use sender's nick as buffer name. target = msg.From } + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) case "JOIN": @@ -524,6 +579,7 @@ func (a *App) handleServerMessage(msg *api.Message) { case "PART": target := msg.To lines := msg.BodyLines() + reason := strings.Join(lines, " ") if target != "" { if reason != "" { @@ -535,6 +591,7 @@ func (a *App) handleServerMessage(msg *api.Message) { case "QUIT": lines := msg.BodyLines() + reason := strings.Join(lines, " ") if reason != "" { a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) @@ -544,10 +601,12 @@ func (a *App) handleServerMessage(msg *api.Message) { case "NICK": lines := msg.BodyLines() + newNick := "" if len(lines) > 0 { newNick = lines[0] } + if msg.From == myNick && newNick != "" { a.mu.Lock() a.nick = newNick @@ -555,6 +614,7 @@ func (a *App) handleServerMessage(msg *api.Message) { a.mu.Unlock() a.ui.SetStatus(newNick, target, "connected") } + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) case "NOTICE": @@ -564,6 +624,7 @@ func (a *App) handleServerMessage(msg *api.Message) { case "TOPIC": lines := msg.BodyLines() + text := strings.Join(lines, " ") if msg.To != "" { a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) @@ -572,6 +633,7 @@ func (a *App) handleServerMessage(msg *api.Message) { default: // Numeric replies and other messages → status window. lines := msg.BodyLines() + text := strings.Join(lines, " ") if text != "" { a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index 16449f2..aa8fafe 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -65,7 +65,9 @@ func NewUI() *UI { if text == "" { return } + ui.input.SetText("") + if ui.onInput != nil { ui.onInput(text) } @@ -79,9 +81,11 @@ func NewUI() *UI { if r >= '0' && r <= '9' { idx := int(r - '0') ui.SwitchBuffer(idx) + return nil } } + return event }) @@ -121,6 +125,7 @@ func (ui *UI) AddLine(bufferName string, line string) { // Mark unread if not currently viewing this buffer. if ui.buffers[ui.currentBuffer] != buf { buf.Unread++ + ui.refreshStatus() } @@ -143,13 +148,17 @@ func (ui *UI) SwitchBuffer(n int) { if n < 0 || n >= len(ui.buffers) { return } + ui.currentBuffer = n buf := ui.buffers[n] buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { fmt.Fprintln(ui.messages, line) } + ui.messages.ScrollToEnd() ui.refreshStatus() }) @@ -162,14 +171,19 @@ func (ui *UI) SwitchToBuffer(name string) { for i, b := range ui.buffers { if b == buf { ui.currentBuffer = i + break } } + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { fmt.Fprintln(ui.messages, line) } + ui.messages.ScrollToEnd() ui.refreshStatus() }) @@ -189,11 +203,13 @@ func (ui *UI) refreshStatus() { func (ui *UI) refreshStatusWith(nick, target, connStatus string) { var unreadParts []string + for i, buf := range ui.buffers { if buf.Unread > 0 { unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread)) } } + unread := "" if len(unreadParts) > 0 { unread = " [Act: " + strings.Join(unreadParts, ",") + "]" @@ -212,8 +228,10 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer { return buf } } + buf := &Buffer{Name: name} ui.buffers = append(ui.buffers, buf) + return buf } @@ -229,5 +247,6 @@ func (ui *UI) BufferIndex(name string) int { return i } } + return -1 } diff --git a/internal/db/db.go b/internal/db/db.go index a6663ff..7313cb8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -90,6 +90,7 @@ func NewTest(dsn string) (*Database, error) { // Item 9: Enable foreign keys if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil { _ = d.Close() + return nil, fmt.Errorf("enable foreign keys: %w", err) } @@ -219,6 +220,7 @@ func (s *Database) DeleteAuthToken( _, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token = ?`, token, ) + return err } @@ -231,6 +233,7 @@ func (s *Database) UpdateUserLastSeen( `UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, userID, ) + return err } @@ -394,6 +397,7 @@ func (s *Database) DequeueMessages( if err != nil { return nil, err } + defer func() { _ = rows.Close() }() entries := []*models.MessageQueueEntry{} @@ -423,7 +427,7 @@ func (s *Database) AckMessages( } placeholders := make([]string, len(entryIDs)) - args := make([]interface{}, len(entryIDs)) + args := make([]any, len(entryIDs)) for i, id := range entryIDs { placeholders[i] = "?" diff --git a/internal/db/queries.go b/internal/db/queries.go index ce63057..fdf3e3f 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -62,7 +62,8 @@ func (s *Database) ListChannels(ctx context.Context, userID string) ([]ChannelIn for rows.Next() { var ch ChannelInfo - if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic) + if err != nil { return nil, err } @@ -95,7 +96,8 @@ func (s *Database) ChannelMembers(ctx context.Context, channelID string) ([]Memb for rows.Next() { var m MemberInfo - if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil { + err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen) + if err != nil { return nil, err } @@ -182,7 +184,8 @@ func (s *Database) PollMessages(ctx context.Context, userID string, afterTS stri for rows.Next() { var m MessageInfo - if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt) + if err != nil { return nil, err } @@ -200,7 +203,7 @@ func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeT var rows interface { Next() bool - Scan(dest ...interface{}) error + Scan(dest ...any) error Close() error Err() error } @@ -233,7 +236,8 @@ func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeT for rows.Next() { var m MessageInfo - if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt) + if err != nil { return nil, err } @@ -256,7 +260,7 @@ func (s *Database) GetDMsBefore(ctx context.Context, userA, userB string, before var rows interface { Next() bool - Scan(dest ...interface{}) error + Scan(dest ...any) error Close() error Err() error } @@ -290,7 +294,8 @@ func (s *Database) GetDMsBefore(ctx context.Context, userA, userB string, before for rows.Next() { var m MessageInfo - if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { + err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt) + if err != nil { return nil, err } @@ -341,7 +346,8 @@ func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) { for rows.Next() { var ch ChannelInfo - if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic) + if err != nil { return nil, err } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index de53a92..432416c 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -34,6 +34,7 @@ func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (string, uid, nick, err := s.authUser(r) if err != nil { s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) + return "", "", false } @@ -52,6 +53,7 @@ func (s *Handlers) HandleCreateSession() http.HandlerFunc { type request struct { Nick string `json:"nick"` } + type response struct { ID string `json:"id"` Nick string `json:"nick"` @@ -62,12 +64,14 @@ func (s *Handlers) HandleCreateSession() http.HandlerFunc { var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return } req.Nick = strings.TrimSpace(req.Nick) if req.Nick == "" || len(req.Nick) > 32 { s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return } @@ -77,6 +81,7 @@ func (s *Handlers) HandleCreateSession() http.HandlerFunc { if err != nil { if strings.Contains(err.Error(), "UNIQUE") { s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict) + return } @@ -162,6 +167,7 @@ func (s *Handlers) HandleChannelMembers() http.HandlerFunc { "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return } @@ -205,10 +211,10 @@ func (s *Handlers) HandleGetMessages() http.HandlerFunc { // The "command" field dispatches to the appropriate logic. func (s *Handlers) HandleSendCommand() http.HandlerFunc { type request struct { - Command string `json:"command"` - To string `json:"to"` - Params []string `json:"params,omitempty"` - Body interface{} `json:"body,omitempty"` + Command string `json:"command"` + To string `json:"to"` + Params []string `json:"params,omitempty"` + Body any `json:"body,omitempty"` } return func(w http.ResponseWriter, r *http.Request) { @@ -218,8 +224,10 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { } var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return } @@ -229,7 +237,7 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { // Helper to extract body as string lines. bodyLines := func() []string { switch v := req.Body.(type) { - case []interface{}: + case []any: lines := make([]string, 0, len(v)) for _, item := range v { @@ -267,6 +275,7 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { default: _ = nick // suppress unused warning + s.respondJSON(w, r, map[string]string{"error": "unknown command: " + req.Command}, http.StatusBadRequest) } } @@ -275,11 +284,13 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, nick, to string, lines []string) { if to == "" { s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return } if len(lines) == 0 { s.respondJSON(w, r, map[string]string{"error": "body required"}, http.StatusBadRequest) + return } @@ -293,6 +304,7 @@ func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, ni "SELECT id FROM channels WHERE name = ?", to).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return } @@ -310,6 +322,7 @@ func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, ni targetUser, err := s.params.Database.GetUserByNick(r.Context(), to) if err != nil { s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return } @@ -328,6 +341,7 @@ func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, ni func (s *Handlers) handleJoin(w http.ResponseWriter, r *http.Request, uid, to string) { if to == "" { s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return } @@ -357,6 +371,7 @@ func (s *Handlers) handleJoin(w http.ResponseWriter, r *http.Request, uid, to st func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to string) { if to == "" { s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return } @@ -371,6 +386,7 @@ func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to st "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return } @@ -387,18 +403,22 @@ func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to st func (s *Handlers) handleNick(w http.ResponseWriter, r *http.Request, uid string, lines []string) { if len(lines) == 0 { s.respondJSON(w, r, map[string]string{"error": "body required (new nick)"}, http.StatusBadRequest) + return } newNick := strings.TrimSpace(lines[0]) if newNick == "" || len(newNick) > 32 { s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return } - if err := s.params.Database.ChangeNick(r.Context(), uid, newNick); err != nil { + err := s.params.Database.ChangeNick(r.Context(), uid, newNick) + if err != nil { if strings.Contains(err.Error(), "UNIQUE") { s.respondJSON(w, r, map[string]string{"error": "nick already in use"}, http.StatusConflict) + return } @@ -414,11 +434,13 @@ func (s *Handlers) handleNick(w http.ResponseWriter, r *http.Request, uid string func (s *Handlers) handleTopic(w http.ResponseWriter, r *http.Request, uid, to string, lines []string) { if to == "" { s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return } if len(lines) == 0 { s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest) + return } @@ -429,7 +451,8 @@ func (s *Handlers) handleTopic(w http.ResponseWriter, r *http.Request, uid, to s channel = "#" + channel } - if err := s.params.Database.SetTopic(r.Context(), channel, uid, topic); err != nil { + err := s.params.Database.SetTopic(r.Context(), channel, uid, topic) + if err != nil { s.log.Error("set topic failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) @@ -450,6 +473,7 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc { target := r.URL.Query().Get("target") if target == "" { s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest) + return } @@ -468,6 +492,7 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc { "SELECT id FROM channels WHERE name = ?", target).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return } @@ -485,6 +510,7 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc { targetUser, err := s.params.Database.GetUserByNick(r.Context(), target) if err != nil { s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return } diff --git a/internal/models/auth_token.go b/internal/models/auth_token.go index f1646e2..2b8b4b9 100644 --- a/internal/models/auth_token.go +++ b/internal/models/auth_token.go @@ -2,7 +2,7 @@ package models import ( "context" - "fmt" + "errors" "time" ) @@ -23,5 +23,5 @@ func (t *AuthToken) User(ctx context.Context) (*User, error) { return ul.GetUserByID(ctx, t.UserID) } - return nil, fmt.Errorf("user lookup not available") + return nil, errors.New("user lookup not available") } diff --git a/internal/models/channel_member.go b/internal/models/channel_member.go index f93ed9d..e98cf7f 100644 --- a/internal/models/channel_member.go +++ b/internal/models/channel_member.go @@ -2,7 +2,7 @@ package models import ( "context" - "fmt" + "errors" "time" ) @@ -23,7 +23,7 @@ func (cm *ChannelMember) User(ctx context.Context) (*User, error) { return ul.GetUserByID(ctx, cm.UserID) } - return nil, fmt.Errorf("user lookup not available") + return nil, errors.New("user lookup not available") } // Channel returns the full Channel for this membership. @@ -32,5 +32,5 @@ func (cm *ChannelMember) Channel(ctx context.Context) (*Channel, error) { return cl.GetChannelByID(ctx, cm.ChannelID) } - return nil, fmt.Errorf("channel lookup not available") + return nil, errors.New("channel lookup not available") } diff --git a/internal/models/session.go b/internal/models/session.go index 42231d9..e6aaf94 100644 --- a/internal/models/session.go +++ b/internal/models/session.go @@ -2,7 +2,7 @@ package models import ( "context" - "fmt" + "errors" "time" ) @@ -23,5 +23,5 @@ func (s *Session) User(ctx context.Context) (*User, error) { return ul.GetUserByID(ctx, s.UserID) } - return nil, fmt.Errorf("user lookup not available") + return nil, errors.New("user lookup not available") } diff --git a/internal/server/routes.go b/internal/server/routes.go index e211492..2cd1530 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -71,16 +71,20 @@ func (s *Server) SetupRoutes() { s.log.Error("failed to get web dist filesystem", "error", err) } else { fileServer := http.FileServer(http.FS(distFS)) + s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { // Try to serve the file; if not found, serve index.html for SPA routing f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:]) if err != nil || len(f) == 0 { indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(indexHTML) + return } + fileServer.ServeHTTP(w, r) }) } -- 2.49.1 From ed96c6ccdeb1f79397d4a87b2b650afa2701d62c Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 20 Feb 2026 03:02:32 -0800 Subject: [PATCH 03/22] fix: format cmd/chat-cli/api/client.go with gofmt --- cmd/chat-cli/api/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index f7c96b3..d7d07de 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -5,9 +5,9 @@ import ( "encoding/json" "fmt" "io" - "strconv" "net/http" "net/url" + "strconv" "time" ) -- 2.49.1 From 3adc5479b79874d319e5d3f930f118ebc941e93b Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:14:46 -0800 Subject: [PATCH 04/22] fix: resolve wsl_v5 lint issues --- cmd/chat-cli/api/client.go | 2 ++ internal/db/queries.go | 6 ++++++ internal/handlers/api.go | 1 + 3 files changed, 9 insertions(+) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index d7d07de..723f771 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -152,6 +152,7 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { if err := json.Unmarshal(data, &msgs); err != nil { // Try wrapped format. var wrapped MessagesResponse + err2 := json.Unmarshal(data, &wrapped) if err2 != nil { return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data)) @@ -199,6 +200,7 @@ func (c *Client) GetMembers(channel string) ([]string, error) { if err := json.Unmarshal(data, &members); err != nil { // Try object format. var obj map[string]any + err2 := json.Unmarshal(data, &obj) if err2 != nil { return nil, err diff --git a/internal/db/queries.go b/internal/db/queries.go index fdf3e3f..42cfab2 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -62,6 +62,7 @@ func (s *Database) ListChannels(ctx context.Context, userID string) ([]ChannelIn for rows.Next() { var ch ChannelInfo + err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic) if err != nil { return nil, err @@ -96,6 +97,7 @@ func (s *Database) ChannelMembers(ctx context.Context, channelID string) ([]Memb for rows.Next() { var m MemberInfo + err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen) if err != nil { return nil, err @@ -184,6 +186,7 @@ func (s *Database) PollMessages(ctx context.Context, userID string, afterTS stri for rows.Next() { var m MessageInfo + err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt) if err != nil { return nil, err @@ -236,6 +239,7 @@ func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeT for rows.Next() { var m MessageInfo + err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt) if err != nil { return nil, err @@ -294,6 +298,7 @@ func (s *Database) GetDMsBefore(ctx context.Context, userA, userB string, before for rows.Next() { var m MessageInfo + err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt) if err != nil { return nil, err @@ -346,6 +351,7 @@ func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) { for rows.Next() { var ch ChannelInfo + err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic) if err != nil { return nil, err diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 432416c..9cc4fca 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -224,6 +224,7 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { } var req request + err := json.NewDecoder(r.Body).Decode(&req) if err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) -- 2.49.1 From d8c63640f50c76d4b1543057c092ca0231911453 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:15:10 -0800 Subject: [PATCH 05/22] fix: resolve wastedassign and gosmopolitan lint issues --- cmd/chat-cli/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index f4594d3..c20efd0 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -539,11 +539,11 @@ func (a *App) pollLoop() { } func (a *App) handleServerMessage(msg *api.Message) { - ts := "" + var ts string if msg.TS != "" { t := msg.ParseTS() - ts = t.Local().Format("15:04") + ts = t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally } else { ts = time.Now().Format("15:04") } -- 2.49.1 From 6ca3ad0e99a3b5367cfa94b6ebed616ae0241e56 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:15:25 -0800 Subject: [PATCH 06/22] fix: resolve tagliatelle lint issues --- cmd/chat-cli/api/types.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go index 335b7d3..d5d0a78 100644 --- a/cmd/chat-cli/api/types.go +++ b/cmd/chat-cli/api/types.go @@ -9,16 +9,16 @@ type SessionRequest struct { // SessionResponse is the response from POST /api/v1/session. type SessionResponse struct { - SessionID string `json:"session_id"` - ClientID string `json:"client_id"` + SessionID string `json:"sessionId"` + ClientID string `json:"clientId"` Nick string `json:"nick"` Token string `json:"token"` } // StateResponse is the response from GET /api/v1/state. type StateResponse struct { - SessionID string `json:"session_id"` - ClientID string `json:"client_id"` + SessionID string `json:"sessionId"` + ClientID string `json:"clientId"` Nick string `json:"nick"` Channels []string `json:"channels"` } @@ -59,7 +59,7 @@ type Channel struct { Name string `json:"name"` Topic string `json:"topic"` Members int `json:"members"` - CreatedAt string `json:"created_at"` + CreatedAt string `json:"createdAt"` } // ServerInfo is the response from GET /api/v1/server. -- 2.49.1 From 5fad27ff4c05651d0698257b55456736249f0e01 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:15:37 -0800 Subject: [PATCH 07/22] fix: resolve lll lint issues --- internal/db/queries.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index 42cfab2..d72f661 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -199,7 +199,9 @@ func (s *Database) PollMessages(ctx context.Context, userID string, afterTS stri } // GetMessagesBefore returns channel messages before a given timestamp (for history scrollback). -func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeTS string, limit int) ([]MessageInfo, error) { +func (s *Database) GetMessagesBefore( + ctx context.Context, target string, beforeTS string, limit int, +) ([]MessageInfo, error) { if limit <= 0 { limit = 50 } @@ -257,7 +259,9 @@ func (s *Database) GetMessagesBefore(ctx context.Context, target string, beforeT } // GetDMsBefore returns DMs between two users before a given timestamp. -func (s *Database) GetDMsBefore(ctx context.Context, userA, userB string, beforeTS string, limit int) ([]MessageInfo, error) { +func (s *Database) GetDMsBefore( + ctx context.Context, userA, userB string, beforeTS string, limit int, +) ([]MessageInfo, error) { if limit <= 0 { limit = 50 } -- 2.49.1 From f125a3f591097d3901c13df0d0945685e87cc192 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:16:09 -0800 Subject: [PATCH 08/22] fix: resolve revive lint issues --- cmd/chat-cli/api/client.go | 1 + cmd/chat-cli/api/types.go | 2 +- cmd/chat-cli/main.go | 6 +----- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 723f771..1145220 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -1,3 +1,4 @@ +// Package api provides the HTTP client for the chat server API. package api import ( diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go index d5d0a78..ee1d487 100644 --- a/cmd/chat-cli/api/types.go +++ b/cmd/chat-cli/api/types.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name "api" is conventional for API client packages import "time" diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index c20efd0..d0db948 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -1,3 +1,4 @@ +// Package main implements the chat-cli terminal client. package main import ( @@ -443,11 +444,6 @@ func (a *App) cmdWindow(args string) { a.ui.SwitchBuffer(n) a.mu.Lock() - if n < a.ui.BufferCount() && n >= 0 { - // Update target to the buffer name. - // Needs to be done carefully. - } - nick := a.nick a.mu.Unlock() -- 2.49.1 From 3d968a1102f1cf59c8a251102f9a4e5f33cce5ad Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:17:09 -0800 Subject: [PATCH 09/22] fix: resolve noinlineerr lint issues --- cmd/chat-cli/api/client.go | 24 ++++++++++++++++++------ internal/db/db.go | 6 ++++-- internal/handlers/api.go | 9 ++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 1145220..2d1174f 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -78,7 +78,9 @@ func (c *Client) CreateSession(nick string) (*SessionResponse, error) { } var resp SessionResponse - if err := json.Unmarshal(data, &resp); err != nil { + + err = json.Unmarshal(data, &resp) + if err != nil { return nil, fmt.Errorf("decode session: %w", err) } @@ -95,7 +97,9 @@ func (c *Client) GetState() (*StateResponse, error) { } var resp StateResponse - if err := json.Unmarshal(data, &resp); err != nil { + + err = json.Unmarshal(data, &resp) + if err != nil { return nil, fmt.Errorf("decode state: %w", err) } @@ -150,7 +154,9 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { // The server may return an array directly or wrapped. var msgs []Message - if err := json.Unmarshal(data, &msgs); err != nil { + + err = json.Unmarshal(data, &msgs) + if err != nil { // Try wrapped format. var wrapped MessagesResponse @@ -183,7 +189,9 @@ func (c *Client) ListChannels() ([]Channel, error) { } var channels []Channel - if err := json.Unmarshal(data, &channels); err != nil { + + err = json.Unmarshal(data, &channels) + if err != nil { return nil, err } @@ -198,7 +206,9 @@ func (c *Client) GetMembers(channel string) ([]string, error) { } var members []string - if err := json.Unmarshal(data, &members); err != nil { + + err = json.Unmarshal(data, &members) + if err != nil { // Try object format. var obj map[string]any @@ -221,7 +231,9 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) { } var info ServerInfo - if err := json.Unmarshal(data, &info); err != nil { + + err = json.Unmarshal(data, &info) + if err != nil { return nil, err } diff --git a/internal/db/db.go b/internal/db/db.go index 7313cb8..c0009a5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -553,7 +553,8 @@ func (s *Database) connect(ctx context.Context) error { s.log.Info("database connected") // Item 9: Enable foreign keys on every connection - if _, err := s.db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + _, err = s.db.ExecContext(ctx, "PRAGMA foreign_keys = ON") + if err != nil { return fmt.Errorf("enable foreign keys: %w", err) } @@ -709,7 +710,8 @@ func (s *Database) applyMigrations( ) } - if err := tx.Commit(); err != nil { + err = tx.Commit() + if err != nil { return fmt.Errorf( "commit migration %d: %w", m.version, err, ) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 9cc4fca..585a184 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -62,7 +62,8 @@ func (s *Handlers) HandleCreateSession() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) return @@ -359,7 +360,8 @@ func (s *Handlers) handleJoin(w http.ResponseWriter, r *http.Request, uid, to st return } - if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { + err = s.params.Database.JoinChannel(r.Context(), chID, uid) + if err != nil { s.log.Error("join channel failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) @@ -391,7 +393,8 @@ func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to st return } - if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { + err = s.params.Database.PartChannel(r.Context(), chID, uid) + if err != nil { s.log.Error("part channel failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) -- 2.49.1 From dd5e9e61ab6f2109c7ef702c64927f6337b9eefd Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:17:50 -0800 Subject: [PATCH 10/22] fix: resolve errcheck lint issues --- cmd/chat-cli/api/client.go | 5 +++-- cmd/chat-cli/main.go | 2 +- cmd/chat-cli/ui.go | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 2d1174f..95141b5 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -56,7 +56,7 @@ func (c *Client) do(method, path string, body any) ([]byte, error) { if err != nil { return nil, fmt.Errorf("http: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { @@ -141,7 +141,8 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { if err != nil { return nil, err } - defer resp.Body.Close() + + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index d0db948..78268dd 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -440,7 +440,7 @@ func (a *App) cmdWindow(args string) { } n := 0 - fmt.Sscanf(args, "%d", &n) + _, _ = fmt.Sscanf(args, "%d", &n) a.ui.SwitchBuffer(n) a.mu.Lock() diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index aa8fafe..a1c5dac 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -131,7 +131,7 @@ func (ui *UI) AddLine(bufferName string, line string) { // If viewing this buffer, append to display. if ui.buffers[ui.currentBuffer] == buf { - fmt.Fprintln(ui.messages, line) + _, _ = fmt.Fprintln(ui.messages, line) } }) } @@ -156,7 +156,7 @@ func (ui *UI) SwitchBuffer(n int) { ui.messages.Clear() for _, line := range buf.Lines { - fmt.Fprintln(ui.messages, line) + _, _ = fmt.Fprintln(ui.messages, line) } ui.messages.ScrollToEnd() @@ -181,7 +181,7 @@ func (ui *UI) SwitchToBuffer(name string) { ui.messages.Clear() for _, line := range buf.Lines { - fmt.Fprintln(ui.messages, line) + _, _ = fmt.Fprintln(ui.messages, line) } ui.messages.ScrollToEnd() @@ -218,7 +218,7 @@ func (ui *UI) refreshStatusWith(nick, target, connStatus string) { bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name) ui.statusBar.Clear() - fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s", + _, _ = fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s", connStatus, nick, bufInfo, target, unread) } -- 2.49.1 From 4fe5227cbf0a9c797db1544b6ba9465e91d63c1c Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:18:56 -0800 Subject: [PATCH 11/22] fix: resolve err113 lint issues with sentinel errors --- cmd/chat-cli/api/client.go | 14 +++++++++++--- internal/models/auth_token.go | 3 +-- internal/models/channel_member.go | 5 ++--- internal/models/model.go | 8 ++++++++ internal/models/session.go | 3 +-- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 95141b5..01ce582 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -4,6 +4,7 @@ package api import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,6 +13,13 @@ import ( "time" ) +var ( + // ErrHTTPStatus is returned when the server responds with an error status code. + ErrHTTPStatus = errors.New("HTTP error") + // ErrUnexpectedFormat is returned when the response format is unexpected. + ErrUnexpectedFormat = errors.New("unexpected format") +) + // Client wraps HTTP calls to the chat server API. type Client struct { BaseURL string @@ -64,7 +72,7 @@ func (c *Client) do(method, path string, body any) ([]byte, error) { } if resp.StatusCode >= 400 { - return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + return data, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data)) } return data, nil @@ -150,7 +158,7 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + return nil, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data)) } // The server may return an array directly or wrapped. @@ -218,7 +226,7 @@ func (c *Client) GetMembers(channel string) ([]string, error) { return nil, err } // Extract member names from whatever format. - return nil, fmt.Errorf("unexpected members format: %s", string(data)) + return nil, fmt.Errorf("%w: members: %s", ErrUnexpectedFormat, string(data)) } return members, nil diff --git a/internal/models/auth_token.go b/internal/models/auth_token.go index 2b8b4b9..c2c3fd1 100644 --- a/internal/models/auth_token.go +++ b/internal/models/auth_token.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "time" ) @@ -23,5 +22,5 @@ func (t *AuthToken) User(ctx context.Context) (*User, error) { return ul.GetUserByID(ctx, t.UserID) } - return nil, errors.New("user lookup not available") + return nil, ErrUserLookupNotAvailable } diff --git a/internal/models/channel_member.go b/internal/models/channel_member.go index e98cf7f..59586c7 100644 --- a/internal/models/channel_member.go +++ b/internal/models/channel_member.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "time" ) @@ -23,7 +22,7 @@ func (cm *ChannelMember) User(ctx context.Context) (*User, error) { return ul.GetUserByID(ctx, cm.UserID) } - return nil, errors.New("user lookup not available") + return nil, ErrUserLookupNotAvailable } // Channel returns the full Channel for this membership. @@ -32,5 +31,5 @@ func (cm *ChannelMember) Channel(ctx context.Context) (*Channel, error) { return cl.GetChannelByID(ctx, cm.ChannelID) } - return nil, errors.New("channel lookup not available") + return nil, ErrChannelLookupNotAvailable } diff --git a/internal/models/model.go b/internal/models/model.go index b65c6e8..ca5c7bc 100644 --- a/internal/models/model.go +++ b/internal/models/model.go @@ -6,6 +6,14 @@ package models import ( "context" "database/sql" + "errors" +) + +var ( + // ErrUserLookupNotAvailable is returned when the user lookup interface is not set. + ErrUserLookupNotAvailable = errors.New("user lookup not available") + // ErrChannelLookupNotAvailable is returned when the channel lookup interface is not set. + ErrChannelLookupNotAvailable = errors.New("channel lookup not available") ) // DB is the interface that models use to query the database. diff --git a/internal/models/session.go b/internal/models/session.go index e6aaf94..295def2 100644 --- a/internal/models/session.go +++ b/internal/models/session.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "time" ) @@ -23,5 +22,5 @@ func (s *Session) User(ctx context.Context) (*User, error) { return ul.GetUserByID(ctx, s.UserID) } - return nil, errors.New("user lookup not available") + return nil, ErrUserLookupNotAvailable } -- 2.49.1 From c6c5aaf48eb96c9c2f045dcf6a472575a52f47e8 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:19:19 -0800 Subject: [PATCH 12/22] fix: resolve noctx lint issues --- cmd/chat-cli/api/client.go | 5 +++-- internal/db/db.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 01ce582..51b5b0b 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -3,6 +3,7 @@ package api import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -49,7 +50,7 @@ func (c *Client) do(method, path string, body any) ([]byte, error) { bodyReader = bytes.NewReader(data) } - req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) + req, err := http.NewRequestWithContext(context.Background(), method, c.BaseURL+path, bodyReader) if err != nil { return nil, fmt.Errorf("request: %w", err) } @@ -138,7 +139,7 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { path += "?" + params.Encode() } - req, err := http.NewRequest(http.MethodGet, c.BaseURL+path, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, c.BaseURL+path, nil) if err != nil { return nil, err } diff --git a/internal/db/db.go b/internal/db/db.go index c0009a5..cf4376f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -88,7 +88,7 @@ func NewTest(dsn string) (*Database, error) { } // Item 9: Enable foreign keys - if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil { + if _, err := d.ExecContext(context.Background(), "PRAGMA foreign_keys = ON"); err != nil { _ = d.Close() return nil, fmt.Errorf("enable foreign keys: %w", err) -- 2.49.1 From f6ca154315787e050000aa32fde013f0c19157e4 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:20:26 -0800 Subject: [PATCH 13/22] fix: resolve mnd lint issues with named constants --- cmd/chat-cli/api/client.go | 17 +++++++++++++---- cmd/chat-cli/main.go | 19 ++++++++++++++----- internal/handlers/api.go | 4 +++- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 51b5b0b..6ff52db 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -14,6 +14,15 @@ import ( "time" ) +const ( + // httpClientTimeout is the default HTTP client timeout in seconds. + httpClientTimeout = 30 + // httpStatusErrorThreshold is the minimum status code considered an error. + httpStatusErrorThreshold = 400 + // pollTimeoutBuffer is extra seconds added to HTTP timeout beyond the poll timeout. + pollTimeoutBuffer = 5 +) + var ( // ErrHTTPStatus is returned when the server responds with an error status code. ErrHTTPStatus = errors.New("HTTP error") @@ -33,7 +42,7 @@ func NewClient(baseURL string) *Client { return &Client{ BaseURL: baseURL, HTTPClient: &http.Client{ - Timeout: 30 * time.Second, + Timeout: httpClientTimeout * time.Second, }, } } @@ -72,7 +81,7 @@ func (c *Client) do(method, path string, body any) ([]byte, error) { return nil, fmt.Errorf("read body: %w", err) } - if resp.StatusCode >= 400 { + if resp.StatusCode >= httpStatusErrorThreshold { return data, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data)) } @@ -125,7 +134,7 @@ func (c *Client) SendMessage(msg *Message) error { // PollMessages long-polls for new messages. func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { // Use a longer HTTP timeout than the server long-poll timeout. - client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second} + client := &http.Client{Timeout: time.Duration(timeout+pollTimeoutBuffer) * time.Second} params := url.Values{} if afterID != "" { @@ -158,7 +167,7 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { return nil, err } - if resp.StatusCode >= 400 { + if resp.StatusCode >= httpStatusErrorThreshold { return nil, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data)) } diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index 78268dd..99d4531 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -11,6 +11,15 @@ import ( "git.eeqj.de/sneak/chat/cmd/chat-cli/api" ) +const ( + // splitParts is the number of parts to split a command into (command + args). + splitParts = 2 + // pollTimeout is the long-poll timeout in seconds. + pollTimeout = 15 + // pollRetryDelay is the delay before retrying a failed poll. + pollRetryDelay = 2 * time.Second +) + // App holds the application state. type App struct { ui *UI @@ -89,7 +98,7 @@ func (a *App) handleInput(text string) { } func (a *App) handleCommand(text string) { - parts := strings.SplitN(text, " ", 2) + parts := strings.SplitN(text, " ", splitParts) cmd := strings.ToLower(parts[0]) args := "" @@ -285,8 +294,8 @@ func (a *App) cmdPart(channel string) { } func (a *App) cmdMsg(args string) { - parts := strings.SplitN(args, " ", 2) - if len(parts) < 2 { + parts := strings.SplitN(args, " ", splitParts) + if len(parts) < splitParts { a.ui.AddStatus("[red]Usage: /msg ") return @@ -514,10 +523,10 @@ func (a *App) pollLoop() { return } - msgs, err := client.PollMessages(lastID, 15) + msgs, err := client.PollMessages(lastID, pollTimeout) if err != nil { // Transient error — retry after delay. - time.Sleep(2 * time.Second) + time.Sleep(pollRetryDelay) continue } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 585a184..bbf3b1f 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -41,8 +41,10 @@ func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (string, return uid, nick, true } +const idBytes = 16 + func generateID() string { - b := make([]byte, 16) + b := make([]byte, idBytes) _, _ = rand.Read(b) return hex.EncodeToString(b) -- 2.49.1 From 0be5e80b858a722ce9d6cbcf52e11cd19832a5ec Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:21:02 -0800 Subject: [PATCH 14/22] fix: resolve rowserrcheck lint issues --- internal/db/queries.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index d72f661..6897182 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "fmt" "time" ) @@ -206,12 +207,7 @@ func (s *Database) GetMessagesBefore( limit = 50 } - var rows interface { - Next() bool - Scan(dest ...any) error - Close() error - Err() error - } + var rows *sql.Rows var err error @@ -266,12 +262,7 @@ func (s *Database) GetDMsBefore( limit = 50 } - var rows interface { - Next() bool - Scan(dest ...any) error - Close() error - Err() error - } + var rows *sql.Rows var err error -- 2.49.1 From 2c89b23beabf01e66eff474a8df511a84fbeadfc Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:21:14 -0800 Subject: [PATCH 15/22] fix: resolve forcetypeassert lint issues --- internal/server/routes.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/server/routes.go b/internal/server/routes.go index 2cd1530..2c15e75 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -73,10 +73,17 @@ func (s *Server) SetupRoutes() { fileServer := http.FileServer(http.FS(distFS)) s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + readFS, ok := distFS.(fs.ReadFileFS) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + + return + } + // Try to serve the file; if not found, serve index.html for SPA routing - f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:]) + f, err := readFS.ReadFile(r.URL.Path[1:]) if err != nil || len(f) == 0 { - indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html") + indexHTML, _ := readFS.ReadFile("index.html") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) -- 2.49.1 From a3c26c415ee8921bb8858091bba8044271b45a3a Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:22:03 -0800 Subject: [PATCH 16/22] fix: resolve funcorder lint issues --- cmd/chat-cli/api/client.go | 83 +++++++++++++++++++------------------- cmd/chat-cli/ui.go | 32 +++++++-------- 2 files changed, 58 insertions(+), 57 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 6ff52db..66836ee 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -47,47 +47,6 @@ func NewClient(baseURL string) *Client { } } -func (c *Client) do(method, path string, body any) ([]byte, error) { - var bodyReader io.Reader - - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("marshal: %w", err) - } - - bodyReader = bytes.NewReader(data) - } - - req, err := http.NewRequestWithContext(context.Background(), method, c.BaseURL+path, bodyReader) - if err != nil { - return nil, fmt.Errorf("request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - if c.Token != "" { - req.Header.Set("Authorization", "Bearer "+c.Token) - } - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("http: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read body: %w", err) - } - - if resp.StatusCode >= httpStatusErrorThreshold { - return data, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data)) - } - - return data, nil -} - // CreateSession creates a new session on the server. func (c *Client) CreateSession(nick string) (*SessionResponse, error) { data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick}) @@ -258,3 +217,45 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) { return &info, nil } + +func (c *Client) do(method, path string, body any) ([]byte, error) { + var bodyReader io.Reader + + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(context.Background(), method, c.BaseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL constructed from trusted base URL + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode >= httpStatusErrorThreshold { + return data, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data)) + } + + return data, nil +} diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index a1c5dac..f044584 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -196,6 +196,22 @@ func (ui *UI) SetStatus(nick, target, connStatus string) { }) } +// BufferCount returns the number of buffers. +func (ui *UI) BufferCount() int { + return len(ui.buffers) +} + +// BufferIndex returns the index of a named buffer, or -1. +func (ui *UI) BufferIndex(name string) int { + for i, buf := range ui.buffers { + if buf.Name == name { + return i + } + } + + return -1 +} + func (ui *UI) refreshStatus() { // Will be called from the main goroutine via QueueUpdateDraw parent. // Rebuild status from app state — caller must provide context. @@ -234,19 +250,3 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer { return buf } - -// BufferCount returns the number of buffers. -func (ui *UI) BufferCount() int { - return len(ui.buffers) -} - -// BufferIndex returns the index of a named buffer, or -1. -func (ui *UI) BufferIndex(name string) int { - for i, buf := range ui.buffers { - if buf.Name == name { - return i - } - } - - return -1 -} -- 2.49.1 From db3b0bfee1d2d07647e7bcf2f659ec870bb03556 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:22:32 -0800 Subject: [PATCH 17/22] fix: resolve ireturn lint issues --- internal/models/model.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/models/model.go b/internal/models/model.go index ca5c7bc..68739cd 100644 --- a/internal/models/model.go +++ b/internal/models/model.go @@ -48,7 +48,7 @@ func (b *Base) GetDB() *sql.DB { } // GetUserLookup returns the DB as a UserLookup if it implements the interface. -func (b *Base) GetUserLookup() UserLookup { +func (b *Base) GetUserLookup() UserLookup { //nolint:ireturn // intentional interface return for dependency inversion if ul, ok := b.db.(UserLookup); ok { return ul } @@ -57,7 +57,7 @@ func (b *Base) GetUserLookup() UserLookup { } // GetChannelLookup returns the DB as a ChannelLookup if it implements the interface. -func (b *Base) GetChannelLookup() ChannelLookup { +func (b *Base) GetChannelLookup() ChannelLookup { //nolint:ireturn // intentional interface return for dependency inversion if cl, ok := b.db.(ChannelLookup); ok { return cl } -- 2.49.1 From e0da78f17cbca5ae9c6f99b7730e9f2c3ef26b76 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:26:06 -0800 Subject: [PATCH 18/22] fix: resolve wsl_v5, lll, noinlineerr, and gosec lint issues --- cmd/chat-cli/api/client.go | 2 +- internal/db/db.go | 4 +++- internal/handlers/api.go | 5 +++++ internal/models/model.go | 7 +++++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index 66836ee..b014491 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -114,7 +114,7 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { req.Header.Set("Authorization", "Bearer "+c.Token) - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:gosec // G704: BaseURL is set by user at connect time, not tainted input if err != nil { return nil, err } diff --git a/internal/db/db.go b/internal/db/db.go index cf4376f..406fd0b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -88,7 +88,8 @@ func NewTest(dsn string) (*Database, error) { } // Item 9: Enable foreign keys - if _, err := d.ExecContext(context.Background(), "PRAGMA foreign_keys = ON"); err != nil { + _, err = d.ExecContext(context.Background(), "PRAGMA foreign_keys = ON") + if err != nil { _ = d.Close() return nil, fmt.Errorf("enable foreign keys: %w", err) @@ -434,6 +435,7 @@ func (s *Database) AckMessages( args[i] = id } + //nolint:gosec // G201: placeholders are all "?" literals, not user input query := fmt.Sprintf( "DELETE FROM message_queue WHERE id IN (%s)", strings.Join(placeholders, ","), diff --git a/internal/handlers/api.go b/internal/handlers/api.go index bbf3b1f..7182105 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -64,6 +64,7 @@ func (s *Handlers) HandleCreateSession() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req request + err := json.NewDecoder(r.Body).Decode(&req) if err != nil { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) @@ -166,6 +167,7 @@ func (s *Handlers) HandleChannelMembers() http.HandlerFunc { var chID string + //nolint:gosec // G701: parameterized query with ? placeholder, not injection err := s.params.Database.GetDB().QueryRowContext(r.Context(), "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) if err != nil { @@ -304,6 +306,7 @@ func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, ni // Channel message. var chID string + //nolint:gosec // G701: parameterized query, not injection err := s.params.Database.GetDB().QueryRowContext(r.Context(), "SELECT id FROM channels WHERE name = ?", to).Scan(&chID) if err != nil { @@ -387,6 +390,7 @@ func (s *Handlers) handlePart(w http.ResponseWriter, r *http.Request, uid, to st var chID string + //nolint:gosec // G701: parameterized query, not injection err := s.params.Database.GetDB().QueryRowContext(r.Context(), "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) if err != nil { @@ -494,6 +498,7 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc { // Channel history — look up channel by name to get its ID for target matching. var chID string + //nolint:gosec // G701: parameterized query, not injection err := s.params.Database.GetDB().QueryRowContext(r.Context(), "SELECT id FROM channels WHERE name = ?", target).Scan(&chID) if err != nil { diff --git a/internal/models/model.go b/internal/models/model.go index 68739cd..103934b 100644 --- a/internal/models/model.go +++ b/internal/models/model.go @@ -56,8 +56,11 @@ func (b *Base) GetUserLookup() UserLookup { //nolint:ireturn // intentional inte return nil } -// GetChannelLookup returns the DB as a ChannelLookup if it implements the interface. -func (b *Base) GetChannelLookup() ChannelLookup { //nolint:ireturn // intentional interface return for dependency inversion +// GetChannelLookup returns the DB as a ChannelLookup +// if it implements the interface. +// +//nolint:ireturn // intentional interface return for dependency inversion +func (b *Base) GetChannelLookup() ChannelLookup { if cl, ok := b.db.(ChannelLookup); ok { return cl } -- 2.49.1 From 037202280b4a682609a419e705cd62c526be6ed4 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:26:46 -0800 Subject: [PATCH 19/22] fix: resolve nestif issues by extracting helper methods --- internal/handlers/api.go | 181 +++++++++++++++++++++++---------------- 1 file changed, 105 insertions(+), 76 deletions(-) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 7182105..a55cbc1 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -303,46 +303,61 @@ func (s *Handlers) handlePrivmsg(w http.ResponseWriter, r *http.Request, uid, ni content := strings.Join(lines, "\n") if strings.HasPrefix(to, "#") { - // Channel message. - var chID string + s.sendChannelMessage(w, r, uid, nick, to, content) - //nolint:gosec // G701: parameterized query, not injection - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", to).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - - return - } - - msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, nick, content) - if err != nil { - s.log.Error("send message failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - - return - } - - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) - } else { - // DM. - targetUser, err := s.params.Database.GetUserByNick(r.Context(), to) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) - - return - } - - msgID, err := s.params.Database.SendDM(r.Context(), uid, nick, targetUser.ID, content) - if err != nil { - s.log.Error("send dm failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - - return - } - - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + return } + + // DM. + s.sendDirectMessage(w, r, uid, nick, to, content) +} + +func (s *Handlers) sendChannelMessage( + w http.ResponseWriter, r *http.Request, + uid, nick, channel, content string, +) { + var chID string + + //nolint:gosec // G701: parameterized query, not injection + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + + return + } + + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, nick, content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) +} + +func (s *Handlers) sendDirectMessage( + w http.ResponseWriter, r *http.Request, + uid, nick, to, content string, +) { + targetUser, err := s.params.Database.GetUserByNick(r.Context(), to) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + + return + } + + msgID, err := s.params.Database.SendDM(r.Context(), uid, nick, targetUser.ID, content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) } func (s *Handlers) handleJoin(w http.ResponseWriter, r *http.Request, uid, to string) { @@ -495,49 +510,63 @@ func (s *Handlers) HandleGetHistory() http.HandlerFunc { } if strings.HasPrefix(target, "#") { - // Channel history — look up channel by name to get its ID for target matching. - var chID string + s.getChannelHistory(w, r, target, beforeTS, limit) - //nolint:gosec // G701: parameterized query, not injection - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", target).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - - return - } - - msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeTS, limit) - if err != nil { - s.log.Error("get history failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - - return - } - - s.respondJSON(w, r, msgs, http.StatusOK) - } else { - // DM history. - targetUser, err := s.params.Database.GetUserByNick(r.Context(), target) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) - - return - } - - msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetUser.ID, beforeTS, limit) - if err != nil { - s.log.Error("get dm history failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - - return - } - - s.respondJSON(w, r, msgs, http.StatusOK) + return } + + s.getDMHistory(w, r, uid, target, beforeTS, limit) } } +func (s *Handlers) getChannelHistory( + w http.ResponseWriter, r *http.Request, + channel, beforeTS string, limit int, +) { + var chID string + + //nolint:gosec // G701: parameterized query, not injection + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + + return + } + + msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeTS, limit) + if err != nil { + s.log.Error("get history failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, msgs, http.StatusOK) +} + +func (s *Handlers) getDMHistory( + w http.ResponseWriter, r *http.Request, + uid, target, beforeTS string, limit int, +) { + targetUser, err := s.params.Database.GetUserByNick(r.Context(), target) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + + return + } + + msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetUser.ID, beforeTS, limit) + if err != nil { + s.log.Error("get dm history failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + + return + } + + s.respondJSON(w, r, msgs, http.StatusOK) +} + // HandleServerInfo returns server metadata (MOTD, name). func (s *Handlers) HandleServerInfo() http.HandlerFunc { type response struct { -- 2.49.1 From 2f6d1f284c8231063b623b16df1a37c4a6df1d2f Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:29:18 -0800 Subject: [PATCH 20/22] fix: resolve cyclop, funlen issues by extracting helper methods --- cmd/chat-cli/main.go | 231 ++++++++++++++++++++------------------ cmd/chat-cli/ui.go | 51 +++++---- internal/db/db.go | 99 ++++++++-------- internal/handlers/api.go | 76 +++++++------ internal/server/routes.go | 54 ++++----- 5 files changed, 263 insertions(+), 248 deletions(-) diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index 99d4531..c6865d3 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -97,6 +97,24 @@ func (a *App) handleInput(text string) { a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) } +func (a *App) commandHandlers() map[string]func(string) { + return map[string]func(string){ + "/connect": a.cmdConnect, + "/nick": a.cmdNick, + "/join": a.cmdJoin, + "/part": a.cmdPart, + "/msg": a.cmdMsg, + "/query": a.cmdQuery, + "/topic": a.cmdTopic, + "/names": func(_ string) { a.cmdNames() }, + "/list": func(_ string) { a.cmdList() }, + "/window": a.cmdWindow, + "/w": a.cmdWindow, + "/quit": func(_ string) { a.cmdQuit() }, + "/help": func(_ string) { a.cmdHelp() }, + } +} + func (a *App) handleCommand(text string) { parts := strings.SplitN(text, " ", splitParts) cmd := strings.ToLower(parts[0]) @@ -106,32 +124,9 @@ func (a *App) handleCommand(text string) { args = parts[1] } - switch cmd { - case "/connect": - a.cmdConnect(args) - case "/nick": - a.cmdNick(args) - case "/join": - a.cmdJoin(args) - case "/part": - a.cmdPart(args) - case "/msg": - a.cmdMsg(args) - case "/query": - a.cmdQuery(args) - case "/topic": - a.cmdTopic(args) - case "/names": - a.cmdNames() - case "/list": - a.cmdList() - case "/window", "/w": - a.cmdWindow(args) - case "/quit": - a.cmdQuit() - case "/help": - a.cmdHelp() - default: + if handler, ok := a.commandHandlers()[cmd]; ok { + handler(args) + } else { a.ui.AddStatus("[red]Unknown command: " + cmd) } } @@ -543,105 +538,123 @@ func (a *App) pollLoop() { } } -func (a *App) handleServerMessage(msg *api.Message) { - var ts string - +func (a *App) messageTimestamp(msg *api.Message) string { if msg.TS != "" { t := msg.ParseTS() - ts = t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally - } else { - ts = time.Now().Format("15:04") + + return t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally } + return time.Now().Format("15:04") +} + +func (a *App) handleServerMessage(msg *api.Message) { + ts := a.messageTimestamp(msg) + a.mu.Lock() myNick := a.nick a.mu.Unlock() switch msg.Command { case "PRIVMSG": - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - if msg.From == myNick { - // Skip our own echoed messages (already displayed locally). - return - } - - target := msg.To - if !strings.HasPrefix(target, "#") { - // DM — use sender's nick as buffer name. - target = msg.From - } - - a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) - + a.handleMsgPrivmsg(msg, ts, myNick) case "JOIN": - target := msg.To - if target != "" { - a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target)) - } - + a.handleMsgJoin(msg, ts) case "PART": - target := msg.To - lines := msg.BodyLines() - - reason := strings.Join(lines, " ") - if target != "" { - if reason != "" { - a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason)) - } else { - a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target)) - } - } - + a.handleMsgPart(msg, ts) case "QUIT": - lines := msg.BodyLines() - - reason := strings.Join(lines, " ") - if reason != "" { - a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) - } else { - a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From)) - } - + a.handleMsgQuit(msg, ts) case "NICK": - lines := msg.BodyLines() - - newNick := "" - if len(lines) > 0 { - newNick = lines[0] - } - - if msg.From == myNick && newNick != "" { - a.mu.Lock() - a.nick = newNick - target := a.target - a.mu.Unlock() - a.ui.SetStatus(newNick, target, "connected") - } - - a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) - + a.handleMsgNick(msg, ts, myNick) case "NOTICE": - lines := msg.BodyLines() - text := strings.Join(lines, " ") - a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) - + a.handleMsgNotice(msg, ts) case "TOPIC": - lines := msg.BodyLines() - - text := strings.Join(lines, " ") - if msg.To != "" { - a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) - } - + a.handleMsgTopic(msg, ts) default: - // Numeric replies and other messages → status window. - lines := msg.BodyLines() - - text := strings.Join(lines, " ") - if text != "" { - a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) - } + a.handleMsgDefault(msg, ts) + } +} + +func (a *App) handleMsgPrivmsg(msg *api.Message, ts, myNick string) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + if msg.From == myNick { + return + } + + target := msg.To + if !strings.HasPrefix(target, "#") { + target = msg.From + } + + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) +} + +func (a *App) handleMsgJoin(msg *api.Message, ts string) { + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, msg.To)) + } +} + +func (a *App) handleMsgPart(msg *api.Message, ts string) { + target := msg.To + reason := strings.Join(msg.BodyLines(), " ") + + if target == "" { + return + } + + if reason != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason)) + } else { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target)) + } +} + +func (a *App) handleMsgQuit(msg *api.Message, ts string) { + reason := strings.Join(msg.BodyLines(), " ") + if reason != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) + } else { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From)) + } +} + +func (a *App) handleMsgNick(msg *api.Message, ts, myNick string) { + lines := msg.BodyLines() + + newNick := "" + if len(lines) > 0 { + newNick = lines[0] + } + + if msg.From == myNick && newNick != "" { + a.mu.Lock() + a.nick = newNick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(newNick, target, "connected") + } + + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) +} + +func (a *App) handleMsgNotice(msg *api.Message, ts string) { + text := strings.Join(msg.BodyLines(), " ") + a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) +} + +func (a *App) handleMsgTopic(msg *api.Message, ts string) { + text := strings.Join(msg.BodyLines(), " ") + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) + } +} + +func (a *App) handleMsgDefault(msg *api.Message, ts string) { + text := strings.Join(msg.BodyLines(), " ") + if text != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) } } diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index f044584..1304460 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -55,26 +55,44 @@ func NewUI() *UI { ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetTextColor(tcell.ColorWhite) - // Input field. + ui.setupInput() + ui.setupKeyCapture() + + // Layout: messages on top, status bar, input at bottom. + ui.layout = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ui.messages, 0, 1, false). + AddItem(ui.statusBar, 1, 0, false). + AddItem(ui.input, 1, 0, true) + + ui.app.SetRoot(ui.layout, true) + ui.app.SetFocus(ui.input) + + return ui +} + +func (ui *UI) setupInput() { ui.input = tview.NewInputField(). SetFieldBackgroundColor(tcell.ColorBlack). SetFieldTextColor(tcell.ColorWhite) ui.input.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEnter { - text := ui.input.GetText() - if text == "" { - return - } + if key != tcell.KeyEnter { + return + } - ui.input.SetText("") + text := ui.input.GetText() + if text == "" { + return + } - if ui.onInput != nil { - ui.onInput(text) - } + ui.input.SetText("") + + if ui.onInput != nil { + ui.onInput(text) } }) +} - // Capture Alt+N for window switching. +func (ui *UI) setupKeyCapture() { ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Modifiers()&tcell.ModAlt != 0 { r := event.Rune() @@ -88,17 +106,6 @@ func NewUI() *UI { return event }) - - // Layout: messages on top, status bar, input at bottom. - ui.layout = tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(ui.messages, 0, 1, false). - AddItem(ui.statusBar, 1, 0, false). - AddItem(ui.input, 1, 0, true) - - ui.app.SetRoot(ui.layout, true) - ui.app.SetFocus(ui.input) - - return ui } // Run starts the UI event loop (blocks). diff --git a/internal/db/db.go b/internal/db/db.go index 406fd0b..e0c2c41 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -662,63 +662,52 @@ func (s *Database) applyMigrations( migrations []migration, ) error { for _, m := range migrations { - var exists int - - err := s.db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", - m.version, - ).Scan(&exists) - if err != nil { - return fmt.Errorf( - "check migration %d: %w", m.version, err, - ) - } - - if exists > 0 { - continue - } - - s.log.Info( - "applying migration", - "version", m.version, "name", m.name, - ) - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf( - "begin tx for migration %d: %w", m.version, err, - ) - } - - _, err = tx.ExecContext(ctx, m.sql) - if err != nil { - _ = tx.Rollback() - - return fmt.Errorf( - "apply migration %d (%s): %w", - m.version, m.name, err, - ) - } - - _, err = tx.ExecContext(ctx, - "INSERT INTO schema_migrations (version) VALUES (?)", - m.version, - ) - if err != nil { - _ = tx.Rollback() - - return fmt.Errorf( - "record migration %d: %w", m.version, err, - ) - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf( - "commit migration %d: %w", m.version, err, - ) + if err := s.applyOneMigration(ctx, m); err != nil { + return err } } return nil } + +func (s *Database) applyOneMigration(ctx context.Context, m migration) error { + var exists int + + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", + m.version, + ).Scan(&exists) + if err != nil { + return fmt.Errorf("check migration %d: %w", m.version, err) + } + + if exists > 0 { + return nil + } + + s.log.Info("applying migration", "version", m.version, "name", m.name) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx for migration %d: %w", m.version, err) + } + + _, err = tx.ExecContext(ctx, m.sql) + if err != nil { + _ = tx.Rollback() + + return fmt.Errorf("apply migration %d (%s): %w", m.version, m.name, err) + } + + _, err = tx.ExecContext(ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", + m.version, + ) + if err != nil { + _ = tx.Rollback() + + return fmt.Errorf("record migration %d: %w", m.version, err) + } + + return tx.Commit() +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a55cbc1..10d8fe9 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -239,51 +239,53 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { req.Command = strings.ToUpper(strings.TrimSpace(req.Command)) req.To = strings.TrimSpace(req.To) + lines := extractBodyLines(req.Body) - // Helper to extract body as string lines. - bodyLines := func() []string { - switch v := req.Body.(type) { - case []any: - lines := make([]string, 0, len(v)) + s.dispatchCommand(w, r, uid, nick, req.Command, req.To, lines) + } +} - for _, item := range v { - if str, ok := item.(string); ok { - lines = append(lines, str) - } - } +// extractBodyLines converts the request body to string lines. +func extractBodyLines(body any) []string { + switch v := body.(type) { + case []any: + lines := make([]string, 0, len(v)) - return lines - case []string: - return v - default: - return nil + for _, item := range v { + if str, ok := item.(string); ok { + lines = append(lines, str) } } - switch req.Command { - case "PRIVMSG", "NOTICE": - s.handlePrivmsg(w, r, uid, nick, req.To, bodyLines()) + return lines + case []string: + return v + default: + return nil + } +} - case "JOIN": - s.handleJoin(w, r, uid, req.To) +func (s *Handlers) dispatchCommand( + w http.ResponseWriter, r *http.Request, + uid, nick, command, to string, lines []string, +) { + switch command { + case "PRIVMSG", "NOTICE": + s.handlePrivmsg(w, r, uid, nick, to, lines) + case "JOIN": + s.handleJoin(w, r, uid, to) + case "PART": + s.handlePart(w, r, uid, to) + case "NICK": + s.handleNick(w, r, uid, lines) + case "TOPIC": + s.handleTopic(w, r, uid, to, lines) + case "PING": + s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) + default: + _ = nick - case "PART": - s.handlePart(w, r, uid, req.To) - - case "NICK": - s.handleNick(w, r, uid, bodyLines()) - - case "TOPIC": - s.handleTopic(w, r, uid, req.To, bodyLines()) - - case "PING": - s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) - - default: - _ = nick // suppress unused warning - - s.respondJSON(w, r, map[string]string{"error": "unknown command: " + req.Command}, http.StatusBadRequest) - } + s.respondJSON(w, r, map[string]string{"error": "unknown command: " + command}, http.StatusBadRequest) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index 2c15e75..5a4363e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -65,34 +65,38 @@ func (s *Server) SetupRoutes() { r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) }) - // Serve embedded SPA + s.setupSPA() +} + +func (s *Server) setupSPA() { distFS, err := fs.Sub(web.Dist, "dist") if err != nil { s.log.Error("failed to get web dist filesystem", "error", err) - } else { - fileServer := http.FileServer(http.FS(distFS)) - s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { - readFS, ok := distFS.(fs.ReadFileFS) - if !ok { - http.Error(w, "internal error", http.StatusInternalServerError) - - return - } - - // Try to serve the file; if not found, serve index.html for SPA routing - f, err := readFS.ReadFile(r.URL.Path[1:]) - if err != nil || len(f) == 0 { - indexHTML, _ := readFS.ReadFile("index.html") - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(indexHTML) - - return - } - - fileServer.ServeHTTP(w, r) - }) + return } + + fileServer := http.FileServer(http.FS(distFS)) + + s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + readFS, ok := distFS.(fs.ReadFileFS) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + + return + } + + f, err := readFS.ReadFile(r.URL.Path[1:]) + if err != nil || len(f) == 0 { + indexHTML, _ := readFS.ReadFile("index.html") + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(indexHTML) + + return + } + + fileServer.ServeHTTP(w, r) + }) } -- 2.49.1 From a9586eb95f571add7a8bafea023a8b86c4966b07 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:30:01 -0800 Subject: [PATCH 21/22] fix: resolve funcorder and noinlineerr issues --- cmd/chat-cli/ui.go | 76 +++++++++++++++++++++++----------------------- internal/db/db.go | 3 +- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index 1304460..9d14dc5 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -70,44 +70,6 @@ func NewUI() *UI { return ui } -func (ui *UI) setupInput() { - ui.input = tview.NewInputField(). - SetFieldBackgroundColor(tcell.ColorBlack). - SetFieldTextColor(tcell.ColorWhite) - ui.input.SetDoneFunc(func(key tcell.Key) { - if key != tcell.KeyEnter { - return - } - - text := ui.input.GetText() - if text == "" { - return - } - - ui.input.SetText("") - - if ui.onInput != nil { - ui.onInput(text) - } - }) -} - -func (ui *UI) setupKeyCapture() { - ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Modifiers()&tcell.ModAlt != 0 { - r := event.Rune() - if r >= '0' && r <= '9' { - idx := int(r - '0') - ui.SwitchBuffer(idx) - - return nil - } - } - - return event - }) -} - // Run starts the UI event loop (blocks). func (ui *UI) Run() error { return ui.app.Run() @@ -219,6 +181,44 @@ func (ui *UI) BufferIndex(name string) int { return -1 } +func (ui *UI) setupInput() { + ui.input = tview.NewInputField(). + SetFieldBackgroundColor(tcell.ColorBlack). + SetFieldTextColor(tcell.ColorWhite) + ui.input.SetDoneFunc(func(key tcell.Key) { + if key != tcell.KeyEnter { + return + } + + text := ui.input.GetText() + if text == "" { + return + } + + ui.input.SetText("") + + if ui.onInput != nil { + ui.onInput(text) + } + }) +} + +func (ui *UI) setupKeyCapture() { + ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Modifiers()&tcell.ModAlt != 0 { + r := event.Rune() + if r >= '0' && r <= '9' { + idx := int(r - '0') + ui.SwitchBuffer(idx) + + return nil + } + } + + return event + }) +} + func (ui *UI) refreshStatus() { // Will be called from the main goroutine via QueueUpdateDraw parent. // Rebuild status from app state — caller must provide context. diff --git a/internal/db/db.go b/internal/db/db.go index e0c2c41..114691e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -662,7 +662,8 @@ func (s *Database) applyMigrations( migrations []migration, ) error { for _, m := range migrations { - if err := s.applyOneMigration(ctx, m); err != nil { + err := s.applyOneMigration(ctx, m) + if err != nil { return err } } -- 2.49.1 From 0b132cfbf48e274ecb06c7a46d7779a3660c2cf1 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:31:02 -0800 Subject: [PATCH 22/22] fix: replace conflicting migration 003 with no-op Migration 003 tried to recreate tables (users, channel_members, messages) with INTEGER IDs, conflicting with 002_schema.sql which already defines them with TEXT UUIDs. This caused all tests to fail. --- internal/db/schema/003_users.sql | 35 ++++---------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/internal/db/schema/003_users.sql b/internal/db/schema/003_users.sql index f305aa0..c5a9069 100644 --- a/internal/db/schema/003_users.sql +++ b/internal/db/schema/003_users.sql @@ -1,31 +1,4 @@ -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nick TEXT NOT NULL UNIQUE, - token TEXT NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_seen DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS channel_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(channel_id, user_id) -); - -CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - is_dm INTEGER NOT NULL DEFAULT 0, - dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); -CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at); -CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); +-- Migration 003: no-op (schema already created by 002_schema.sql) +-- This migration previously conflicted with 002 by attempting to recreate +-- tables with incompatible column types (INTEGER vs TEXT IDs). +SELECT 1; -- 2.49.1