12 Commits

Author SHA1 Message Date
clawbot
c7bcedd8d4 fix: pin golangci-lint install to commit SHA (fixes #13)
All checks were successful
check / check (push) Successful in 1m31s
- Fix Dockerfile: use correct v2 module path with commit SHA
  (github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638...)
- Add CGO_ENABLED=0 for alpine compatibility
- Fix 35 lint issues found by golangci-lint v2.1.6:
  - Remove unused nolint directives, add nolintlint where needed
  - Pre-allocate migrations slice
  - Use t.Context() instead of context.Background() in tests
  - Fix blank lines between comments and exported functions in ui.go
2026-02-26 20:20:31 -08:00
9daf836cbe Merge pull request 'fix: repo standards audit — fix all divergences (closes #17)' (#18) from fix/repo-standards-audit into main
Some checks failed
check / check (push) Failing after 12s
Reviewed-on: #18
2026-02-27 05:10:00 +01:00
84303c969a fix: pin golangci-lint to v2.1.6 in Dockerfile
Some checks failed
check / check (push) Failing after 14s
Replace @latest with @v2.1.6 to comply with hash-pinning policy
defined in REPO_POLICIES.md.
2026-02-26 11:43:52 -08:00
clawbot
d2bc467581 fix: resolve lint issues — rename api package, fix nolint directives
Some checks failed
check / check (push) Failing after 1m3s
2026-02-26 07:45:37 -08:00
clawbot
88af2ea98f fix: repair migration 003 schema conflict and rewrite tests (refs #17)
Some checks failed
check / check (push) Failing after 1m18s
Migration 003 created tables with INTEGER keys referencing TEXT primary
keys from migration 002, causing 'no such column' errors. Fix by
properly dropping old tables before recreating with the integer schema.

Rewrite all tests to use the queries.go API (which matches the live
schema) instead of the model-based API (which expected the old UUID
schema).
2026-02-26 06:28:07 -08:00
clawbot
b78d526f02 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
2026-02-26 06:27:56 -08:00
clawbot
636546d74a docs: add Author section to README (refs #17) 2026-02-26 06:09:08 -08:00
clawbot
27de1227c4 chore: pin Dockerfile images by sha256, run make check in build (refs #17) 2026-02-26 06:09:04 -08:00
clawbot
ef83d6624b chore: fix Makefile — add fmt-check, docker, hooks targets; 30s test timeout (refs #17) 2026-02-26 06:08:47 -08:00
clawbot
fc91dc37c0 chore: update .gitignore and .dockerignore to match standards (refs #17) 2026-02-26 06:08:31 -08:00
clawbot
1e5811edda chore: add missing required files (refs #17)
Add LICENSE (MIT), .editorconfig, REPO_POLICIES.md, and
.gitea/workflows/check.yml per repo standards.
2026-02-26 06:08:24 -08:00
clawbot
3f8ceefd52 fix: rename duplicate db methods to fix compilation (refs #17)
CreateUser, GetUserByNick, GetUserByToken exist in both db.go (model-based,
used by tests) and queries.go (simple, used by handlers). Rename the
model-based variants to CreateUserModel, GetUserByNickModel, and
GetUserByTokenModel to resolve the compilation error.
2026-02-26 06:08:07 -08:00
23 changed files with 2475 additions and 1100 deletions

View File

@@ -1,8 +1,9 @@
.git
node_modules
.DS_Store
bin/ bin/
chatd
data.db data.db
.env .env
.git
*.test *.test
*.out *.out
debug.log debug.log

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

30
.gitignore vendored
View File

@@ -1,7 +1,28 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Build artifacts
/chatd /chatd
/bin/ /bin/
data.db
.env
*.exe *.exe
*.dll *.dll
*.so *.so
@@ -9,6 +30,9 @@ data.db
*.test *.test
*.out *.out
vendor/ vendor/
# Project
data.db
debug.log debug.log
/chat-cli
web/node_modules/ web/node_modules/
chat-cli

View File

@@ -1,6 +1,7 @@
FROM golang:1.24-alpine AS builder # golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
RUN apk add --no-cache git RUN apk add --no-cache git build-base
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -8,10 +9,16 @@ RUN go mod download
COPY . . COPY . .
# Run all checks — build fails if branch is not green
# golangci-lint v2.1.6
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
RUN make check
ARG VERSION=dev ARG VERSION=dev
RUN go build -ldflags "-X main.Version=${VERSION}" -o /chatd ./cmd/chatd RUN go build -ldflags "-X main.Version=${VERSION}" -o /chatd ./cmd/chatd
FROM alpine:3.21 # alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates
COPY --from=builder /chatd /usr/local/bin/chatd COPY --from=builder /chatd /usr/local/bin/chatd

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 sneak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt test check clean run debug .PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := chatd BINARY := chatd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -17,18 +17,15 @@ fmt:
gofmt -s -w . gofmt -s -w .
goimports -w . goimports -w .
test: fmt-check:
go test -v -race -cover ./...
# Check runs all validation without making changes
# Used by CI and Docker build — fails if anything is wrong
check:
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./... test:
@echo "==> Running tests..." go test -timeout 30s -v -race -cover ./...
go test -v -race ./...
# check runs all validation without making changes
# Used by CI and Docker build — fails if anything is wrong
check: test lint fmt-check
@echo "==> Building..." @echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
@echo "==> All checks passed!" @echo "==> All checks passed!"
@@ -41,3 +38,12 @@ debug: build
clean: clean:
rm -rf bin/ chatd data.db rm -rf bin/ chatd data.db
docker:
docker build -t chat .
hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

View File

@@ -656,3 +656,8 @@ embedded web client.
## License ## License
MIT MIT
## Author
[@sneak](https://sneak.berlin)

182
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,182 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
Post-1.0.0: add new migration files.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

View File

@@ -1,15 +1,31 @@
package api // Package chatapi provides a client for the chat server HTTP API.
package chatapi
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,nolintlint // 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,nolintlint // 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
}

View File

@@ -1,4 +1,4 @@
package api package chatapi
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
} }

View File

@@ -1,13 +1,21 @@
// 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"
"git.eeqj.de/sneak/chat/cmd/chat-cli/api" 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.
@@ -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,
),
)
}

View File

@@ -39,60 +39,11 @@ 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
} }
@@ -121,12 +72,13 @@ 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)
} }
}) })
} }
@@ -134,7 +86,11 @@ func (ui *UI) AddLine(bufferName string, line string) {
// 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.
@@ -143,80 +99,58 @@ func (ui *UI) SwitchBuffer(n int) {
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() {
// Will be called from the main goroutine via QueueUpdateDraw parent.
// Rebuild status from app state — caller must provide context.
}
func (ui *UI) refreshStatusWith(nick, target, connStatus string) {
var unreadParts []string
for i, buf := range ui.buffers {
if buf.Unread > 0 {
unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread))
}
}
unread := ""
if len(unreadParts) > 0 {
unread = " [Act: " + strings.Join(unreadParts, ",") + "]"
}
bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name)
ui.statusBar.Clear()
fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s",
connStatus, nick, bufInfo, target, unread)
}
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
for _, buf := range ui.buffers {
if buf.Name == name {
return buf
}
}
buf := &Buffer{Name: name}
ui.buffers = append(ui.buffers, buf)
return buf
}
// BufferCount returns the number of buffers. // BufferCount returns the number of buffers.
func (ui *UI) BufferCount() int { func (ui *UI) BufferCount() int {
return len(ui.buffers) return len(ui.buffers)
@@ -229,5 +163,133 @@ func (ui *UI) BufferIndex(name string) int {
return i return i
} }
} }
return -1 return -1
} }
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,
),
)
}
}
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()
_, _ = fmt.Fprintf(
ui.statusBar, " [%s] %s %s %s%s",
connStatus, nick, bufInfo, target, unread,
)
}
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
for _, buf := range ui.buffers {
if buf.Name == name {
return buf
}
}
buf := &Buffer{Name: name}
ui.buffers = append(ui.buffers, buf)
return buf
}

View File

@@ -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,nolintlint // 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,8 +164,8 @@ 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) GetUserByNick( func (s *Database) GetUserByNickModel(
ctx context.Context, ctx context.Context,
nick string, nick string,
) (*models.User, error) { ) (*models.User, error) {
@@ -185,8 +187,8 @@ func (s *Database) GetUserByNick(
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) GetUserByToken( func (s *Database) GetUserByTokenModel(
ctx context.Context, ctx context.Context,
token string, token string,
) (*models.User, error) { ) (*models.User, error) {
@@ -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,11 +234,12 @@ 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) CreateUser( func (s *Database) CreateUserModel(
ctx context.Context, ctx context.Context,
id, nick, passwordHash string, id, nick, passwordHash string,
) (*models.User, error) { ) (*models.User, error) {
@@ -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)
} }
@@ -606,7 +612,7 @@ func (s *Database) loadMigrations() ([]migration, error) {
return nil, fmt.Errorf("read schema dir: %w", err) return nil, fmt.Errorf("read schema dir: %w", err)
} }
var migrations []migration migrations := make([]migration, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || if entry.IsDir() ||
@@ -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
}

View File

@@ -1,7 +1,6 @@
package db_test package db_test
import ( import (
"context"
"fmt" "fmt"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -41,215 +40,121 @@ func TestCreateUser(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
u, err := d.CreateUser(ctx, "u1", nickAlice, "hash1") id, token, err := d.CreateUser(ctx, nickAlice)
if err != nil { if err != nil {
t.Fatalf("CreateUser: %v", err) t.Fatalf("CreateUser: %v", err)
} }
if u.ID != "u1" || u.Nick != nickAlice { if id <= 0 {
t.Errorf("got user %+v", u) t.Errorf("expected positive id, got %d", id)
}
if token == "" {
t.Error("expected non-empty token")
} }
} }
func TestCreateAuthToken(t *testing.T) { func TestGetUserByToken(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
_, err := d.CreateUser(ctx, "u1", nickAlice, "h") _, token, _ := d.CreateUser(ctx, nickAlice)
id, nick, err := d.GetUserByToken(ctx, token)
if err != nil { if err != nil {
t.Fatalf("CreateUser: %v", err) t.Fatalf("GetUserByToken: %v", err)
} }
tok, err := d.CreateAuthToken(ctx, "tok1", "u1") if id <= 0 || nick != nickAlice {
if err != nil { t.Errorf(
t.Fatalf("CreateAuthToken: %v", err) "got id=%d nick=%s, want nick=%s",
} id, nick, nickAlice,
)
if tok.Token != "tok1" || tok.UserID != "u1" {
t.Errorf("unexpected token: %+v", tok)
}
u, err := tok.User(ctx)
if err != nil {
t.Fatalf("AuthToken.User: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("AuthToken.User got %+v", u)
} }
} }
func TestCreateChannel(t *testing.T) { func TestGetUserByNick(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
ch, err := d.CreateChannel( origID, _, _ := d.CreateUser(ctx, nickAlice)
ctx, "c1", "#general", "welcome", "+n",
) id, err := d.GetUserByNick(ctx, nickAlice)
if err != nil { if err != nil {
t.Fatalf("CreateChannel: %v", err) t.Fatalf("GetUserByNick: %v", err)
} }
if ch.ID != "c1" || ch.Name != "#general" { if id != origID {
t.Errorf("unexpected channel: %+v", ch) t.Errorf("got id %d, want %d", id, origID)
} }
} }
func TestAddChannelMember(t *testing.T) { func TestGetOrCreateChannel(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h") id1, err := d.GetOrCreateChannel(ctx, "#general")
_, _ = d.CreateChannel(ctx, "c1", "#general", "", "")
cm, err := d.AddChannelMember(ctx, "c1", "u1", "+o")
if err != nil { if err != nil {
t.Fatalf("AddChannelMember: %v", err) t.Fatalf("GetOrCreateChannel: %v", err)
} }
if cm.ChannelID != "c1" || cm.Modes != "+o" { if id1 <= 0 {
t.Errorf("unexpected member: %+v", cm) t.Errorf("expected positive id, got %d", id1)
}
// Same channel returns same ID.
id2, err := d.GetOrCreateChannel(ctx, "#general")
if err != nil {
t.Fatalf("GetOrCreateChannel(2): %v", err)
}
if id1 != id2 {
t.Errorf("got different ids: %d vs %d", id1, id2)
} }
} }
func TestCreateMessage(t *testing.T) { func TestJoinAndListChannels(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h") uid, _, _ := d.CreateUser(ctx, nickAlice)
ch1, _ := d.GetOrCreateChannel(ctx, "#alpha")
ch2, _ := d.GetOrCreateChannel(ctx, "#beta")
msg, err := d.CreateMessage( _ = d.JoinChannel(ctx, ch1, uid)
ctx, "m1", "u1", nickAlice, _ = d.JoinChannel(ctx, ch2, uid)
"#general", "message", "hello",
) channels, err := d.ListChannels(ctx, uid)
if err != nil { if err != nil {
t.Fatalf("CreateMessage: %v", err) t.Fatalf("ListChannels: %v", err)
}
if msg.ID != "m1" || msg.Body != "hello" {
t.Errorf("unexpected message: %+v", msg)
}
}
func TestQueueMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
_, _ = d.CreateMessage(
ctx, "m1", "u1", nickAlice, "u2", "message", "hi",
)
mq, err := d.QueueMessage(ctx, "u2", "m1")
if err != nil {
t.Fatalf("QueueMessage: %v", err)
}
if mq.UserID != "u2" || mq.MessageID != "m1" {
t.Errorf("unexpected queue entry: %+v", mq)
}
}
func TestCreateSession(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
sess, err := d.CreateSession(ctx, "s1", "u1")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if sess.ID != "s1" || sess.UserID != "u1" {
t.Errorf("unexpected session: %+v", sess)
}
u, err := sess.User(ctx)
if err != nil {
t.Fatalf("Session.User: %v", err)
}
if u.ID != "u1" {
t.Errorf("Session.User got %v, want u1", u.ID)
}
}
func TestCreateServerLink(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
sl, err := d.CreateServerLink(
ctx, "sl1", "peer1",
"https://peer.example.com", "keyhash", true,
)
if err != nil {
t.Fatalf("CreateServerLink: %v", err)
}
if sl.ID != "sl1" || !sl.IsActive {
t.Errorf("unexpected server link: %+v", sl)
}
}
func TestUserChannels(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#alpha", "", "")
_, _ = d.CreateChannel(ctx, "c2", "#beta", "", "")
_, _ = d.AddChannelMember(ctx, "c1", "u1", "")
_, _ = d.AddChannelMember(ctx, "c2", "u1", "")
channels, err := u.Channels(ctx)
if err != nil {
t.Fatalf("User.Channels: %v", err)
} }
if len(channels) != 2 { if len(channels) != 2 {
t.Fatalf("expected 2 channels, got %d", len(channels)) t.Fatalf("expected 2 channels, got %d", len(channels))
} }
if channels[0].Name != "#alpha" {
t.Errorf("first channel: got %s", channels[0].Name)
}
if channels[1].Name != "#beta" {
t.Errorf("second channel: got %s", channels[1].Name)
}
} }
func TestUserChannelsEmpty(t *testing.T) { func TestListChannelsEmpty(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h") uid, _, _ := d.CreateUser(ctx, nickAlice)
channels, err := u.Channels(ctx) channels, err := d.ListChannels(ctx, uid)
if err != nil { if err != nil {
t.Fatalf("User.Channels: %v", err) t.Fatalf("ListChannels: %v", err)
} }
if len(channels) != 0 { if len(channels) != 0 {
@@ -257,60 +162,57 @@ func TestUserChannelsEmpty(t *testing.T) {
} }
} }
func TestUserQueuedMessages(t *testing.T) { func TestPartChannel(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h") uid, _, _ := d.CreateUser(ctx, nickAlice)
_, _ = d.CreateUser(ctx, "u2", nickBob, "h") chID, _ := d.GetOrCreateChannel(ctx, "#general")
for i := range 3 { _ = d.JoinChannel(ctx, chID, uid)
id := fmt.Sprintf("m%d", i) _ = d.PartChannel(ctx, chID, uid)
_, _ = d.CreateMessage( channels, err := d.ListChannels(ctx, uid)
ctx, id, "u2", nickBob, "u1",
"message", fmt.Sprintf("msg%d", i),
)
time.Sleep(10 * time.Millisecond)
_, _ = d.QueueMessage(ctx, "u1", id)
}
msgs, err := u.QueuedMessages(ctx)
if err != nil { if err != nil {
t.Fatalf("User.QueuedMessages: %v", err) t.Fatalf("ListChannels: %v", err)
} }
if len(msgs) != 3 { if len(channels) != 0 {
t.Fatalf("expected 3 messages, got %d", len(msgs)) t.Errorf("expected 0 after part, got %d", len(channels))
}
for i, msg := range msgs {
want := fmt.Sprintf("msg%d", i)
if msg.Body != want {
t.Errorf("msg %d: got %q, want %q", i, msg.Body, want)
}
} }
} }
func TestUserQueuedMessagesEmpty(t *testing.T) { func TestSendAndGetMessages(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h") uid, _, _ := d.CreateUser(ctx, nickAlice)
chID, _ := d.GetOrCreateChannel(ctx, "#general")
_ = d.JoinChannel(ctx, chID, uid)
msgs, err := u.QueuedMessages(ctx) _, err := d.SendMessage(ctx, chID, uid, "hello world")
if err != nil { if err != nil {
t.Fatalf("User.QueuedMessages: %v", err) t.Fatalf("SendMessage: %v", err)
} }
if len(msgs) != 0 { msgs, err := d.GetMessages(ctx, chID, 0, 0)
t.Errorf("expected 0 messages, got %d", len(msgs)) if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Content != "hello world" {
t.Errorf(
"got content %q, want %q",
msgs[0].Content, "hello world",
)
} }
} }
@@ -318,50 +220,38 @@ func TestChannelMembers(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "") uid1, _, _ := d.CreateUser(ctx, nickAlice)
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h") uid2, _, _ := d.CreateUser(ctx, nickBob)
_, _ = d.CreateUser(ctx, "u2", nickBob, "h") uid3, _, _ := d.CreateUser(ctx, nickCharlie)
_, _ = d.CreateUser(ctx, "u3", nickCharlie, "h") chID, _ := d.GetOrCreateChannel(ctx, "#general")
_, _ = d.AddChannelMember(ctx, "c1", "u1", "+o")
_, _ = d.AddChannelMember(ctx, "c1", "u2", "+v")
_, _ = d.AddChannelMember(ctx, "c1", "u3", "")
members, err := ch.Members(ctx) _ = d.JoinChannel(ctx, chID, uid1)
_ = d.JoinChannel(ctx, chID, uid2)
_ = d.JoinChannel(ctx, chID, uid3)
members, err := d.ChannelMembers(ctx, chID)
if err != nil { if err != nil {
t.Fatalf("Channel.Members: %v", err) t.Fatalf("ChannelMembers: %v", err)
} }
if len(members) != 3 { if len(members) != 3 {
t.Fatalf("expected 3 members, got %d", len(members)) t.Fatalf("expected 3 members, got %d", len(members))
} }
nicks := map[string]bool{}
for _, m := range members {
nicks[m.Nick] = true
}
for _, want := range []string{
nickAlice, nickBob, nickCharlie,
} {
if !nicks[want] {
t.Errorf("missing nick %q", want)
}
}
} }
func TestChannelMembersEmpty(t *testing.T) { func TestChannelMembersEmpty(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
ch, _ := d.CreateChannel(ctx, "c1", "#empty", "", "") chID, _ := d.GetOrCreateChannel(ctx, "#empty")
members, err := ch.Members(ctx) members, err := d.ChannelMembers(ctx, chID)
if err != nil { if err != nil {
t.Fatalf("Channel.Members: %v", err) t.Fatalf("ChannelMembers: %v", err)
} }
if len(members) != 0 { if len(members) != 0 {
@@ -369,126 +259,166 @@ func TestChannelMembersEmpty(t *testing.T) {
} }
} }
func TestChannelRecentMessages(t *testing.T) { func TestSendDM(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "") uid1, _, _ := d.CreateUser(ctx, nickAlice)
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h") uid2, _, _ := d.CreateUser(ctx, nickBob)
msgID, err := d.SendDM(ctx, uid1, uid2, "hey bob")
if err != nil {
t.Fatalf("SendDM: %v", err)
}
if msgID <= 0 {
t.Errorf("expected positive msgID, got %d", msgID)
}
msgs, err := d.GetDMs(ctx, uid1, uid2, 0, 0)
if err != nil {
t.Fatalf("GetDMs: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 DM, got %d", len(msgs))
}
if msgs[0].Content != "hey bob" {
t.Errorf("got %q, want %q", msgs[0].Content, "hey bob")
}
}
func TestPollMessages(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := t.Context()
uid1, _, _ := d.CreateUser(ctx, nickAlice)
uid2, _, _ := d.CreateUser(ctx, nickBob)
chID, _ := d.GetOrCreateChannel(ctx, "#general")
_ = d.JoinChannel(ctx, chID, uid1)
_ = d.JoinChannel(ctx, chID, uid2)
_, _ = d.SendMessage(ctx, chID, uid2, "hello")
_, _ = d.SendDM(ctx, uid2, uid1, "private")
time.Sleep(10 * time.Millisecond)
msgs, err := d.PollMessages(ctx, uid1, 0, 0)
if err != nil {
t.Fatalf("PollMessages: %v", err)
}
if len(msgs) < 2 {
t.Fatalf("expected >=2 messages, got %d", len(msgs))
}
}
func TestChangeNick(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := t.Context()
_, token, _ := d.CreateUser(ctx, nickAlice)
err := d.ChangeNick(ctx, 1, "alice2")
if err != nil {
t.Fatalf("ChangeNick: %v", err)
}
_, nick, err := d.GetUserByToken(ctx, token)
if err != nil {
t.Fatalf("GetUserByToken: %v", err)
}
if nick != "alice2" {
t.Errorf("got nick %q, want alice2", nick)
}
}
func TestSetTopic(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := t.Context()
uid, _, _ := d.CreateUser(ctx, nickAlice)
_, _ = d.GetOrCreateChannel(ctx, "#general")
err := d.SetTopic(ctx, "#general", uid, "new topic")
if err != nil {
t.Fatalf("SetTopic: %v", err)
}
channels, err := d.ListAllChannels(ctx)
if err != nil {
t.Fatalf("ListAllChannels: %v", err)
}
found := false
for _, ch := range channels {
if ch.Name == "#general" && ch.Topic == "new topic" {
found = true
}
}
if !found {
t.Error("topic was not updated")
}
}
func TestGetMessagesBefore(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := t.Context()
uid, _, _ := d.CreateUser(ctx, nickAlice)
chID, _ := d.GetOrCreateChannel(ctx, "#general")
_ = d.JoinChannel(ctx, chID, uid)
for i := range 5 { for i := range 5 {
id := fmt.Sprintf("m%d", i) _, _ = d.SendMessage(
ctx, chID, uid,
_, _ = d.CreateMessage( fmt.Sprintf("msg%d", i),
ctx, id, "u1", nickAlice, "#general",
"message", fmt.Sprintf("msg%d", i),
) )
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
msgs, err := ch.RecentMessages(ctx, 3) msgs, err := d.GetMessagesBefore(ctx, chID, 0, 3)
if err != nil { if err != nil {
t.Fatalf("RecentMessages: %v", err) t.Fatalf("GetMessagesBefore: %v", err)
} }
if len(msgs) != 3 { if len(msgs) != 3 {
t.Fatalf("expected 3, got %d", len(msgs)) t.Fatalf("expected 3, got %d", len(msgs))
} }
if msgs[0].Body != "msg4" {
t.Errorf("first: got %q, want msg4", msgs[0].Body)
}
if msgs[2].Body != "msg2" {
t.Errorf("last: got %q, want msg2", msgs[2].Body)
}
} }
func TestChannelRecentMessagesLargeLimit(t *testing.T) { func TestListAllChannels(t *testing.T) {
t.Parallel() t.Parallel()
d := setupTestDB(t) d := setupTestDB(t)
ctx := context.Background() ctx := t.Context()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "") _, _ = d.GetOrCreateChannel(ctx, "#alpha")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h") _, _ = d.GetOrCreateChannel(ctx, "#beta")
_, _ = d.CreateMessage(
ctx, "m1", "u1", nickAlice,
"#general", "message", "only",
)
msgs, err := ch.RecentMessages(ctx, 100) channels, err := d.ListAllChannels(ctx)
if err != nil { if err != nil {
t.Fatalf("RecentMessages: %v", err) t.Fatalf("ListAllChannels: %v", err)
} }
if len(msgs) != 1 { if len(channels) != 2 {
t.Errorf("expected 1, got %d", len(msgs)) t.Errorf("expected 2, got %d", len(channels))
}
}
func TestChannelMemberUser(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "", "")
cm, _ := d.AddChannelMember(ctx, "c1", "u1", "+o")
u, err := cm.User(ctx)
if err != nil {
t.Fatalf("ChannelMember.User: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("got %+v", u)
}
}
func TestChannelMemberChannel(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "topic", "+n")
cm, _ := d.AddChannelMember(ctx, "c1", "u1", "")
ch, err := cm.Channel(ctx)
if err != nil {
t.Fatalf("ChannelMember.Channel: %v", err)
}
if ch.ID != "c1" || ch.Topic != "topic" {
t.Errorf("got %+v", ch)
}
}
func TestDMMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
msg, err := d.CreateMessage(
ctx, "m1", "u1", nickAlice, "u2", "message", "hey",
)
if err != nil {
t.Fatalf("CreateMessage DM: %v", err)
}
if msg.Target != "u2" {
t.Errorf("target: got %q, want u2", msg.Target)
} }
} }

View File

@@ -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
} }

View File

@@ -1,6 +1,18 @@
PRAGMA foreign_keys = ON; -- Migration 003: Replace UUID-based tables with simple integer-keyed
-- tables for the HTTP API. Drops the 002 tables and recreates them.
CREATE TABLE IF NOT EXISTS users ( PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS message_queue;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS server_links;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS channel_members;
DROP TABLE IF EXISTS auth_tokens;
DROP TABLE IF EXISTS channels;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
nick TEXT NOT NULL UNIQUE, nick TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE, token TEXT NOT NULL UNIQUE,
@@ -8,7 +20,15 @@ CREATE TABLE IF NOT EXISTS users (
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS channel_members ( CREATE TABLE channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -16,7 +36,7 @@ CREATE TABLE IF NOT EXISTS channel_members (
UNIQUE(channel_id, user_id) UNIQUE(channel_id, user_id)
); );
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -26,6 +46,8 @@ CREATE TABLE IF NOT EXISTS messages (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at); CREATE INDEX idx_messages_dm ON messages(user_id, dm_target_id, created_at);
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); CREATE INDEX idx_users_token ON users(token);
PRAGMA foreign_keys = ON;

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }

View File

@@ -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
} }

View File

@@ -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,nolintlint // returns interface by design
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,nolintlint // returns interface by design
if cl, ok := b.db.(ChannelLookup); ok { if cl, ok := b.db.(ChannelLookup); ok {
return cl return cl
} }

View File

@@ -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
} }

View File

@@ -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)
}