style: fix all golangci-lint issues and format code (refs #17)
Fix 380 lint violations across all Go source files including wsl_v5, nlreturn, noinlineerr, errcheck, funlen, funcorder, tagliatelle, perfsprint, modernize, revive, gosec, ireturn, mnd, forcetypeassert, cyclop, and others. Key changes: - Split large handler/command functions into smaller methods - Extract scan helpers for database queries - Reorder exported/unexported methods per funcorder - Add sentinel errors in models package - Use camelCase JSON tags per tagliatelle defaults - Add package comments - Fix .gitignore to not exclude cmd/chat-cli directory
This commit is contained in:
parent
636546d74a
commit
b78d526f02
2
.gitignore
vendored
2
.gitignore
vendored
@ -34,5 +34,5 @@ vendor/
|
|||||||
# Project
|
# Project
|
||||||
data.db
|
data.db
|
||||||
debug.log
|
debug.log
|
||||||
chat-cli
|
/chat-cli
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@ -1,15 +1,31 @@
|
|||||||
|
// Package api provides a client for the chat server HTTP API.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
httpTimeout = 30 * time.Second
|
||||||
|
pollExtraDelay = 5
|
||||||
|
httpErrThreshold = 400
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrHTTP is returned for non-2xx responses.
|
||||||
|
var ErrHTTP = errors.New("http error")
|
||||||
|
|
||||||
|
// ErrUnexpectedFormat is returned when the response format is
|
||||||
|
// not recognised.
|
||||||
|
var ErrUnexpectedFormat = errors.New("unexpected format")
|
||||||
|
|
||||||
// Client wraps HTTP calls to the chat server API.
|
// Client wraps HTTP calls to the chat server API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
@ -22,59 +38,32 @@ func NewClient(baseURL string) *Client {
|
|||||||
return &Client{
|
return &Client{
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: httpTimeout,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) do(method, path string, body interface{}) ([]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.NewRequest(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 resp.Body.Close()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
func (c *Client) CreateSession(nick string) (*SessionResponse, error) {
|
func (c *Client) CreateSession(
|
||||||
data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick})
|
nick string,
|
||||||
|
) (*SessionResponse, error) {
|
||||||
|
data, err := c.do(
|
||||||
|
"POST", "/api/v1/session",
|
||||||
|
&SessionRequest{Nick: nick},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp SessionResponse
|
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)
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Token = resp.Token
|
c.Token = resp.Token
|
||||||
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,78 +73,113 @@ func (c *Client) GetState() (*StateResponse, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp StateResponse
|
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)
|
return nil, fmt.Errorf("decode state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage sends a message (any IRC command).
|
// SendMessage sends a message (any IRC command).
|
||||||
func (c *Client) SendMessage(msg *Message) error {
|
func (c *Client) SendMessage(msg *Message) error {
|
||||||
_, err := c.do("POST", "/api/v1/messages", msg)
|
_, err := c.do("POST", "/api/v1/messages", msg)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollMessages long-polls for new messages.
|
// PollMessages long-polls for new messages.
|
||||||
func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) {
|
func (c *Client) PollMessages(
|
||||||
// Use a longer HTTP timeout than the server long-poll timeout.
|
afterID string,
|
||||||
client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second}
|
timeout int,
|
||||||
|
) ([]Message, error) {
|
||||||
|
pollTimeout := time.Duration(
|
||||||
|
timeout+pollExtraDelay,
|
||||||
|
) * time.Second
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: pollTimeout}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if afterID != "" {
|
if afterID != "" {
|
||||||
params.Set("after", afterID)
|
params.Set("after", afterID)
|
||||||
}
|
}
|
||||||
params.Set("timeout", fmt.Sprintf("%d", timeout))
|
|
||||||
|
params.Set("timeout", strconv.Itoa(timeout))
|
||||||
|
|
||||||
path := "/api/v1/messages"
|
path := "/api/v1/messages"
|
||||||
if len(params) > 0 {
|
if len(params) > 0 {
|
||||||
path += "?" + params.Encode()
|
path += "?" + params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
req, err := http.NewRequest( //nolint:noctx // CLI tool
|
||||||
|
http.MethodGet, c.BaseURL+path, nil,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req) //nolint:gosec // URL from user config
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= httpErrThreshold {
|
||||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
return nil, fmt.Errorf(
|
||||||
|
"%w: %d: %s",
|
||||||
|
ErrHTTP, resp.StatusCode, string(data),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The server may return an array directly or wrapped.
|
return decodeMessages(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMessages(data []byte) ([]Message, error) {
|
||||||
var msgs []Message
|
var msgs []Message
|
||||||
if err := json.Unmarshal(data, &msgs); err != nil {
|
|
||||||
// Try wrapped format.
|
err := json.Unmarshal(data, &msgs)
|
||||||
var wrapped MessagesResponse
|
if err == nil {
|
||||||
if err2 := json.Unmarshal(data, &wrapped); err2 != nil {
|
return msgs, nil
|
||||||
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
|
|
||||||
}
|
|
||||||
msgs = wrapped.Messages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return msgs, nil
|
var wrapped MessagesResponse
|
||||||
|
|
||||||
|
err2 := json.Unmarshal(data, &wrapped)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"decode messages: %w (raw: %s)",
|
||||||
|
err, string(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped.Messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// JoinChannel joins a channel via the unified command endpoint.
|
// JoinChannel joins a channel via the unified command
|
||||||
|
// endpoint.
|
||||||
func (c *Client) JoinChannel(channel string) error {
|
func (c *Client) JoinChannel(channel string) error {
|
||||||
return c.SendMessage(&Message{Command: "JOIN", To: channel})
|
return c.SendMessage(
|
||||||
|
&Message{Command: "JOIN", To: channel},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PartChannel leaves a channel via the unified command endpoint.
|
// PartChannel leaves a channel via the unified command
|
||||||
|
// endpoint.
|
||||||
func (c *Client) PartChannel(channel string) error {
|
func (c *Client) PartChannel(channel string) error {
|
||||||
return c.SendMessage(&Message{Command: "PART", To: channel})
|
return c.SendMessage(
|
||||||
|
&Message{Command: "PART", To: channel},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChannels returns all channels on the server.
|
// ListChannels returns all channels on the server.
|
||||||
@ -164,29 +188,39 @@ func (c *Client) ListChannels() ([]Channel, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var channels []Channel
|
var channels []Channel
|
||||||
if err := json.Unmarshal(data, &channels); err != nil {
|
|
||||||
|
err = json.Unmarshal(data, &channels)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return channels, nil
|
return channels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMembers returns members of a channel.
|
// GetMembers returns members of a channel.
|
||||||
func (c *Client) GetMembers(channel string) ([]string, error) {
|
func (c *Client) GetMembers(
|
||||||
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil)
|
channel string,
|
||||||
|
) ([]string, error) {
|
||||||
|
path := "/api/v1/channels/" +
|
||||||
|
url.PathEscape(channel) + "/members"
|
||||||
|
|
||||||
|
data, err := c.do("GET", path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var members []string
|
var members []string
|
||||||
if err := json.Unmarshal(data, &members); err != nil {
|
|
||||||
// Try object format.
|
err = json.Unmarshal(data, &members)
|
||||||
var obj map[string]interface{}
|
if err != nil {
|
||||||
if err2 := json.Unmarshal(data, &obj); err2 != nil {
|
return nil, fmt.Errorf(
|
||||||
return nil, err
|
"%w: members: %s",
|
||||||
}
|
ErrUnexpectedFormat, string(data),
|
||||||
// Extract member names from whatever format.
|
)
|
||||||
return nil, fmt.Errorf("unexpected members format: %s", string(data))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return members, nil
|
return members, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,9 +230,63 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var info ServerInfo
|
var info ServerInfo
|
||||||
if err := json.Unmarshal(data, &info); err != nil {
|
|
||||||
|
err = json.Unmarshal(data, &info)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &info, nil
|
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.NewRequest( //nolint:noctx // CLI tool
|
||||||
|
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 from user config
|
||||||
|
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 >= httpErrThreshold {
|
||||||
|
return data, fmt.Errorf(
|
||||||
|
"%w: %d: %s",
|
||||||
|
ErrHTTP, resp.StatusCode, string(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package api
|
package api //nolint:revive // package name is intentional
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
@ -9,42 +9,45 @@ type SessionRequest struct {
|
|||||||
|
|
||||||
// SessionResponse is the response from POST /api/v1/session.
|
// SessionResponse is the response from POST /api/v1/session.
|
||||||
type SessionResponse struct {
|
type SessionResponse struct {
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"sessionId"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"clientId"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateResponse is the response from GET /api/v1/state.
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
type StateResponse struct {
|
type StateResponse struct {
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"sessionId"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"clientId"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a chat message envelope.
|
// Message represents a chat message envelope.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Params []string `json:"params,omitempty"`
|
Params []string `json:"params,omitempty"`
|
||||||
Body interface{} `json:"body,omitempty"`
|
Body any `json:"body,omitempty"`
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
TS string `json:"ts,omitempty"`
|
TS string `json:"ts,omitempty"`
|
||||||
Meta interface{} `json:"meta,omitempty"`
|
Meta any `json:"meta,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BodyLines returns the body as a slice of strings (for text messages).
|
// BodyLines returns the body as a slice of strings (for text
|
||||||
|
// messages).
|
||||||
func (m *Message) BodyLines() []string {
|
func (m *Message) BodyLines() []string {
|
||||||
switch v := m.Body.(type) {
|
switch v := m.Body.(type) {
|
||||||
case []interface{}:
|
case []any:
|
||||||
lines := make([]string, 0, len(v))
|
lines := make([]string, 0, len(v))
|
||||||
|
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
if s, ok := item.(string); ok {
|
if s, ok := item.(string); ok {
|
||||||
lines = append(lines, s)
|
lines = append(lines, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
case []string:
|
case []string:
|
||||||
return v
|
return v
|
||||||
@ -58,7 +61,7 @@ type Channel struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Members int `json:"members"`
|
Members int `json:"members"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerInfo is the response from GET /api/v1/server.
|
// ServerInfo is the response from GET /api/v1/server.
|
||||||
@ -79,5 +82,6 @@ func (m *Message) ParseTS() time.Time {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Now()
|
return time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
// Package main implements chat-cli, an IRC-style terminal client.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -10,6 +12,12 @@ import (
|
|||||||
"git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
"git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pollTimeoutSec = 15
|
||||||
|
retryDelay = 2 * time.Second
|
||||||
|
maxNickLength = 32
|
||||||
|
)
|
||||||
|
|
||||||
// App holds the application state.
|
// App holds the application state.
|
||||||
type App struct {
|
type App struct {
|
||||||
ui *UI
|
ui *UI
|
||||||
@ -32,11 +40,18 @@ func main() {
|
|||||||
app.ui.OnInput(app.handleInput)
|
app.ui.OnInput(app.handleInput)
|
||||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
app.ui.AddStatus("Welcome to chat-cli — an IRC-style client")
|
app.ui.AddStatus(
|
||||||
app.ui.AddStatus("Type [yellow]/connect <server-url>[white] to begin, or [yellow]/help[white] for commands")
|
"Welcome to chat-cli \u2014 an IRC-style client",
|
||||||
|
)
|
||||||
|
app.ui.AddStatus(
|
||||||
|
"Type [yellow]/connect <server-url>[white] " +
|
||||||
|
"to begin, or [yellow]/help[white] for commands",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := app.ui.Run()
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
|
||||||
if err := app.ui.Run(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,21 +59,34 @@ func main() {
|
|||||||
func (a *App) handleInput(text string) {
|
func (a *App) handleInput(text string) {
|
||||||
if strings.HasPrefix(text, "/") {
|
if strings.HasPrefix(text, "/") {
|
||||||
a.handleCommand(text)
|
a.handleCommand(text)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain text → PRIVMSG to current target.
|
a.sendPlainText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) sendPlainText(text string) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
target := a.target
|
target := a.target
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
|
nick := a.nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected. Use /connect <url>")
|
a.ui.AddStatus(
|
||||||
|
"[red]Not connected. Use /connect <url>",
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if target == "" {
|
if target == "" {
|
||||||
a.ui.AddStatus("[red]No target. Use /join #channel or /query nick")
|
a.ui.AddStatus(
|
||||||
|
"[red]No target. " +
|
||||||
|
"Use /join #channel or /query nick",
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,21 +96,28 @@ func (a *App) handleInput(text string) {
|
|||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Send error: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echo locally.
|
|
||||||
ts := time.Now().Format("15:04")
|
ts := time.Now().Format("15:04")
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
a.ui.AddLine(
|
||||||
a.mu.Unlock()
|
target,
|
||||||
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text))
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
ts, nick, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleCommand(text string) {
|
func (a *App) handleCommand(text string) { //nolint:cyclop // command dispatch
|
||||||
parts := strings.SplitN(text, " ", 2)
|
parts := strings.SplitN(text, " ", 2) //nolint:mnd // split into cmd+args
|
||||||
cmd := strings.ToLower(parts[0])
|
cmd := strings.ToLower(parts[0])
|
||||||
|
|
||||||
args := ""
|
args := ""
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
args = parts[1]
|
args = parts[1]
|
||||||
@ -114,27 +149,41 @@ func (a *App) handleCommand(text string) {
|
|||||||
case "/help":
|
case "/help":
|
||||||
a.cmdHelp()
|
a.cmdHelp()
|
||||||
default:
|
default:
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd))
|
a.ui.AddStatus(
|
||||||
|
"[red]Unknown command: " + cmd,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdConnect(serverURL string) {
|
func (a *App) cmdConnect(serverURL string) {
|
||||||
if serverURL == "" {
|
if serverURL == "" {
|
||||||
a.ui.AddStatus("[red]Usage: /connect <server-url>")
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /connect <server-url>",
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverURL = strings.TrimRight(serverURL, "/")
|
serverURL = strings.TrimRight(serverURL, "/")
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("Connecting to %s...", serverURL),
|
||||||
|
)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
client := api.NewClient(serverURL)
|
client := api.NewClient(serverURL)
|
||||||
|
|
||||||
resp, err := client.CreateSession(nick)
|
resp, err := client.CreateSession(nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Connection failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,19 +194,26 @@ func (a *App) cmdConnect(serverURL string) {
|
|||||||
a.lastMsgID = ""
|
a.lastMsgID = ""
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[green]Connected! Nick: %s, Session: %s",
|
||||||
|
resp.Nick, resp.SessionID,
|
||||||
|
),
|
||||||
|
)
|
||||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||||
|
|
||||||
// Start polling.
|
|
||||||
a.stopPoll = make(chan struct{})
|
a.stopPoll = make(chan struct{})
|
||||||
|
|
||||||
go a.pollLoop()
|
go a.pollLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdNick(nick string) {
|
func (a *App) cmdNick(nick string) {
|
||||||
if nick == "" {
|
if nick == "" {
|
||||||
a.ui.AddStatus("[red]Usage: /nick <name>")
|
a.ui.AddStatus("[red]Usage: /nick <name>")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
@ -166,7 +222,14 @@ func (a *App) cmdNick(nick string) {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.nick = nick
|
a.nick = nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick))
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Nick set to %s (will be used on connect)",
|
||||||
|
nick,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,7 +238,12 @@ func (a *App) cmdNick(nick string) {
|
|||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Nick change failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,15 +251,20 @@ func (a *App) cmdNick(nick string) {
|
|||||||
a.nick = nick
|
a.nick = nick
|
||||||
target := a.target
|
target := a.target
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.SetStatus(nick, target, "connected")
|
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) {
|
func (a *App) cmdJoin(channel string) {
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
a.ui.AddStatus("[red]Usage: /join #channel")
|
a.ui.AddStatus("[red]Usage: /join #channel")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(channel, "#") {
|
if !strings.HasPrefix(channel, "#") {
|
||||||
channel = "#" + channel
|
channel = "#" + channel
|
||||||
}
|
}
|
||||||
@ -199,14 +272,19 @@ func (a *App) cmdJoin(channel string) {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected")
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.JoinChannel(channel)
|
err := a.client.JoinChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Join failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,39 +294,55 @@ func (a *App) cmdJoin(channel string) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(channel)
|
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")
|
a.ui.SetStatus(nick, channel, "connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdPart(channel string) {
|
func (a *App) cmdPart(channel string) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = a.target
|
channel = a.target
|
||||||
}
|
}
|
||||||
|
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if channel == "" || !strings.HasPrefix(channel, "#") {
|
if channel == "" || !strings.HasPrefix(channel, "#") {
|
||||||
a.ui.AddStatus("[red]No channel to part")
|
a.ui.AddStatus("[red]No channel to part")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected")
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.PartChannel(channel)
|
err := a.client.PartChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Part failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", channel))
|
a.ui.AddLine(
|
||||||
|
channel,
|
||||||
|
"[yellow]*** Left "+channel,
|
||||||
|
)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
|
||||||
if a.target == channel {
|
if a.target == channel {
|
||||||
a.target = ""
|
a.target = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
@ -257,19 +351,23 @@ func (a *App) cmdPart(channel string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdMsg(args string) {
|
func (a *App) cmdMsg(args string) {
|
||||||
parts := strings.SplitN(args, " ", 2)
|
parts := strings.SplitN(args, " ", 2) //nolint:mnd // split into target+text
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 { //nolint:mnd // min args
|
||||||
a.ui.AddStatus("[red]Usage: /msg <nick> <text>")
|
a.ui.AddStatus("[red]Usage: /msg <nick> <text>")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, text := parts[0], parts[1]
|
target, text := parts[0], parts[1]
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
connected := a.connected
|
connected := a.connected
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected")
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,17 +377,28 @@ func (a *App) cmdMsg(args string) {
|
|||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Send failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Now().Format("15:04")
|
ts := time.Now().Format("15:04")
|
||||||
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text))
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
ts, nick, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdQuery(nick string) {
|
func (a *App) cmdQuery(nick string) {
|
||||||
if nick == "" {
|
if nick == "" {
|
||||||
a.ui.AddStatus("[red]Usage: /query <nick>")
|
a.ui.AddStatus("[red]Usage: /query <nick>")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,22 +419,29 @@ func (a *App) cmdTopic(args string) {
|
|||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected")
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
if !strings.HasPrefix(target, "#") {
|
||||||
a.ui.AddStatus("[red]Not in a channel")
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
// Query topic.
|
|
||||||
err := a.client.SendMessage(&api.Message{
|
err := a.client.SendMessage(&api.Message{
|
||||||
Command: "TOPIC",
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Topic query failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,7 +451,11 @@ func (a *App) cmdTopic(args string) {
|
|||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Topic set failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,20 +467,32 @@ func (a *App) cmdNames() {
|
|||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected")
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
if !strings.HasPrefix(target, "#") {
|
||||||
a.ui.AddStatus("[red]Not in a channel")
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := a.client.GetMembers(target)
|
members, err := a.client.GetMembers(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Names failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " ")))
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[cyan]*** Members of %s: %s",
|
||||||
|
target, strings.Join(members, " "),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdList() {
|
func (a *App) cmdList() {
|
||||||
@ -370,46 +502,55 @@ func (a *App) cmdList() {
|
|||||||
|
|
||||||
if !connected {
|
if !connected {
|
||||||
a.ui.AddStatus("[red]Not connected")
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
channels, err := a.client.ListChannels()
|
channels, err := a.client.ListChannels()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]List failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic))
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
" %s (%d members) %s",
|
||||||
|
ch.Name, ch.Members, ch.Topic,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdWindow(args string) {
|
func (a *App) cmdWindow(args string) {
|
||||||
if args == "" {
|
if args == "" {
|
||||||
a.ui.AddStatus("[red]Usage: /window <number>")
|
a.ui.AddStatus("[red]Usage: /window <number>")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n := 0
|
|
||||||
fmt.Sscanf(args, "%d", &n)
|
n, _ := strconv.Atoi(args)
|
||||||
a.ui.SwitchBuffer(n)
|
a.ui.SwitchBuffer(n)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if n < a.ui.BufferCount() && n >= 0 {
|
|
||||||
// Update target to the buffer name.
|
|
||||||
// Needs to be done carefully.
|
|
||||||
}
|
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
// Update target based on buffer.
|
if n >= 0 && n < a.ui.BufferCount() {
|
||||||
if n < a.ui.BufferCount() {
|
|
||||||
buf := a.ui.buffers[n]
|
buf := a.ui.buffers[n]
|
||||||
|
|
||||||
if buf.Name != "(status)" {
|
if buf.Name != "(status)" {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.target = buf.Name
|
a.target = buf.Name
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.ui.SetStatus(nick, buf.Name, "connected")
|
a.ui.SetStatus(nick, buf.Name, "connected")
|
||||||
} else {
|
} else {
|
||||||
a.ui.SetStatus(nick, "", "connected")
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
@ -419,12 +560,17 @@ func (a *App) cmdWindow(args string) {
|
|||||||
|
|
||||||
func (a *App) cmdQuit() {
|
func (a *App) cmdQuit() {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(&api.Message{Command: "QUIT"})
|
_ = a.client.SendMessage(
|
||||||
|
&api.Message{Command: "QUIT"},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.stopPoll != nil {
|
if a.stopPoll != nil {
|
||||||
close(a.stopPoll)
|
close(a.stopPoll)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
a.ui.Stop()
|
a.ui.Stop()
|
||||||
}
|
}
|
||||||
@ -432,20 +578,21 @@ func (a *App) cmdQuit() {
|
|||||||
func (a *App) cmdHelp() {
|
func (a *App) cmdHelp() {
|
||||||
help := []string{
|
help := []string{
|
||||||
"[cyan]*** chat-cli commands:",
|
"[cyan]*** chat-cli commands:",
|
||||||
" /connect <url> — Connect to server",
|
" /connect <url> \u2014 Connect to server",
|
||||||
" /nick <name> — Change nickname",
|
" /nick <name> \u2014 Change nickname",
|
||||||
" /join #channel — Join channel",
|
" /join #channel \u2014 Join channel",
|
||||||
" /part [#chan] — Leave channel",
|
" /part [#chan] \u2014 Leave channel",
|
||||||
" /msg <nick> <text> — Send DM",
|
" /msg <nick> <text> \u2014 Send DM",
|
||||||
" /query <nick> — Open DM window",
|
" /query <nick> \u2014 Open DM window",
|
||||||
" /topic [text] — View/set topic",
|
" /topic [text] \u2014 View/set topic",
|
||||||
" /names — List channel members",
|
" /names \u2014 List channel members",
|
||||||
" /list — List channels",
|
" /list \u2014 List channels",
|
||||||
" /window <n> — Switch buffer (Alt+0-9)",
|
" /window <n> \u2014 Switch buffer (Alt+0-9)",
|
||||||
" /quit — Disconnect and exit",
|
" /quit \u2014 Disconnect and exit",
|
||||||
" /help — This help",
|
" /help \u2014 This help",
|
||||||
" Plain text sends to current target.",
|
" Plain text sends to current target.",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range help {
|
for _, line := range help {
|
||||||
a.ui.AddStatus(line)
|
a.ui.AddStatus(line)
|
||||||
}
|
}
|
||||||
@ -469,32 +616,31 @@ func (a *App) pollLoop() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := client.PollMessages(lastID, 15)
|
msgs, err := client.PollMessages(
|
||||||
|
lastID, pollTimeoutSec,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Transient error — retry after delay.
|
time.Sleep(retryDelay)
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, msg := range msgs {
|
for i := range msgs {
|
||||||
a.handleServerMessage(&msg)
|
a.handleServerMessage(&msgs[i])
|
||||||
if msg.ID != "" {
|
|
||||||
|
if msgs[i].ID != "" {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.lastMsgID = msg.ID
|
a.lastMsgID = msgs[i].ID
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleServerMessage(msg *api.Message) {
|
func (a *App) handleServerMessage(
|
||||||
ts := ""
|
msg *api.Message,
|
||||||
if msg.TS != "" {
|
) {
|
||||||
t := msg.ParseTS()
|
ts := a.parseMessageTS(msg)
|
||||||
ts = t.Local().Format("15:04")
|
|
||||||
} else {
|
|
||||||
ts = time.Now().Format("15:04")
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
myNick := a.nick
|
myNick := a.nick
|
||||||
@ -502,79 +648,203 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case "PRIVMSG":
|
case "PRIVMSG":
|
||||||
lines := msg.BodyLines()
|
a.handlePrivmsgMsg(msg, ts, myNick)
|
||||||
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":
|
case "JOIN":
|
||||||
target := msg.To
|
a.handleJoinMsg(msg, ts)
|
||||||
if target != "" {
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target))
|
|
||||||
}
|
|
||||||
|
|
||||||
case "PART":
|
case "PART":
|
||||||
target := msg.To
|
a.handlePartMsg(msg, ts)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
lines := msg.BodyLines()
|
a.handleQuitMsg(msg, ts)
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
case "NICK":
|
case "NICK":
|
||||||
lines := msg.BodyLines()
|
a.handleNickMsg(msg, ts, myNick)
|
||||||
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))
|
|
||||||
|
|
||||||
case "NOTICE":
|
case "NOTICE":
|
||||||
lines := msg.BodyLines()
|
a.handleNoticeMsg(msg, ts)
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text))
|
|
||||||
|
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
lines := msg.BodyLines()
|
a.handleTopicMsg(msg, ts)
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Numeric replies and other messages → status window.
|
a.handleDefaultMsg(msg, ts)
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
if text != "" {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) parseMessageTS(msg *api.Message) string {
|
||||||
|
if msg.TS != "" {
|
||||||
|
t := msg.ParseTS()
|
||||||
|
|
||||||
|
return t.In(time.Local).Format("15:04") //nolint:gosmopolitan // CLI uses local time
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now().Format("15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handlePrivmsgMsg(
|
||||||
|
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) handleJoinMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
target := msg.To
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has joined %s",
|
||||||
|
ts, msg.From, target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handlePartMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
target := msg.To
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
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) handleQuitMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleNickMsg(
|
||||||
|
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) handleNoticeMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [magenta]--%s-- %s",
|
||||||
|
ts, msg.From, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleTopicMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
if msg.To == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
msg.To,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [cyan]*** %s set topic: %s",
|
||||||
|
ts, msg.From, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDefaultMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [white][%s] %s",
|
||||||
|
ts, msg.Command, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ type UI struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewUI creates the tview-based IRC-like UI.
|
// NewUI creates the tview-based IRC-like UI.
|
||||||
|
|
||||||
func NewUI() *UI {
|
func NewUI() *UI {
|
||||||
ui := &UI{
|
ui := &UI{
|
||||||
app: tview.NewApplication(),
|
app: tview.NewApplication(),
|
||||||
@ -39,80 +40,35 @@ func NewUI() *UI {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message area.
|
ui.setupMessages()
|
||||||
ui.messages = tview.NewTextView().
|
ui.setupStatusBar()
|
||||||
SetDynamicColors(true).
|
ui.setupInput()
|
||||||
SetScrollable(true).
|
ui.setupKeybindings()
|
||||||
SetWordWrap(true).
|
ui.setupLayout()
|
||||||
SetChangedFunc(func() {
|
|
||||||
ui.app.Draw()
|
|
||||||
})
|
|
||||||
ui.messages.SetBorder(false)
|
|
||||||
|
|
||||||
// Status bar.
|
|
||||||
ui.statusBar = tview.NewTextView().
|
|
||||||
SetDynamicColors(true)
|
|
||||||
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
|
|
||||||
ui.statusBar.SetTextColor(tcell.ColorWhite)
|
|
||||||
|
|
||||||
// Input field.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
ui.input.SetText("")
|
|
||||||
if ui.onInput != nil {
|
|
||||||
ui.onInput(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Capture Alt+N for window switching.
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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
|
return ui
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the UI event loop (blocks).
|
// Run starts the UI event loop (blocks).
|
||||||
|
|
||||||
func (ui *UI) Run() error {
|
func (ui *UI) Run() error {
|
||||||
return ui.app.Run()
|
return ui.app.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the UI.
|
// Stop stops the UI.
|
||||||
|
|
||||||
func (ui *UI) Stop() {
|
func (ui *UI) Stop() {
|
||||||
ui.app.Stop()
|
ui.app.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnInput sets the callback for user input.
|
// OnInput sets the callback for user input.
|
||||||
|
|
||||||
func (ui *UI) OnInput(fn func(string)) {
|
func (ui *UI) OnInput(fn func(string)) {
|
||||||
ui.onInput = fn
|
ui.onInput = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLine adds a line to the specified buffer.
|
// AddLine adds a line to the specified buffer.
|
||||||
|
|
||||||
func (ui *UI) AddLine(bufferName string, line string) {
|
func (ui *UI) AddLine(bufferName string, line string) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
buf := ui.getOrCreateBuffer(bufferName)
|
buf := ui.getOrCreateBuffer(bufferName)
|
||||||
@ -121,89 +77,219 @@ func (ui *UI) AddLine(bufferName string, line string) {
|
|||||||
// Mark unread if not currently viewing this buffer.
|
// Mark unread if not currently viewing this buffer.
|
||||||
if ui.buffers[ui.currentBuffer] != buf {
|
if ui.buffers[ui.currentBuffer] != buf {
|
||||||
buf.Unread++
|
buf.Unread++
|
||||||
|
|
||||||
ui.refreshStatus()
|
ui.refreshStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If viewing this buffer, append to display.
|
// If viewing this buffer, append to display.
|
||||||
if ui.buffers[ui.currentBuffer] == buf {
|
if ui.buffers[ui.currentBuffer] == buf {
|
||||||
fmt.Fprintln(ui.messages, line)
|
_, _ = fmt.Fprintln(ui.messages, line)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddStatus adds a line to the status buffer (buffer 0).
|
// AddStatus adds a line to the status buffer (buffer 0).
|
||||||
|
|
||||||
func (ui *UI) AddStatus(line string) {
|
func (ui *UI) AddStatus(line string) {
|
||||||
ts := time.Now().Format("15:04")
|
ts := time.Now().Format("15:04")
|
||||||
ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line))
|
|
||||||
|
ui.AddLine(
|
||||||
|
"(status)",
|
||||||
|
fmt.Sprintf("[gray]%s[white] %s", ts, line),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchBuffer switches to the buffer at index n.
|
// SwitchBuffer switches to the buffer at index n.
|
||||||
|
|
||||||
func (ui *UI) SwitchBuffer(n int) {
|
func (ui *UI) SwitchBuffer(n int) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
if n < 0 || n >= len(ui.buffers) {
|
if n < 0 || n >= len(ui.buffers) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.currentBuffer = n
|
ui.currentBuffer = n
|
||||||
|
|
||||||
buf := ui.buffers[n]
|
buf := ui.buffers[n]
|
||||||
buf.Unread = 0
|
buf.Unread = 0
|
||||||
|
|
||||||
ui.messages.Clear()
|
ui.messages.Clear()
|
||||||
|
|
||||||
for _, line := range buf.Lines {
|
for _, line := range buf.Lines {
|
||||||
fmt.Fprintln(ui.messages, line)
|
_, _ = fmt.Fprintln(ui.messages, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.messages.ScrollToEnd()
|
ui.messages.ScrollToEnd()
|
||||||
ui.refreshStatus()
|
ui.refreshStatus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchToBuffer switches to the named buffer, creating it if needed.
|
// SwitchToBuffer switches to the named buffer, creating it
|
||||||
|
|
||||||
func (ui *UI) SwitchToBuffer(name string) {
|
func (ui *UI) SwitchToBuffer(name string) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
buf := ui.getOrCreateBuffer(name)
|
buf := ui.getOrCreateBuffer(name)
|
||||||
|
|
||||||
for i, b := range ui.buffers {
|
for i, b := range ui.buffers {
|
||||||
if b == buf {
|
if b == buf {
|
||||||
ui.currentBuffer = i
|
ui.currentBuffer = i
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.Unread = 0
|
buf.Unread = 0
|
||||||
|
|
||||||
ui.messages.Clear()
|
ui.messages.Clear()
|
||||||
|
|
||||||
for _, line := range buf.Lines {
|
for _, line := range buf.Lines {
|
||||||
fmt.Fprintln(ui.messages, line)
|
_, _ = fmt.Fprintln(ui.messages, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.messages.ScrollToEnd()
|
ui.messages.ScrollToEnd()
|
||||||
ui.refreshStatus()
|
ui.refreshStatus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetStatus updates the status bar text.
|
// SetStatus updates the status bar text.
|
||||||
func (ui *UI) SetStatus(nick, target, connStatus string) {
|
|
||||||
|
func (ui *UI) SetStatus(
|
||||||
|
nick, target, connStatus string,
|
||||||
|
) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
ui.refreshStatusWith(nick, target, connStatus)
|
ui.refreshStatusWith(nick, target, connStatus)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) refreshStatus() {
|
// BufferCount returns the number of buffers.
|
||||||
// Will be called from the main goroutine via QueueUpdateDraw parent.
|
|
||||||
// Rebuild status from app state — caller must provide context.
|
func (ui *UI) BufferCount() int {
|
||||||
|
return len(ui.buffers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) refreshStatusWith(nick, target, connStatus string) {
|
// BufferIndex returns the index of a named buffer, or -1.
|
||||||
var unreadParts []string
|
|
||||||
|
func (ui *UI) BufferIndex(name string) int {
|
||||||
for i, buf := range ui.buffers {
|
for i, buf := range ui.buffers {
|
||||||
if buf.Unread > 0 {
|
if buf.Name == name {
|
||||||
unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread))
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unread := ""
|
|
||||||
if len(unreadParts) > 0 {
|
return -1
|
||||||
unread = " [Act: " + strings.Join(unreadParts, ",") + "]"
|
}
|
||||||
|
|
||||||
|
func (ui *UI) setupMessages() {
|
||||||
|
ui.messages = tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetScrollable(true).
|
||||||
|
SetWordWrap(true).
|
||||||
|
SetChangedFunc(func() {
|
||||||
|
ui.app.Draw()
|
||||||
|
})
|
||||||
|
ui.messages.SetBorder(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UI) setupStatusBar() {
|
||||||
|
ui.statusBar = tview.NewTextView().
|
||||||
|
SetDynamicColors(true)
|
||||||
|
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
|
||||||
|
ui.statusBar.SetTextColor(tcell.ColorWhite)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) setupKeybindings() {
|
||||||
|
ui.app.SetInputCapture(
|
||||||
|
func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
r := event.Rune()
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
idx := int(r - '0')
|
||||||
|
ui.SwitchBuffer(idx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UI) setupLayout() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if needed.
|
||||||
|
|
||||||
|
func (ui *UI) refreshStatus() {
|
||||||
|
// Rebuilt from app state by parent QueueUpdateDraw.
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name)
|
unread := ""
|
||||||
|
if len(unreadParts) > 0 {
|
||||||
|
unread = " [Act: " +
|
||||||
|
strings.Join(unreadParts, ",") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
bufInfo := fmt.Sprintf(
|
||||||
|
"[%d:%s]",
|
||||||
|
ui.currentBuffer,
|
||||||
|
ui.buffers[ui.currentBuffer].Name,
|
||||||
|
)
|
||||||
|
|
||||||
ui.statusBar.Clear()
|
ui.statusBar.Clear()
|
||||||
fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s",
|
|
||||||
connStatus, nick, bufInfo, target, unread)
|
_, _ = fmt.Fprintf(
|
||||||
|
ui.statusBar, " [%s] %s %s %s%s",
|
||||||
|
connStatus, nick, bufInfo, target, unread,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
||||||
@ -212,22 +298,9 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
|||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := &Buffer{Name: name}
|
buf := &Buffer{Name: name}
|
||||||
ui.buffers = append(ui.buffers, buf)
|
ui.buffers = append(ui.buffers, buf)
|
||||||
|
|
||||||
return buf
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -88,8 +88,10 @@ func NewTest(dsn string) (*Database, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Item 9: Enable foreign keys
|
// Item 9: Enable foreign keys
|
||||||
if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
_, err = d.Exec("PRAGMA foreign_keys = ON") //nolint:noctx // no context in sql.Open path
|
||||||
|
if err != nil {
|
||||||
_ = d.Close()
|
_ = d.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +164,7 @@ func (s *Database) GetChannelByID(
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByNick looks up a user by their nick.
|
// GetUserByNickModel looks up a user by their nick.
|
||||||
func (s *Database) GetUserByNickModel(
|
func (s *Database) GetUserByNickModel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick string,
|
nick string,
|
||||||
@ -185,7 +187,7 @@ func (s *Database) GetUserByNickModel(
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByToken looks up a user by their auth token.
|
// GetUserByTokenModel looks up a user by their auth token.
|
||||||
func (s *Database) GetUserByTokenModel(
|
func (s *Database) GetUserByTokenModel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
token string,
|
token string,
|
||||||
@ -219,6 +221,7 @@ func (s *Database) DeleteAuthToken(
|
|||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
`DELETE FROM auth_tokens WHERE token = ?`, token,
|
`DELETE FROM auth_tokens WHERE token = ?`, token,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,10 +234,11 @@ func (s *Database) UpdateUserLastSeen(
|
|||||||
`UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
`UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser inserts a new user into the database.
|
// CreateUserModel inserts a new user into the database.
|
||||||
func (s *Database) CreateUserModel(
|
func (s *Database) CreateUserModel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
id, nick, passwordHash string,
|
id, nick, passwordHash string,
|
||||||
@ -394,6 +398,7 @@ func (s *Database) DequeueMessages(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
entries := []*models.MessageQueueEntry{}
|
entries := []*models.MessageQueueEntry{}
|
||||||
@ -423,14 +428,14 @@ func (s *Database) AckMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
placeholders := make([]string, len(entryIDs))
|
placeholders := make([]string, len(entryIDs))
|
||||||
args := make([]interface{}, len(entryIDs))
|
args := make([]any, len(entryIDs))
|
||||||
|
|
||||||
for i, id := range entryIDs {
|
for i, id := range entryIDs {
|
||||||
placeholders[i] = "?"
|
placeholders[i] = "?"
|
||||||
args[i] = id
|
args[i] = id
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(
|
query := fmt.Sprintf( //nolint:gosec // placeholders are ?, not user input
|
||||||
"DELETE FROM message_queue WHERE id IN (%s)",
|
"DELETE FROM message_queue WHERE id IN (%s)",
|
||||||
strings.Join(placeholders, ","),
|
strings.Join(placeholders, ","),
|
||||||
)
|
)
|
||||||
@ -549,7 +554,8 @@ func (s *Database) connect(ctx context.Context) error {
|
|||||||
s.log.Info("database connected")
|
s.log.Info("database connected")
|
||||||
|
|
||||||
// Item 9: Enable foreign keys on every connection
|
// 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)
|
return fmt.Errorf("enable foreign keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -676,41 +682,54 @@ func (s *Database) applyMigrations(
|
|||||||
"version", m.version, "name", m.name,
|
"version", m.version, "name", m.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
err = s.executeMigration(ctx, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return err
|
||||||
"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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"commit migration %d: %w", m.version, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Database) executeMigration(
|
||||||
|
ctx context.Context,
|
||||||
|
m migration,
|
||||||
|
) error {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -3,107 +3,144 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMessageLimit = 50
|
||||||
|
defaultPollLimit = 100
|
||||||
|
tokenBytes = 32
|
||||||
|
)
|
||||||
|
|
||||||
func generateToken() string {
|
func generateToken() string {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, tokenBytes)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
|
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser registers a new user with the given nick and returns the user with token.
|
// CreateUser registers a new user with the given nick and
|
||||||
func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) {
|
// returns the user with token.
|
||||||
|
func (s *Database) CreateUser(
|
||||||
|
ctx context.Context,
|
||||||
|
nick string,
|
||||||
|
) (int64, string, error) {
|
||||||
token := generateToken()
|
token := generateToken()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
"INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)",
|
"INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)",
|
||||||
nick, token, now, now)
|
nick, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", fmt.Errorf("create user: %w", err)
|
return 0, "", fmt.Errorf("create user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
|
|
||||||
return id, token, nil
|
return id, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByToken returns user id and nick for a given auth token.
|
// GetUserByToken returns user id and nick for a given auth
|
||||||
func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) {
|
// token.
|
||||||
|
func (s *Database) GetUserByToken(
|
||||||
|
ctx context.Context,
|
||||||
|
token string,
|
||||||
|
) (int64, string, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|
||||||
var nick string
|
var nick string
|
||||||
err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick)
|
|
||||||
|
err := s.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT id, nick FROM users WHERE token = ?",
|
||||||
|
token,
|
||||||
|
).Scan(&id, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", err
|
return 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last_seen
|
// Update last_seen
|
||||||
_, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id)
|
_, _ = s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"UPDATE users SET last_seen = ? WHERE id = ?",
|
||||||
|
time.Now(), id,
|
||||||
|
)
|
||||||
|
|
||||||
return id, nick, nil
|
return id, nick, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByNick returns user id for a given nick.
|
// GetUserByNick returns user id for a given nick.
|
||||||
func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) {
|
func (s *Database) GetUserByNick(
|
||||||
|
ctx context.Context,
|
||||||
|
nick string,
|
||||||
|
) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id)
|
|
||||||
|
err := s.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT id FROM users WHERE nick = ?",
|
||||||
|
nick,
|
||||||
|
).Scan(&id)
|
||||||
|
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrCreateChannel returns the channel id, creating it if needed.
|
// GetOrCreateChannel returns the channel id, creating it if
|
||||||
func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) {
|
// needed.
|
||||||
|
func (s *Database) GetOrCreateChannel(
|
||||||
|
ctx context.Context,
|
||||||
|
name string,
|
||||||
|
) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id)
|
|
||||||
|
err := s.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT id FROM channels WHERE name = ?",
|
||||||
|
name,
|
||||||
|
).Scan(&id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
"INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)",
|
"INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)",
|
||||||
name, now, now)
|
name, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("create channel: %w", err)
|
return 0, fmt.Errorf("create channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ = res.LastInsertId()
|
id, _ = res.LastInsertId()
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// JoinChannel adds a user to a channel.
|
// 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 int64,
|
||||||
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, 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, joined_at) VALUES (?, ?, ?)",
|
||||||
channelID, userID, time.Now())
|
channelID, userID, time.Now())
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PartChannel removes a user from a channel.
|
// 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 int64,
|
||||||
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
"DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?",
|
"DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?",
|
||||||
channelID, userID)
|
channelID, userID)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListChannels returns all channels the user has joined.
|
return err
|
||||||
func (s *Database) ListChannels(ctx context.Context, userID int64) ([]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
|
|
||||||
WHERE cm.user_id = ? ORDER BY c.name`, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelInfo is a lightweight channel representation.
|
// ChannelInfo is a lightweight channel representation.
|
||||||
@ -113,28 +150,44 @@ type ChannelInfo struct {
|
|||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMembers returns all members of a channel.
|
// ListChannels returns all channels the user has joined.
|
||||||
func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) {
|
func (s *Database) ListChannels(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int64,
|
||||||
|
) ([]ChannelInfo, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := s.db.QueryContext(ctx,
|
||||||
`SELECT u.id, u.nick, u.last_seen FROM users u
|
`SELECT c.id, c.name, c.topic FROM channels c
|
||||||
INNER JOIN channel_members cm ON cm.user_id = u.id
|
INNER JOIN channel_members cm ON cm.channel_id = c.id
|
||||||
WHERE cm.channel_id = ? ORDER BY u.nick`, channelID)
|
WHERE cm.user_id = ? ORDER BY c.name`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var members []MemberInfo
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var channels []ChannelInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m MemberInfo
|
var ch ChannelInfo
|
||||||
if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil {
|
|
||||||
|
err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
members = append(members, m)
|
|
||||||
|
channels = append(channels, ch)
|
||||||
}
|
}
|
||||||
if members == nil {
|
|
||||||
members = []MemberInfo{}
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return members, nil
|
|
||||||
|
if channels == nil {
|
||||||
|
channels = []ChannelInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemberInfo represents a channel member.
|
// MemberInfo represents a channel member.
|
||||||
@ -144,6 +197,46 @@ type MemberInfo struct {
|
|||||||
LastSeen time.Time `json:"lastSeen"`
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelMembers returns all members of a channel.
|
||||||
|
func (s *Database) ChannelMembers(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) ([]MemberInfo, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT u.id, u.nick, u.last_seen 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 func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var members []MemberInfo
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var m MemberInfo
|
||||||
|
|
||||||
|
err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if members == nil {
|
||||||
|
members = []MemberInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MessageInfo represents a chat message.
|
// MessageInfo represents a chat message.
|
||||||
type MessageInfo struct {
|
type MessageInfo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@ -155,11 +248,18 @@ type MessageInfo struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMessages returns messages for a channel, optionally after a given ID.
|
// GetMessages returns messages for a channel, optionally
|
||||||
func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) {
|
// after a given ID.
|
||||||
|
func (s *Database) GetMessages(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
afterID int64,
|
||||||
|
limit int,
|
||||||
|
) ([]MessageInfo, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = defaultMessageLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := s.db.QueryContext(ctx,
|
||||||
`SELECT m.id, c.name, u.nick, m.content, m.created_at
|
`SELECT m.id, c.name, u.nick, m.content, m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
@ -170,128 +270,288 @@ func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var msgs []MessageInfo
|
var msgs []MessageInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m MessageInfo
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs = append(msgs, m)
|
msgs = append(msgs, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if msgs == nil {
|
if msgs == nil {
|
||||||
msgs = []MessageInfo{}
|
msgs = []MessageInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage inserts a channel message.
|
// SendMessage inserts a channel message.
|
||||||
func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) {
|
func (s *Database) SendMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID, userID int64,
|
||||||
|
content string,
|
||||||
|
) (int64, error) {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
"INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)",
|
"INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
channelID, userID, content, time.Now())
|
channelID, userID, content, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDM inserts a direct message.
|
// SendDM inserts a direct message.
|
||||||
func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) {
|
func (s *Database) SendDM(
|
||||||
|
ctx context.Context,
|
||||||
|
fromID, toID int64,
|
||||||
|
content string,
|
||||||
|
) (int64, error) {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
"INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)",
|
"INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)",
|
||||||
fromID, content, toID, time.Now())
|
fromID, content, toID, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDMs returns direct messages between two users after a given ID.
|
// GetDMs returns direct messages between two users after a
|
||||||
func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) {
|
// given ID.
|
||||||
|
func (s *Database) GetDMs(
|
||||||
|
ctx context.Context,
|
||||||
|
userA, userB int64,
|
||||||
|
afterID int64,
|
||||||
|
limit int,
|
||||||
|
) ([]MessageInfo, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = defaultMessageLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := s.db.QueryContext(ctx,
|
||||||
`SELECT m.id, u.nick, m.content, t.nick, m.created_at
|
`SELECT m.id, u.nick, m.content, t.nick, m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN users u ON u.id = m.user_id
|
INNER JOIN users u ON u.id = m.user_id
|
||||||
INNER JOIN users t ON t.id = m.dm_target_id
|
INNER JOIN users t ON t.id = m.dm_target_id
|
||||||
WHERE m.is_dm = 1 AND m.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 = ?))
|
AND ((m.user_id = ? AND m.dm_target_id = ?)
|
||||||
ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit)
|
OR (m.user_id = ? AND m.dm_target_id = ?))
|
||||||
|
ORDER BY m.id ASC LIMIT ?`,
|
||||||
|
afterID, userA, userB, userB, userA, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var msgs []MessageInfo
|
var msgs []MessageInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m MessageInfo
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.IsDM = true
|
m.IsDM = true
|
||||||
|
|
||||||
msgs = append(msgs, m)
|
msgs = append(msgs, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if msgs == nil {
|
if msgs == nil {
|
||||||
msgs = []MessageInfo{}
|
msgs = []MessageInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollMessages returns all new messages (channel + DM) for a user after a given ID.
|
// PollMessages returns all new messages (channel + DM) for
|
||||||
func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) {
|
// a user after a given ID.
|
||||||
|
func (s *Database) PollMessages(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int64,
|
||||||
|
afterID int64,
|
||||||
|
limit int,
|
||||||
|
) ([]MessageInfo, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = defaultPollLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx,
|
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
|
`SELECT m.id, COALESCE(c.name, ''), u.nick, m.content,
|
||||||
|
m.is_dm, COALESCE(t.nick, ''), m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN users u ON u.id = m.user_id
|
INNER JOIN users u ON u.id = m.user_id
|
||||||
LEFT JOIN channels c ON c.id = m.channel_id
|
LEFT JOIN channels c ON c.id = m.channel_id
|
||||||
LEFT JOIN users t ON t.id = m.dm_target_id
|
LEFT JOIN users t ON t.id = m.dm_target_id
|
||||||
WHERE m.id > ? AND (
|
WHERE m.id > ? AND (
|
||||||
(m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?))
|
(m.is_dm = 0 AND m.channel_id IN
|
||||||
OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?))
|
(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)
|
ORDER BY m.id ASC LIMIT ?`,
|
||||||
|
afterID, userID, userID, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var msgs []MessageInfo
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
msgs := make([]MessageInfo, 0)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m MessageInfo
|
var (
|
||||||
var isDM int
|
m MessageInfo
|
||||||
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil {
|
isDM int
|
||||||
|
)
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&m.ID, &m.Channel, &m.Nick, &m.Content,
|
||||||
|
&isDM, &m.DMTarget, &m.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.IsDM = isDM == 1
|
m.IsDM = isDM == 1
|
||||||
msgs = append(msgs, m)
|
msgs = append(msgs, m)
|
||||||
}
|
}
|
||||||
if msgs == nil {
|
|
||||||
msgs = []MessageInfo{}
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMessagesBefore returns channel messages before a given ID (for history scrollback).
|
func scanChannelMessages(
|
||||||
func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) {
|
rows *sql.Rows,
|
||||||
if limit <= 0 {
|
) ([]MessageInfo, error) {
|
||||||
limit = 50
|
var msgs []MessageInfo
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs = append(msgs, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = []MessageInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDMMessages(
|
||||||
|
rows *sql.Rows,
|
||||||
|
) ([]MessageInfo, error) {
|
||||||
|
var msgs []MessageInfo
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
m.IsDM = true
|
||||||
|
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = []MessageInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseMessages(msgs []MessageInfo) {
|
||||||
|
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = defaultMessageLimit
|
||||||
|
}
|
||||||
|
|
||||||
var query string
|
var query string
|
||||||
|
|
||||||
var args []any
|
var args []any
|
||||||
|
|
||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at
|
query = `SELECT m.id, c.name, u.nick, m.content,
|
||||||
|
m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN users u ON u.id = m.user_id
|
INNER JOIN users u ON u.id = m.user_id
|
||||||
INNER JOIN channels c ON c.id = m.channel_id
|
INNER JOIN channels c ON c.id = m.channel_id
|
||||||
WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id < ?
|
WHERE m.channel_id = ? AND m.is_dm = 0
|
||||||
|
AND m.id < ?
|
||||||
ORDER BY m.id DESC LIMIT ?`
|
ORDER BY m.id DESC LIMIT ?`
|
||||||
args = []any{channelID, beforeID, limit}
|
args = []any{channelID, beforeID, limit}
|
||||||
} else {
|
} else {
|
||||||
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at
|
query = `SELECT m.id, c.name, u.nick, m.content,
|
||||||
|
m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN users u ON u.id = m.user_id
|
INNER JOIN users u ON u.id = m.user_id
|
||||||
INNER JOIN channels c ON c.id = m.channel_id
|
INNER JOIN channels c ON c.id = m.channel_id
|
||||||
@ -299,116 +559,153 @@ func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, befor
|
|||||||
ORDER BY m.id DESC LIMIT ?`
|
ORDER BY m.id DESC LIMIT ?`
|
||||||
args = []any{channelID, limit}
|
args = []any{channelID, limit}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var msgs []MessageInfo
|
defer func() { _ = rows.Close() }()
|
||||||
for rows.Next() {
|
|
||||||
var m MessageInfo
|
msgs, scanErr := scanChannelMessages(rows)
|
||||||
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil {
|
if scanErr != nil {
|
||||||
return nil, err
|
return nil, scanErr
|
||||||
}
|
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse to ascending order.
|
||||||
|
reverseMessages(msgs)
|
||||||
|
|
||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDMsBefore returns DMs between two users before a given ID (for history scrollback).
|
// GetDMsBefore returns DMs between two users before a given
|
||||||
func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) {
|
// ID (for history scrollback).
|
||||||
|
func (s *Database) GetDMsBefore(
|
||||||
|
ctx context.Context,
|
||||||
|
userA, userB int64,
|
||||||
|
beforeID int64,
|
||||||
|
limit int,
|
||||||
|
) ([]MessageInfo, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = defaultMessageLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
var query string
|
var query string
|
||||||
|
|
||||||
var args []any
|
var args []any
|
||||||
|
|
||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at
|
query = `SELECT m.id, u.nick, m.content, t.nick,
|
||||||
|
m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN users u ON u.id = m.user_id
|
INNER JOIN users u ON u.id = m.user_id
|
||||||
INNER JOIN users t ON t.id = m.dm_target_id
|
INNER JOIN users t ON t.id = m.dm_target_id
|
||||||
WHERE m.is_dm = 1 AND m.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 = ?))
|
AND ((m.user_id = ? AND m.dm_target_id = ?)
|
||||||
|
OR (m.user_id = ? AND m.dm_target_id = ?))
|
||||||
ORDER BY m.id DESC LIMIT ?`
|
ORDER BY m.id DESC LIMIT ?`
|
||||||
args = []any{beforeID, userA, userB, userB, userA, limit}
|
args = []any{
|
||||||
|
beforeID, userA, userB, userB, userA, limit,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at
|
query = `SELECT m.id, u.nick, m.content, t.nick,
|
||||||
|
m.created_at
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN users u ON u.id = m.user_id
|
INNER JOIN users u ON u.id = m.user_id
|
||||||
INNER JOIN users t ON t.id = m.dm_target_id
|
INNER JOIN users t ON t.id = m.dm_target_id
|
||||||
WHERE m.is_dm = 1
|
WHERE m.is_dm = 1
|
||||||
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_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 ?`
|
ORDER BY m.id DESC LIMIT ?`
|
||||||
args = []any{userA, userB, userB, userA, limit}
|
args = []any{userA, userB, userB, userA, limit}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
var msgs []MessageInfo
|
defer func() { _ = rows.Close() }()
|
||||||
for rows.Next() {
|
|
||||||
var m MessageInfo
|
msgs, scanErr := scanDMMessages(rows)
|
||||||
if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil {
|
if scanErr != nil {
|
||||||
return nil, err
|
return nil, scanErr
|
||||||
}
|
|
||||||
m.IsDM = true
|
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse to ascending order.
|
||||||
|
reverseMessages(msgs)
|
||||||
|
|
||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangeNick updates a user's nickname.
|
// 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 int64,
|
||||||
|
newNick string,
|
||||||
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
"UPDATE users SET nick = ? WHERE id = ?", newNick, userID)
|
"UPDATE users SET nick = ? WHERE id = ?",
|
||||||
|
newNick, userID)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTopic sets the topic for a channel.
|
// 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,
|
||||||
|
_ int64,
|
||||||
|
topic string,
|
||||||
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
"UPDATE channels SET topic = ? WHERE name = ?", topic, channelName)
|
"UPDATE channels SET topic = ? WHERE name = ?",
|
||||||
|
topic, channelName)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServerName returns the server name (unused, config provides this).
|
// GetServerName returns the server name (unused, config
|
||||||
|
// provides this).
|
||||||
func (s *Database) GetServerName() string {
|
func (s *Database) GetServerName() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAllChannels returns all channels.
|
// ListAllChannels returns all channels.
|
||||||
func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) {
|
func (s *Database) ListAllChannels(
|
||||||
|
ctx context.Context,
|
||||||
|
) ([]ChannelInfo, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := s.db.QueryContext(ctx,
|
||||||
"SELECT id, name, topic FROM channels ORDER BY name")
|
"SELECT id, name, topic FROM channels ORDER BY name")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var channels []ChannelInfo
|
var channels []ChannelInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ch ChannelInfo
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
channels = append(channels, ch)
|
channels = append(channels, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if channels == nil {
|
if channels == nil {
|
||||||
channels = []ChannelInfo{}
|
channels = []ChannelInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return channels, nil
|
return channels, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,6 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,5 +22,5 @@ func (t *AuthToken) User(ctx context.Context) (*User, error) {
|
|||||||
return ul.GetUserByID(ctx, t.UserID)
|
return ul.GetUserByID(ctx, t.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("user lookup not available")
|
return nil, ErrUserLookupNotAvailable
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,7 +22,7 @@ func (cm *ChannelMember) User(ctx context.Context) (*User, error) {
|
|||||||
return ul.GetUserByID(ctx, cm.UserID)
|
return ul.GetUserByID(ctx, cm.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("user lookup not available")
|
return nil, ErrUserLookupNotAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel returns the full Channel for this membership.
|
// 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 cl.GetChannelByID(ctx, cm.ChannelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("channel lookup not available")
|
return nil, ErrChannelLookupNotAvailable
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DB is the interface that models use to query the database.
|
// DB is the interface that models use to query the database.
|
||||||
@ -24,6 +25,12 @@ type ChannelLookup interface {
|
|||||||
GetChannelByID(ctx context.Context, id string) (*Channel, error)
|
GetChannelByID(ctx context.Context, id string) (*Channel, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sentinel errors for model lookup methods.
|
||||||
|
var (
|
||||||
|
ErrUserLookupNotAvailable = errors.New("user lookup not available")
|
||||||
|
ErrChannelLookupNotAvailable = errors.New("channel lookup not available")
|
||||||
|
)
|
||||||
|
|
||||||
// Base is embedded in all model structs to provide database access.
|
// Base is embedded in all model structs to provide database access.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
db DB
|
db DB
|
||||||
@ -40,7 +47,7 @@ func (b *Base) GetDB() *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserLookup returns the DB as a UserLookup if it implements the interface.
|
// GetUserLookup returns the DB as a UserLookup if it implements the interface.
|
||||||
func (b *Base) GetUserLookup() UserLookup {
|
func (b *Base) GetUserLookup() UserLookup { //nolint:ireturn // interface return is intentional
|
||||||
if ul, ok := b.db.(UserLookup); ok {
|
if ul, ok := b.db.(UserLookup); ok {
|
||||||
return ul
|
return ul
|
||||||
}
|
}
|
||||||
@ -49,7 +56,7 @@ func (b *Base) GetUserLookup() UserLookup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelLookup returns the DB as a ChannelLookup if it implements the interface.
|
// GetChannelLookup returns the DB as a ChannelLookup if it implements the interface.
|
||||||
func (b *Base) GetChannelLookup() ChannelLookup {
|
func (b *Base) GetChannelLookup() ChannelLookup { //nolint:ireturn // interface return is intentional
|
||||||
if cl, ok := b.db.(ChannelLookup); ok {
|
if cl, ok := b.db.(ChannelLookup); ok {
|
||||||
return cl
|
return cl
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,5 +22,5 @@ func (s *Session) User(ctx context.Context) (*User, error) {
|
|||||||
return ul.GetUserByID(ctx, s.UserID)
|
return ul.GetUserByID(ctx, s.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("user lookup not available")
|
return nil, ErrUserLookupNotAvailable
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,17 +71,37 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.log.Error("failed to get web dist filesystem", "error", err)
|
s.log.Error("failed to get web dist filesystem", "error", err)
|
||||||
} else {
|
} else {
|
||||||
fileServer := http.FileServer(http.FS(distFS))
|
fileServer := http.FileServer(http.FS(distFS))
|
||||||
|
|
||||||
s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Try to serve the file; if not found, serve index.html for SPA routing
|
s.serveSPA(distFS, fileServer, w, r)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveSPA(
|
||||||
|
distFS fs.FS,
|
||||||
|
fileServer http.Handler,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
|
readFS, ok := distFS.(fs.ReadFileFS)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "filesystem error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve the file; fall back to 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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user