22 Commits

Author SHA1 Message Date
clawbot
e60c83f191 docs: add project tagline, ignore built binaries 2026-02-10 17:55:15 -08:00
clawbot
f4a9ec13bd docs: bold tagline, simplify opening paragraph 2026-02-10 17:55:13 -08:00
clawbot
a2c47f5618 docs: add design philosophy section explaining protocol rationale
Explains why the protocol is IRC over HTTP (not a new protocol borrowing
IRC names), the four specific changes from IRC (HTTP transport, server-held
sessions, structured bodies, message metadata), and why the C2S command
dispatch resembles but is not JSON-RPC.
2026-02-10 17:54:56 -08:00
clawbot
1a6e929f56 docs: update README API spec for unified command endpoint
- Document all C2S commands with required/optional fields table
- Remove separate join/part/nick/topic endpoint docs
- Update /channels/all to /channels
- Update /register to /session
2026-02-10 17:53:48 -08:00
clawbot
af3a26fcdf feat(web): update SPA to use unified command endpoint
- Session creation uses /session instead of /register
- JOIN/PART via POST /messages with command field
- NICK changes via POST /messages with NICK command
- Messages sent as PRIVMSG commands with body array
- DMs use PRIVMSG command format
2026-02-10 17:53:17 -08:00
clawbot
d06bb5334a feat(cli): update client to use unified command endpoint
- JoinChannel/PartChannel now send via POST /messages with command field
- ListChannels uses /channels instead of /channels/all
- gofmt whitespace fixes
2026-02-10 17:53:13 -08:00
clawbot
0ee3fd78d2 refactor: unify all C2S commands through POST /messages
All client-to-server commands now go through POST /api/v1/messages
with a 'command' field. The server dispatches by command type:

- PRIVMSG/NOTICE: send message to channel or user
- JOIN: join channel (creates if needed)
- PART: leave channel
- NICK: change nickname
- TOPIC: set channel topic
- PING: keepalive (returns PONG)

Removed separate routes:
- POST /channels/join
- DELETE /channels/{channel}
- POST /register (renamed to POST /session)
- GET /channels/all (moved to GET /channels)

Added DB methods: ChangeNick, SetTopic
2026-02-10 17:53:08 -08:00
clawbot
f7776f8d3f feat: scaffold IRC-style CLI client (chat-cli)
Adds cmd/chat-cli/ with:
- tview-based irssi-like TUI (message buffer, status bar, input line)
- IRC-style commands: /connect, /nick, /join, /part, /msg, /query,
  /topic, /names, /list, /window, /quit, /help
- Multi-buffer window model with Alt+N switching and unread indicators
- Background long-poll goroutine for message delivery
- Clean API client wrapper in cmd/chat-cli/api/
- Structured types matching the JSON message protocol
2026-02-10 11:51:01 -08:00
clawbot
4b074aafd7 docs: add PUBKEY schema for signing key distribution
Dedicated JSON Schema for the PUBKEY command — announces/relays
user signing public keys. Body is a structured object with alg
and key fields. Already documented in README; this adds the
schema file and index entry.
2026-02-10 10:36:55 -08:00
clawbot
ab70f889a6 refactor: structured body (array|object, never string) for canonicalization
Message bodies are always arrays of strings (text lines) or objects
(structured data like PUBKEY). Never raw strings. This enables:
- Multiline messages without escape sequences
- Deterministic JSON canonicalization (RFC 8785 JCS) for signing
- Structured data where needed

Update all schemas: body fields use array type with string items.
Update message.json envelope: body is oneOf[array, object], id is UUID.
Update README: message envelope table, examples, and canonicalization docs.
Update schema/README.md: field types, examples with array bodies.
2026-02-10 10:36:02 -08:00
clawbot
dfb1636be5 refactor: model message schemas after IRC RFC 1459/2812
Replace c2s/s2c/s2s taxonomy with IRC-native structure:
- schema/commands/ — IRC command schemas (PRIVMSG, NOTICE, JOIN, PART,
  QUIT, NICK, TOPIC, MODE, KICK, PING, PONG)
- schema/numerics/ — IRC numeric reply codes (001-004, 322-323, 332,
  353, 366, 372-376, 401, 403, 433, 442, 482)
- schema/message.json — base envelope mapping IRC wire format to JSON

Messages use 'command' field with IRC command names or 3-digit numeric
codes. 'body' is a string (IRC trailing parameter), not object/array.
'from'/'to' map to IRC prefix and first parameter.

Federation uses the same IRC commands (no custom RELAY/LINK/SYNC).

Update README message format, command tables, and examples to match.
2026-02-10 10:31:26 -08:00
clawbot
c8d88de8c5 chore: remove superseded schema files
Remove schema/commands/ and schema/message.json, replaced by
the new schema/{c2s,s2c,s2s}/*.schema.json structure.
2026-02-10 10:26:44 -08:00
clawbot
02acf1c919 docs: document IRC message protocol, signing, and canonicalization
- Add IRC command/numeric mapping tables (C2S, S2C, S2S)
- Document structured message bodies (array/object, never raw strings)
- Document RFC 8785 JCS canonicalization for deterministic hashing
- Document Ed25519 signing/verification flow with TOFU key distribution
- Document PUBKEY message type for public key announcement
- Update message examples to use IRC command format
- Update curl examples to use command-based messages
- Note web client as convenience UI; primary interface is IRC-style clients
- Add schema/ to project structure
2026-02-10 10:26:32 -08:00
clawbot
909da3cc99 feat: add IRC-style message protocol JSON schemas (draft 2020-12)
Add JSON Schema definitions for all message types:
- Base message envelope (message.schema.json)
- C2S: PRIVMSG, NOTICE, JOIN, PART, QUIT, NICK, MODE, TOPIC, KICK, PING, PUBKEY
- S2C: named commands + numeric reply codes (001, 002, 322, 353, 366, 372, 375, 376, 401, 403, 433)
- S2S: RELAY, LINK, UNLINK, SYNC, PING, PONG
- Schema index (schema/README.md)

All messages use IRC command names and numeric codes from RFC 1459/2812.
Bodies are always objects or arrays (never raw strings) to support
deterministic canonicalization (RFC 8785 JCS) and message signing.
2026-02-10 10:26:32 -08:00
clawbot
4645be5f20 style: fix whitespace alignment in config.go 2026-02-10 10:26:32 -08:00
user
065b243def docs: add JSON Schema definitions for all message types (draft 2020-12)
C2S (7): send, join, part, nick, topic, mode, ping
S2C (12): message, dm, notice, join, part, quit, nick, topic, mode, system, error, pong
S2S (6): relay, link, unlink, sync, ping, pong

Each message type has its own schema file under schema/{c2s,s2c,s2s}/.
schema/README.md provides an index of all types with descriptions.
2026-02-10 10:25:42 -08:00
user
6483670dc7 docs: emphasize API-first design, add curl examples, note web client as reference impl 2026-02-10 10:21:10 -08:00
user
16e08c2839 docs: update README with IRC-inspired unified API design
- Document simplified endpoint structure
- Single message stream (GET /messages) for all message types
- Unified send (POST /messages) with 'to' field
- GET /state replaces separate /me and /channels
- GET /history for scrollback
- Update project status
2026-02-10 10:20:13 -08:00
user
aabf8e902c feat: update web client for unified API endpoints
- Use /state instead of /me for auth check
- Use /messages instead of /poll for message stream
- Use unified POST /messages with 'to' field for all sends
- Update part channel URL to DELETE /channels/{name}
2026-02-10 10:20:09 -08:00
user
74437b8372 refactor: update routes for unified API endpoints
- GET /api/v1/state replaces /me and /channels
- GET/POST /api/v1/messages replaces /poll, /channels/{ch}/messages, /dm/{nick}/messages
- GET /api/v1/history for scrollback
- DELETE /api/v1/channels/{name} replaces /channels/{channel}/part
2026-02-10 10:20:05 -08:00
user
7361e8bd9b refactor: merge /me + /channels into /state, unify message endpoints
- HandleState returns user info (id, nick) + joined channels in one response
- HandleGetMessages now serves unified message stream (was HandlePoll)
- HandleSendMessage accepts 'to' field for routing to #channel or nick
- HandleGetHistory supports scrollback for channels and DMs
- Remove separate HandleMe, HandleListChannels, HandleSendDM, HandleGetDMs, HandlePoll
2026-02-10 10:20:00 -08:00
user
ac933d07d2 Add embedded web chat client with C2S HTTP API
- New DB schema: users, channel_members, messages tables (migration 003)
- Full C2S HTTP API: register, channels, messages, DMs, polling
- Preact SPA embedded via embed.FS, served at GET /
- IRC-style UI: tab bar, channel messages, user list, DM tabs, /commands
- Dark theme, responsive, esbuild-bundled (~19KB)
- Polling-based message delivery (1.5s interval)
- Commands: /join, /part, /msg, /nick
2026-02-10 09:22:22 -08:00
32 changed files with 1618 additions and 7050 deletions

View File

@@ -1,9 +1,8 @@
.git bin/
*.md
!README.md
chatd chatd
chat-cli
data.db data.db
data.db-wal
data.db-shm
.env .env
.git
*.test
*.out
debug.log

View File

@@ -1,12 +0,0 @@
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

@@ -1,9 +0,0 @@
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,28 +1,7 @@
# 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
@@ -30,9 +9,6 @@ node_modules/
*.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

@@ -7,25 +7,15 @@ run:
linters: linters:
default: all default: all
disable: disable:
- exhaustruct # Genuinely incompatible with project patterns
- depguard - exhaustruct # Requires all struct fields
- godot - depguard # Dependency allow/block lists
- wsl - godot # Requires comments to end with periods
- wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck - wrapcheck # Too verbose for internal packages
- varnamelen - varnamelen # Short names like db, id are idiomatic Go
- noinlineerr
- dupl linters-settings:
- paralleltest
- nlreturn
- tagliatelle
- goconst
- funlen
- maintidx
- cyclop
- gocognit
- lll
settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -37,5 +27,6 @@ linters:
threshold: 100 threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

View File

@@ -1,29 +1,22 @@
# Build stage
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make gcc musl-dev
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Run tests ARG VERSION=dev
ENV DBURL="file::memory:?cache=shared" RUN go build -ldflags "-X main.Version=${VERSION}" -o /chatd ./cmd/chatd
RUN go test ./...
# Build binaries
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
# Final stage — server only
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates \
&& addgroup -S chat && adduser -S chat -G chat RUN apk add --no-cache ca-certificates
COPY --from=builder /chatd /usr/local/bin/chatd COPY --from=builder /chatd /usr/local/bin/chatd
USER chat WORKDIR /data
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["chatd"] ENTRYPOINT ["chatd"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
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,20 +1,43 @@
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") .PHONY: all build lint fmt test check clean run debug
LDFLAGS := -ldflags "-X main.Version=$(VERSION)"
.PHONY: build test clean docker lint BINARY := chatd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
build: build:
go build $(LDFLAGS) -o chatd ./cmd/chatd/ go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
go build $(LDFLAGS) -o chat-cli ./cmd/chat-cli/
test:
DBURL="file::memory:?cache=shared" go test ./...
clean:
rm -f chatd chat-cli
lint: lint:
GOFLAGS=-buildvcs=false golangci-lint run ./... golangci-lint run --config .golangci.yml ./...
docker: fmt:
docker build -t chat:$(VERSION) . gofmt -s -w .
goimports -w .
test:
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)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
@echo "==> All checks passed!"
run: build
./bin/$(BINARY)
debug: build
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
clean:
rm -rf bin/ chatd data.db

2290
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,182 +0,0 @@
---
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,27 +1,15 @@
package chatapi package api
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings"
"time" "time"
) )
const (
httpTimeout = 30 * time.Second
pollExtraTime = 5
httpErrThreshold = 400
)
var errHTTP = errors.New("HTTP error")
// 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
@@ -33,263 +21,184 @@ type Client struct {
func NewClient(baseURL string) *Client { func NewClient(baseURL string) *Client {
return &Client{ return &Client{
BaseURL: baseURL, BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: httpTimeout}, HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
} }
} }
// CreateSession creates a new session on the server. func (c *Client) do(method, path string, body interface{}) ([]byte, error) {
func (c *Client) CreateSession(
nick string,
) (*SessionResponse, error) {
data, err := c.do(
http.MethodPost,
"/api/v1/session",
&SessionRequest{Nick: nick},
)
if err != nil {
return nil, err
}
var resp SessionResponse
err = json.Unmarshal(data, &resp)
if err != nil {
return nil, fmt.Errorf("decode session: %w", err)
}
c.Token = resp.Token
return &resp, nil
}
// GetState returns the current user state.
func (c *Client) GetState() (*StateResponse, error) {
data, err := c.do(
http.MethodGet, "/api/v1/state", nil,
)
if err != nil {
return nil, err
}
var resp StateResponse
err = json.Unmarshal(data, &resp)
if err != nil {
return nil, fmt.Errorf("decode state: %w", err)
}
return &resp, nil
}
// SendMessage sends a message (any IRC command).
func (c *Client) SendMessage(msg *Message) error {
_, err := c.do(
http.MethodPost, "/api/v1/messages", msg,
)
return err
}
// PollMessages long-polls for new messages.
func (c *Client) PollMessages(
afterID int64,
timeout int,
) (*PollResult, error) {
client := &http.Client{
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
}
params := url.Values{}
if afterID > 0 {
params.Set(
"after",
strconv.FormatInt(afterID, 10),
)
}
params.Set("timeout", strconv.Itoa(timeout))
path := "/api/v1/messages?" + params.Encode()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
c.BaseURL+path,
nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
resp, err := client.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= httpErrThreshold {
return nil, fmt.Errorf(
"%w %d: %s",
errHTTP, resp.StatusCode, string(data),
)
}
var wrapped MessagesResponse
err = json.Unmarshal(data, &wrapped)
if err != nil {
return nil, fmt.Errorf(
"decode messages: %w", err,
)
}
return &PollResult{
Messages: wrapped.Messages,
LastID: wrapped.LastID,
}, nil
}
// JoinChannel joins a channel.
func (c *Client) JoinChannel(channel string) error {
return c.SendMessage(
&Message{Command: "JOIN", To: channel},
)
}
// PartChannel leaves a channel.
func (c *Client) PartChannel(channel string) error {
return c.SendMessage(
&Message{Command: "PART", To: channel},
)
}
// ListChannels returns all channels on the server.
func (c *Client) ListChannels() ([]Channel, error) {
data, err := c.do(
http.MethodGet, "/api/v1/channels", nil,
)
if err != nil {
return nil, err
}
var channels []Channel
err = json.Unmarshal(data, &channels)
if err != nil {
return nil, err
}
return channels, nil
}
// GetMembers returns members of a channel.
func (c *Client) GetMembers(
channel string,
) ([]string, error) {
name := strings.TrimPrefix(channel, "#")
data, err := c.do(
http.MethodGet,
"/api/v1/channels/"+url.PathEscape(name)+
"/members",
nil,
)
if err != nil {
return nil, err
}
var members []string
err = json.Unmarshal(data, &members)
if err != nil {
return nil, fmt.Errorf(
"unexpected members format: %w", err,
)
}
return members, nil
}
// GetServerInfo returns server info.
func (c *Client) GetServerInfo() (*ServerInfo, error) {
data, err := c.do(
http.MethodGet, "/api/v1/server", nil,
)
if err != nil {
return nil, err
}
var info ServerInfo
err = json.Unmarshal(data, &info)
if err != nil {
return nil, err
}
return &info, nil
}
func (c *Client) do(
method, path string,
body any,
) ([]byte, error) {
var bodyReader io.Reader var bodyReader io.Reader
if body != nil { if body != nil {
data, err := json.Marshal(body) data, err := json.Marshal(body)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal: %w", err) return nil, fmt.Errorf("marshal: %w", err)
} }
bodyReader = bytes.NewReader(data) bodyReader = bytes.NewReader(data)
} }
req, err := http.NewRequestWithContext( req, err := http.NewRequest(method, c.BaseURL+path, bodyReader)
context.Background(),
method,
c.BaseURL+path,
bodyReader,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("request: %w", err) return nil, fmt.Errorf("request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if c.Token != "" { if c.Token != "" {
req.Header.Set( req.Header.Set("Authorization", "Bearer "+c.Token)
"Authorization", "Bearer "+c.Token,
)
} }
resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path resp, err := c.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("http: %w", err) return nil, fmt.Errorf("http: %w", 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, fmt.Errorf("read body: %w", err) return nil, fmt.Errorf("read body: %w", err)
} }
if resp.StatusCode >= httpErrThreshold { if resp.StatusCode >= 400 {
return data, fmt.Errorf( return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
"%w %d: %s",
errHTTP, resp.StatusCode, string(data),
)
} }
return data, nil return data, nil
} }
// CreateSession creates a new session on the server.
func (c *Client) CreateSession(nick string) (*SessionResponse, error) {
data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick})
if err != nil {
return nil, err
}
var resp SessionResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("decode session: %w", err)
}
c.Token = resp.Token
return &resp, nil
}
// GetState returns the current user state.
func (c *Client) GetState() (*StateResponse, error) {
data, err := c.do("GET", "/api/v1/state", nil)
if err != nil {
return nil, err
}
var resp StateResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("decode state: %w", err)
}
return &resp, nil
}
// SendMessage sends a message (any IRC command).
func (c *Client) SendMessage(msg *Message) error {
_, err := c.do("POST", "/api/v1/messages", msg)
return err
}
// PollMessages long-polls for new messages.
func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) {
// Use a longer HTTP timeout than the server long-poll timeout.
client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second}
params := url.Values{}
if afterID != "" {
params.Set("after", afterID)
}
params.Set("timeout", fmt.Sprintf("%d", timeout))
path := "/api/v1/messages"
if len(params) > 0 {
path += "?" + params.Encode()
}
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
// The server may return an array directly or wrapped.
var msgs []Message
if err := json.Unmarshal(data, &msgs); err != nil {
// Try wrapped format.
var wrapped MessagesResponse
if err2 := json.Unmarshal(data, &wrapped); err2 != nil {
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
}
msgs = wrapped.Messages
}
return msgs, nil
}
// JoinChannel joins a channel via the unified command endpoint.
func (c *Client) JoinChannel(channel string) error {
return c.SendMessage(&Message{Command: "JOIN", To: channel})
}
// PartChannel leaves a channel via the unified command endpoint.
func (c *Client) PartChannel(channel string) error {
return c.SendMessage(&Message{Command: "PART", To: channel})
}
// ListChannels returns all channels on the server.
func (c *Client) ListChannels() ([]Channel, error) {
data, err := c.do("GET", "/api/v1/channels", nil)
if err != nil {
return nil, err
}
var channels []Channel
if err := json.Unmarshal(data, &channels); err != nil {
return nil, err
}
return channels, nil
}
// GetMembers returns members of a channel.
func (c *Client) GetMembers(channel string) ([]string, error) {
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil)
if err != nil {
return nil, err
}
var members []string
if err := json.Unmarshal(data, &members); err != nil {
// Try object format.
var obj map[string]interface{}
if err2 := json.Unmarshal(data, &obj); err2 != nil {
return nil, err
}
// Extract member names from whatever format.
return nil, fmt.Errorf("unexpected members format: %s", string(data))
}
return members, nil
}
// GetServerInfo returns server info.
func (c *Client) GetServerInfo() (*ServerInfo, error) {
data, err := c.do("GET", "/api/v1/server", nil)
if err != nil {
return nil, err
}
var info ServerInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}

View File

@@ -1,5 +1,4 @@
// Package chatapi provides API types and client for chat-cli. package api
package chatapi
import "time" import "time"
@@ -8,16 +7,18 @@ type SessionRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
} }
// SessionResponse is the response from session creation. // SessionResponse is the response from POST /api/v1/session.
type SessionResponse struct { type SessionResponse struct {
ID int64 `json:"id"` SessionID string `json:"session_id"`
ClientID string `json:"client_id"`
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 {
ID int64 `json:"id"` SessionID string `json:"session_id"`
ClientID string `json:"client_id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Channels []string `json:"channels"` Channels []string `json:"channels"`
} }
@@ -28,24 +29,22 @@ type Message struct {
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 any `json:"body,omitempty"` Body interface{} `json:"body,omitempty"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
TS string `json:"ts,omitempty"` TS string `json:"ts,omitempty"`
Meta any `json:"meta,omitempty"` Meta interface{} `json:"meta,omitempty"`
} }
// BodyLines returns the body as a string slice. // 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 []any: case []interface{}:
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
@@ -59,7 +58,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:"createdAt"` CreatedAt string `json:"created_at"`
} }
// ServerInfo is the response from GET /api/v1/server. // ServerInfo is the response from GET /api/v1/server.
@@ -72,13 +71,6 @@ type ServerInfo struct {
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.
type MessagesResponse struct { type MessagesResponse struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
LastID int64 `json:"lastId"`
}
// PollResult wraps the poll response including the cursor.
type PollResult struct {
Messages []Message
LastID int64
} }
// ParseTS parses the message timestamp. // ParseTS parses the message timestamp.
@@ -87,6 +79,5 @@ 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,4 +1,3 @@
// Package main is the entry point for the chat-cli client.
package main package main
import ( import (
@@ -8,14 +7,7 @@ import (
"sync" "sync"
"time" "time"
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api" "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
)
const (
splitParts = 2
pollTimeout = 15
pollRetry = 2 * time.Second
timeFormat = "15:04"
) )
// App holds the application state. // App holds the application state.
@@ -25,9 +17,9 @@ type App struct {
mu sync.Mutex mu sync.Mutex
nick string nick string
target string target string // current target (#channel or nick for DM)
connected bool connected bool
lastQID int64 lastMsgID string
stopPoll chan struct{} stopPoll chan struct{}
} }
@@ -40,17 +32,10 @@ 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( app.ui.AddStatus("Welcome to chat-cli — an IRC-style client")
"Welcome to chat-cli — an IRC-style client", app.ui.AddStatus("Type [yellow]/connect <server-url>[white] to begin, or [yellow]/help[white] for commands")
)
app.ui.AddStatus(
"Type [yellow]/connect <server-url>" +
"[white] to begin, " +
"or [yellow]/help[white] for commands",
)
err := app.ui.Run() if err := app.ui.Run(); err != nil {
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -59,29 +44,21 @@ 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.mu.Lock() a.mu.Lock()
target := a.target target := a.target
connected := a.connected connected := a.connected
a.mu.Unlock() a.mu.Unlock()
if !connected { if !connected {
a.ui.AddStatus( a.ui.AddStatus("[red]Not connected. Use /connect <url>")
"[red]Not connected. Use /connect <url>",
)
return return
} }
if target == "" { if target == "" {
a.ui.AddStatus( a.ui.AddStatus("[red]No target. Use /join #channel or /query nick")
"[red]No target. " +
"Use /join #channel or /query nick",
)
return return
} }
@@ -91,38 +68,26 @@ func (a *App) handleInput(text string) {
Body: []string{text}, Body: []string{text},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err))
"[red]Send error: " + err.Error(),
)
return return
} }
ts := time.Now().Format(timeFormat) // Echo locally.
ts := time.Now().Format("15:04")
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
a.mu.Unlock() a.mu.Unlock()
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) handleCommand(text string) { func (a *App) handleCommand(text string) {
parts := strings.SplitN(text, " ", splitParts) parts := strings.SplitN(text, " ", 2)
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]
} }
a.dispatchCommand(cmd, args)
}
func (a *App) dispatchCommand(cmd, args string) {
switch cmd { switch cmd {
case "/connect": case "/connect":
a.cmdConnect(args) a.cmdConnect(args)
@@ -149,37 +114,27 @@ func (a *App) dispatchCommand(cmd, args string) {
case "/help": case "/help":
a.cmdHelp() a.cmdHelp()
default: default:
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd))
"[red]Unknown command: " + cmd,
)
} }
} }
func (a *App) cmdConnect(serverURL string) { func (a *App) cmdConnect(serverURL string) {
if serverURL == "" { if serverURL == "" {
a.ui.AddStatus( a.ui.AddStatus("[red]Usage: /connect <server-url>")
"[red]Usage: /connect <server-url>",
)
return return
} }
serverURL = strings.TrimRight(serverURL, "/") serverURL = strings.TrimRight(serverURL, "/")
a.ui.AddStatus("Connecting to " + 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( a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err))
"[red]Connection failed: %v", err,
))
return return
} }
@@ -187,29 +142,22 @@ func (a *App) cmdConnect(serverURL string) {
a.client = client a.client = client
a.nick = resp.Nick a.nick = resp.Nick
a.connected = true a.connected = true
a.lastQID = 0 a.lastMsgID = ""
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID))
"[green]Connected! Nick: %s, Session: %d",
resp.Nick, resp.ID,
))
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( a.ui.AddStatus("[red]Usage: /nick <name>")
"[red]Usage: /nick <name>",
)
return return
} }
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
a.mu.Unlock() a.mu.Unlock()
@@ -218,12 +166,7 @@ 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(
"Nick set to " + nick +
" (will be used on connect)",
)
return return
} }
@@ -232,10 +175,7 @@ func (a *App) cmdNick(nick string) {
Body: []string{nick}, Body: []string{nick},
}) })
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err))
"[red]Nick change failed: %v", err,
))
return return
} }
@@ -243,20 +183,15 @@ 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("Nick changed to " + nick) a.ui.AddStatus(fmt.Sprintf("Nick changed to %s", nick))
} }
func (a *App) cmdJoin(channel string) { func (a *App) cmdJoin(channel string) {
if channel == "" { if channel == "" {
a.ui.AddStatus( a.ui.AddStatus("[red]Usage: /join #channel")
"[red]Usage: /join #channel",
)
return return
} }
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
channel = "#" + channel channel = "#" + channel
} }
@@ -264,19 +199,14 @@ 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( a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err))
"[red]Join failed: %v", err,
))
return return
} }
@@ -286,9 +216,7 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.SwitchToBuffer(channel) a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel, a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Joined %s", channel))
"[yellow]*** Joined "+channel,
)
a.ui.SetStatus(nick, channel, "connected") a.ui.SetStatus(nick, channel, "connected")
} }
@@ -297,41 +225,30 @@ func (a *App) cmdPart(channel string) {
if channel == "" { if channel == "" {
channel = a.target channel = a.target
} }
connected := a.connected connected := a.connected
a.mu.Unlock() a.mu.Unlock()
if channel == "" || if channel == "" || !strings.HasPrefix(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( a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err))
"[red]Part failed: %v", err,
))
return return
} }
a.ui.AddLine(channel, a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", 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()
@@ -340,25 +257,19 @@ func (a *App) cmdPart(channel string) {
} }
func (a *App) cmdMsg(args string) { func (a *App) cmdMsg(args string) {
parts := strings.SplitN(args, " ", splitParts) parts := strings.SplitN(args, " ", 2)
if len(parts) < splitParts { if len(parts) < 2 {
a.ui.AddStatus( a.ui.AddStatus("[red]Usage: /msg <nick> <text>")
"[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
} }
@@ -368,27 +279,17 @@ func (a *App) cmdMsg(args string) {
Body: []string{text}, Body: []string{text},
}) })
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err))
"[red]Send failed: %v", err,
))
return return
} }
ts := time.Now().Format(timeFormat) 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( a.ui.AddStatus("[red]Usage: /query <nick>")
"[red]Usage: /query <nick>",
)
return return
} }
@@ -409,27 +310,22 @@ 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( a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err))
"[red]Topic query failed: %v", err,
))
} }
return return
} }
@@ -439,9 +335,7 @@ func (a *App) cmdTopic(args string) {
Body: []string{args}, Body: []string{args},
}) })
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err))
"[red]Topic set failed: %v", err,
))
} }
} }
@@ -453,29 +347,20 @@ 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( a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err))
"[red]Names failed: %v", err,
))
return return
} }
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " ")))
"[cyan]*** Members of %s: %s",
target, strings.Join(members, " "),
))
} }
func (a *App) cmdList() { func (a *App) cmdList() {
@@ -485,60 +370,47 @@ 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( a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err))
"[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( a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic))
" %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( a.ui.AddStatus("[red]Usage: /window <number>")
"[red]Usage: /window <number>",
)
return return
} }
n := 0
var n int fmt.Sscanf(args, "%d", &n)
_, _ = fmt.Sscanf(args, "%d", &n)
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()
if n >= 0 && n < a.ui.BufferCount() { // Update target based on buffer.
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")
} }
@@ -547,17 +419,12 @@ 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( _ = a.client.SendMessage(&api.Message{Command: "QUIT"})
&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()
} }
@@ -574,12 +441,11 @@ func (a *App) cmdHelp() {
" /topic [text] — View/set topic", " /topic [text] — View/set topic",
" /names — List channel members", " /names — List channel members",
" /list — List channels", " /list — List channels",
" /window <n> — Switch buffer", " /window <n> — Switch buffer (Alt+0-9)",
" /quit — Disconnect and exit", " /quit — Disconnect and exit",
" /help — This help", " /help — 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)
} }
@@ -596,36 +462,39 @@ func (a *App) pollLoop() {
a.mu.Lock() a.mu.Lock()
client := a.client client := a.client
lastQID := a.lastQID lastID := a.lastMsgID
a.mu.Unlock() a.mu.Unlock()
if client == nil { if client == nil {
return return
} }
result, err := client.PollMessages( msgs, err := client.PollMessages(lastID, 15)
lastQID, pollTimeout,
)
if err != nil { if err != nil {
time.Sleep(pollRetry) // Transient error — retry after delay.
time.Sleep(2 * time.Second)
continue continue
} }
if result.LastID > 0 { for _, msg := range msgs {
a.handleServerMessage(&msg)
if msg.ID != "" {
a.mu.Lock() a.mu.Lock()
a.lastQID = result.LastID a.lastMsgID = msg.ID
a.mu.Unlock() a.mu.Unlock()
} }
for i := range result.Messages {
a.handleServerMessage(&result.Messages[i])
} }
} }
} }
func (a *App) handleServerMessage(msg *api.Message) { func (a *App) handleServerMessage(msg *api.Message) {
ts := a.formatTS(msg) ts := ""
if msg.TS != "" {
t := msg.ParseTS()
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
@@ -633,172 +502,79 @@ func (a *App) handleServerMessage(msg *api.Message) {
switch msg.Command { switch msg.Command {
case "PRIVMSG": case "PRIVMSG":
a.handlePrivmsgEvent(msg, ts, myNick)
case "JOIN":
a.handleJoinEvent(msg, ts)
case "PART":
a.handlePartEvent(msg, ts)
case "QUIT":
a.handleQuitEvent(msg, ts)
case "NICK":
a.handleNickEvent(msg, ts, myNick)
case "NOTICE":
a.handleNoticeEvent(msg, ts)
case "TOPIC":
a.handleTopicEvent(msg, ts)
default:
a.handleDefaultEvent(msg, ts)
}
}
func (a *App) formatTS(msg *api.Message) string {
if msg.TS != "" {
return msg.ParseTS().UTC().Format(timeFormat)
}
return time.Now().Format(timeFormat)
}
func (a *App) handlePrivmsgEvent(
msg *api.Message, ts, myNick string,
) {
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
if msg.From == myNick { if msg.From == myNick {
// Skip our own echoed messages (already displayed locally).
return return
} }
target := msg.To target := msg.To
if !strings.HasPrefix(target, "#") { if !strings.HasPrefix(target, "#") {
// DM — use sender's nick as buffer name.
target = msg.From target = msg.From
} }
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text))
a.ui.AddLine(target, fmt.Sprintf( case "JOIN":
"[gray]%s [green]<%s>[white] %s", target := msg.To
ts, msg.From, text, if target != "" {
)) a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target))
}
func (a *App) handleJoinEvent(
msg *api.Message, ts string,
) {
if msg.To == "" {
return
}
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s",
ts, msg.From, msg.To,
))
}
func (a *App) handlePartEvent(
msg *api.Message, ts string,
) {
if msg.To == "" {
return
} }
case "PART":
target := msg.To
lines := msg.BodyLines() lines := msg.BodyLines()
reason := strings.Join(lines, " ") reason := strings.Join(lines, " ")
if target != "" {
if reason != "" { if reason != "" {
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason))
"[gray]%s [yellow]*** %s has left %s (%s)",
ts, msg.From, msg.To, reason,
))
} else { } else {
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target))
"[gray]%s [yellow]*** %s has left %s", }
ts, msg.From, msg.To,
))
} }
}
func (a *App) handleQuitEvent( case "QUIT":
msg *api.Message, ts string,
) {
lines := msg.BodyLines() lines := msg.BodyLines()
reason := strings.Join(lines, " ") reason := strings.Join(lines, " ")
if reason != "" { if reason != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason))
"[gray]%s [yellow]*** %s has quit (%s)",
ts, msg.From, reason,
))
} else { } else {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From))
"[gray]%s [yellow]*** %s has quit",
ts, msg.From,
))
} }
}
func (a *App) handleNickEvent( case "NICK":
msg *api.Message, ts, myNick string,
) {
lines := msg.BodyLines() lines := msg.BodyLines()
newNick := "" newNick := ""
if len(lines) > 0 { if len(lines) > 0 {
newNick = lines[0] newNick = lines[0]
} }
if msg.From == myNick && newNick != "" { if msg.From == myNick && newNick != "" {
a.mu.Lock() a.mu.Lock()
a.nick = newNick a.nick = newNick
target := a.target target := a.target
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus(newNick, target, "connected") a.ui.SetStatus(newNick, target, "connected")
} }
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick))
a.ui.AddStatus(fmt.Sprintf( case "NOTICE":
"[gray]%s [yellow]*** %s is now known as %s",
ts, msg.From, newNick,
))
}
func (a *App) handleNoticeEvent(
msg *api.Message, ts string,
) {
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text))
a.ui.AddStatus(fmt.Sprintf( case "TOPIC":
"[gray]%s [magenta]--%s-- %s", lines := msg.BodyLines()
ts, msg.From, text, 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))
func (a *App) handleTopicEvent(
msg *api.Message, ts string,
) {
if msg.To == "" {
return
} }
default:
// Numeric replies and other messages → status window.
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") 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) handleDefaultEvent(
msg *api.Message, ts string,
) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
if text != "" { if text != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text))
"[gray]%s [white][%s] %s", }
ts, msg.Command, text,
))
} }
} }

View File

@@ -39,13 +39,54 @@ func NewUI() *UI {
}, },
} }
ui.initMessages() // Message area.
ui.initStatusBar() ui.messages = tview.NewTextView().
ui.initInput() SetDynamicColors(true).
ui.initKeyCapture() SetScrollable(true).
SetWordWrap(true).
SetChangedFunc(func() {
ui.app.Draw()
})
ui.messages.SetBorder(false)
ui.layout = tview.NewFlex(). // Status bar.
SetDirection(tview.FlexRow). 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.messages, 0, 1, false).
AddItem(ui.statusBar, 1, 0, false). AddItem(ui.statusBar, 1, 0, false).
AddItem(ui.input, 1, 0, true) AddItem(ui.input, 1, 0, true)
@@ -72,30 +113,28 @@ func (ui *UI) OnInput(fn func(string)) {
} }
// AddLine adds a line to the specified buffer. // AddLine adds a line to the specified buffer.
func (ui *UI) AddLine(bufferName, line string) { func (ui *UI) AddLine(bufferName string, line string) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName) buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line) buf.Lines = append(buf.Lines, line)
cur := ui.buffers[ui.currentBuffer] // Mark unread if not currently viewing this buffer.
if cur != buf { if ui.buffers[ui.currentBuffer] != buf {
buf.Unread++ buf.Unread++
ui.refreshStatusBar() ui.refreshStatus()
} }
if cur == buf { // If viewing this buffer, append to display.
_, _ = fmt.Fprintln(ui.messages, line) if ui.buffers[ui.currentBuffer] == buf {
fmt.Fprintln(ui.messages, line)
} }
}) })
} }
// AddStatus adds a line to the status buffer. // 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( ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line))
"(status)",
"[gray]"+ts+"[white] "+line,
)
} }
// SwitchBuffer switches to the buffer at index n. // SwitchBuffer switches to the buffer at index n.
@@ -104,174 +143,67 @@ 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.refreshStatusBar() ui.refreshStatus()
}) })
} }
// SwitchToBuffer switches to named buffer, creating if // SwitchToBuffer switches to the named buffer, creating it if needed.
// needed.
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.refreshStatusBar() ui.refreshStatus()
}) })
} }
// SetStatus updates the status bar text. // SetStatus updates the status bar text.
func (ui *UI) SetStatus( func (ui *UI) SetStatus(nick, target, connStatus string) {
nick, target, connStatus string,
) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.renderStatusBar(nick, target, connStatus) ui.refreshStatusWith(nick, target, connStatus)
}) })
} }
// BufferCount returns the number of buffers. func (ui *UI) refreshStatus() {
func (ui *UI) BufferCount() int { // Will be called from the main goroutine via QueueUpdateDraw parent.
return len(ui.buffers) // Rebuild status from app state — caller must provide context.
} }
// BufferIndex returns the index of a named buffer. func (ui *UI) refreshStatusWith(nick, target, connStatus string) {
func (ui *UI) BufferIndex(name string) int {
for i, buf := range ui.buffers {
if buf.Name == name {
return i
}
}
return -1
}
func (ui *UI) initMessages() {
ui.messages = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetWordWrap(true).
SetChangedFunc(func() {
ui.app.Draw()
})
ui.messages.SetBorder(false)
}
func (ui *UI) initStatusBar() {
ui.statusBar = tview.NewTextView().
SetDynamicColors(true)
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite)
}
func (ui *UI) initInput() {
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) initKeyCapture() {
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) refreshStatusBar() {
// Placeholder; full refresh needs nick/target context.
}
func (ui *UI) renderStatusBar(
nick, target, connStatus string,
) {
var unreadParts []string var unreadParts []string
for i, buf := range ui.buffers { for i, buf := range ui.buffers {
if buf.Unread > 0 { if buf.Unread > 0 {
unreadParts = append(unreadParts, unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread))
fmt.Sprintf(
"%d:%s(%d)",
i, buf.Name, buf.Unread,
),
)
} }
} }
unread := "" unread := ""
if len(unreadParts) > 0 { if len(unreadParts) > 0 {
unread = " [Act: " + unread = " [Act: " + strings.Join(unreadParts, ",") + "]"
strings.Join(unreadParts, ",") + "]"
} }
bufInfo := fmt.Sprintf( bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name)
"[%d:%s]",
ui.currentBuffer,
ui.buffers[ui.currentBuffer].Name,
)
ui.statusBar.Clear() ui.statusBar.Clear()
fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s",
_, _ = fmt.Fprintf(ui.statusBar, connStatus, nick, bufInfo, target, unread)
" [%s] %s %s %s%s",
connStatus, nick, bufInfo, target, unread,
)
} }
func (ui *UI) getOrCreateBuffer(name string) *Buffer { func (ui *UI) getOrCreateBuffer(name string) *Buffer {
@@ -280,9 +212,22 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer {
return buf return buf
} }
} }
buf := &Buffer{Name: name} buf := &Buffer{Name: name}
ui.buffers = append(ui.buffers, buf) ui.buffers = append(ui.buffers, buf)
return buf return buf
} }
// BufferCount returns the number of buffers.
func (ui *UI) BufferCount() int {
return len(ui.buffers)
}
// BufferIndex returns the index of a named buffer, or -1.
func (ui *UI) BufferIndex(name string) int {
for i, buf := range ui.buffers {
if buf.Name == name {
return i
}
}
return -1
}

View File

@@ -1,65 +0,0 @@
// Package broker provides an in-memory pub/sub for long-poll notifications.
package broker
import (
"sync"
)
// Broker notifies waiting clients when new messages are available.
type Broker struct {
mu sync.Mutex
listeners map[int64][]chan struct{} // userID -> list of waiting channels
}
// New creates a new Broker.
func New() *Broker {
return &Broker{
listeners: make(map[int64][]chan struct{}),
}
}
// Wait returns a channel that will be closed when a message is available for the user.
func (b *Broker) Wait(userID int64) chan struct{} {
ch := make(chan struct{}, 1)
b.mu.Lock()
b.listeners[userID] = append(b.listeners[userID], ch)
b.mu.Unlock()
return ch
}
// Notify wakes up all waiting clients for a user.
func (b *Broker) Notify(userID int64) {
b.mu.Lock()
waiters := b.listeners[userID]
delete(b.listeners, userID)
b.mu.Unlock()
for _, ch := range waiters {
select {
case ch <- struct{}{}:
default:
}
}
}
// Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove(userID int64, ch chan struct{}) {
b.mu.Lock()
defer b.mu.Unlock()
waiters := b.listeners[userID]
for i, w := range waiters {
if w == ch {
b.listeners[userID] = append(waiters[:i], waiters[i+1:]...)
break
}
}
if len(b.listeners[userID]) == 0 {
delete(b.listeners, userID)
}
}

View File

@@ -1,118 +0,0 @@
package broker_test
import (
"sync"
"testing"
"time"
"git.eeqj.de/sneak/chat/internal/broker"
)
func TestNewBroker(t *testing.T) {
t.Parallel()
b := broker.New()
if b == nil {
t.Fatal("expected non-nil broker")
}
}
func TestWaitAndNotify(t *testing.T) {
t.Parallel()
b := broker.New()
ch := b.Wait(1)
go func() {
time.Sleep(10 * time.Millisecond)
b.Notify(1)
}()
select {
case <-ch:
case <-time.After(2 * time.Second):
t.Fatal("timeout")
}
}
func TestNotifyWithoutWaiters(t *testing.T) {
t.Parallel()
b := broker.New()
b.Notify(42) // should not panic
}
func TestRemove(t *testing.T) {
t.Parallel()
b := broker.New()
ch := b.Wait(1)
b.Remove(1, ch)
b.Notify(1)
select {
case <-ch:
t.Fatal("should not receive after remove")
case <-time.After(50 * time.Millisecond):
}
}
func TestMultipleWaiters(t *testing.T) {
t.Parallel()
b := broker.New()
ch1 := b.Wait(1)
ch2 := b.Wait(1)
b.Notify(1)
select {
case <-ch1:
case <-time.After(time.Second):
t.Fatal("ch1 timeout")
}
select {
case <-ch2:
case <-time.After(time.Second):
t.Fatal("ch2 timeout")
}
}
func TestConcurrentWaitNotify(t *testing.T) {
t.Parallel()
b := broker.New()
var wg sync.WaitGroup
const concurrency = 100
for i := range concurrency {
wg.Add(1)
go func(uid int64) {
defer wg.Done()
ch := b.Wait(uid)
b.Notify(uid)
select {
case <-ch:
case <-time.After(time.Second):
t.Error("timeout")
}
}(int64(i % 10))
}
wg.Wait()
}
func TestRemoveNonexistent(t *testing.T) {
t.Parallel()
b := broker.New()
ch := make(chan struct{}, 1)
b.Remove(999, ch) // should not panic
}

View File

@@ -11,16 +11,20 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/models"
"go.uber.org/fx" "go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // .env _ "github.com/joho/godotenv/autoload" // loads .env file
_ "modernc.org/sqlite" // driver _ "modernc.org/sqlite" // SQLite driver
) )
const minMigrationParts = 2 const (
minMigrationParts = 2
)
// SchemaFiles contains embedded SQL migration files. // SchemaFiles contains embedded SQL migration files.
// //
@@ -35,18 +39,35 @@ type Params struct {
Config *config.Config Config *config.Config
} }
// Database manages the SQLite connection and migrations. // Database manages the SQLite database connection and migrations.
type Database struct { type Database struct {
db *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
params *Params params *Params
} }
// New creates a new Database and registers lifecycle hooks. // GetDB returns the underlying sql.DB connection.
func New( func (s *Database) GetDB() *sql.DB {
lc fx.Lifecycle, return s.db
params Params, }
) (*Database, error) {
// NewChannel creates a Channel model instance with the db reference injected.
func (s *Database) NewChannel(id int64, name, topic, modes string, createdAt, updatedAt time.Time) *models.Channel {
c := &models.Channel{
ID: id,
Name: name,
Topic: topic,
Modes: modes,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
c.SetDB(s)
return c
}
// New creates a new Database instance and registers lifecycle hooks.
func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := new(Database) s := new(Database)
s.params = &params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
@@ -73,11 +94,6 @@ func New(
return s, nil return s, nil
} }
// GetDB returns the underlying sql.DB connection.
func (s *Database) GetDB() *sql.DB {
return s.db
}
func (s *Database) connect(ctx context.Context) error { func (s *Database) connect(ctx context.Context) error {
dbURL := s.params.Config.DBURL dbURL := s.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
@@ -88,18 +104,14 @@ func (s *Database) connect(ctx context.Context) error {
d, err := sql.Open("sqlite", dbURL) d, err := sql.Open("sqlite", dbURL)
if err != nil { if err != nil {
s.log.Error( s.log.Error("failed to open database", "error", err)
"failed to open database", "error", err,
)
return err return err
} }
err = d.PingContext(ctx) err = d.PingContext(ctx)
if err != nil { if err != nil {
s.log.Error( s.log.Error("failed to ping database", "error", err)
"failed to ping database", "error", err,
)
return err return err
} }
@@ -107,13 +119,6 @@ func (s *Database) connect(ctx context.Context) error {
s.db = d s.db = d
s.log.Info("database connected") s.log.Info("database connected")
_, err = s.db.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
return s.runMigrations(ctx) return s.runMigrations(ctx)
} }
@@ -123,17 +128,10 @@ type migration struct {
sql string sql string
} }
func (s *Database) runMigrations( func (s *Database) runMigrations(ctx context.Context) error {
ctx context.Context, err := s.bootstrapMigrationsTable(ctx)
) error {
_, err := s.db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
if err != nil { if err != nil {
return fmt.Errorf( return err
"create schema_migrations: %w", err,
)
} }
migrations, err := s.loadMigrations() migrations, err := s.loadMigrations()
@@ -141,126 +139,54 @@ func (s *Database) runMigrations(
return err return err
} }
for _, m := range migrations { err = s.applyMigrations(ctx, migrations)
err = s.applyMigration(ctx, m)
if err != nil { if err != nil {
return err return err
} }
}
s.log.Info("database migrations complete") s.log.Info("database migrations complete")
return nil return nil
} }
func (s *Database) applyMigration( func (s *Database) bootstrapMigrationsTable(ctx context.Context) error {
ctx context.Context, _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
m migration, version INTEGER PRIMARY KEY,
) error { applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
var exists int )`)
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations
WHERE version = ?`,
m.version,
).Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf("failed to create schema_migrations table: %w", err)
"check migration %d: %w", m.version, err,
)
} }
if exists > 0 {
return nil return nil
}
s.log.Info(
"applying migration",
"version", m.version,
"name", m.name,
)
return s.execMigration(ctx, m)
} }
func (s *Database) execMigration( func (s *Database) loadMigrations() ([]migration, error) {
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,
)
}
return tx.Commit()
}
func (s *Database) loadMigrations() (
[]migration,
error,
) {
entries, err := fs.ReadDir(SchemaFiles, "schema") entries, err := fs.ReadDir(SchemaFiles, "schema")
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to read schema dir: %w", err)
"read schema dir: %w", err,
)
} }
var migrations []migration var migrations []migration
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
!strings.HasSuffix(entry.Name(), ".sql") {
continue continue
} }
parts := strings.SplitN( parts := strings.SplitN(entry.Name(), "_", minMigrationParts)
entry.Name(), "_", minMigrationParts,
)
if len(parts) < minMigrationParts { if len(parts) < minMigrationParts {
continue continue
} }
version, parseErr := strconv.Atoi(parts[0]) version, err := strconv.Atoi(parts[0])
if parseErr != nil { if err != nil {
continue continue
} }
content, readErr := SchemaFiles.ReadFile( content, err := SchemaFiles.ReadFile("schema/" + entry.Name())
"schema/" + entry.Name(), if err != nil {
) return nil, fmt.Errorf("failed to read migration %s: %w", entry.Name(), err)
if readErr != nil {
return nil, fmt.Errorf(
"read migration %s: %w",
entry.Name(), readErr,
)
} }
migrations = append(migrations, migration{ migrations = append(migrations, migration{
@@ -276,3 +202,32 @@ func (s *Database) loadMigrations() (
return migrations, nil return migrations, nil
} }
func (s *Database) applyMigrations(ctx context.Context, migrations []migration) error {
for _, m := range migrations {
var exists int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check migration %d: %w", m.version, err)
}
if exists > 0 {
continue
}
s.log.Info("applying migration", "version", m.version, "name", m.name)
_, err = s.db.ExecContext(ctx, m.sql)
if err != nil {
return fmt.Errorf("failed to apply migration %d (%s): %w", m.version, m.name, err)
}
_, err = s.db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", m.version)
if err != nil {
return fmt.Errorf("failed to record migration %d: %w", m.version, err)
}
}
return nil
}

View File

@@ -1,47 +0,0 @@
package db
import (
"context"
"database/sql"
"fmt"
"log/slog"
"sync/atomic"
)
//nolint:gochecknoglobals // test counter
var testDBCounter atomic.Int64
// NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
n := testDBCounter.Add(1)
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
n,
)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
database := &Database{db: d, log: slog.Default()}
err = database.runMigrations(context.Background())
if err != nil {
closeErr := d.Close()
if closeErr != nil {
return nil, closeErr
}
return nil, err
}
return database, nil
}
// Close closes the underlying database connection.
func (s *Database) Close() error {
return s.db.Close()
}

View File

@@ -3,38 +3,107 @@ package db
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
)
const (
tokenBytes = 32
defaultPollLimit = 100
defaultHistLimit = 50
) )
func generateToken() string { func generateToken() string {
b := make([]byte, tokenBytes) b := make([]byte, 32)
_, _ = rand.Read(b) _, _ = rand.Read(b)
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// IRCMessage is the IRC envelope for all messages. // CreateUser registers a new user with the given nick and returns the user with token.
type IRCMessage struct { func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) {
ID string `json:"id"` token := generateToken()
Command string `json:"command"` now := time.Now()
From string `json:"from,omitempty"` res, err := s.db.ExecContext(ctx,
To string `json:"to,omitempty"` "INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)",
Body json.RawMessage `json:"body,omitempty"` nick, token, now, now)
TS string `json:"ts"` if err != nil {
Meta json.RawMessage `json:"meta,omitempty"` return 0, "", fmt.Errorf("create user: %w", err)
DBID int64 `json:"-"` }
id, _ := res.LastInsertId()
return id, token, nil
}
// GetUserByToken returns user id and nick for a given auth token.
func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) {
var id int64
var nick string
err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick)
if err != nil {
return 0, "", err
}
// Update last_seen
_, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id)
return id, nick, nil
}
// GetUserByNick returns user id for a given nick.
func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id)
return id, err
}
// GetOrCreateChannel returns the channel id, creating it if needed.
func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id)
if err == nil {
return id, nil
}
now := time.Now()
res, err := s.db.ExecContext(ctx,
"INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)",
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
id, _ = res.LastInsertId()
return id, nil
}
// JoinChannel adds a user to a channel.
func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error {
_, err := s.db.ExecContext(ctx,
"INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)",
channelID, userID, time.Now())
return err
}
// PartChannel removes a user from a channel.
func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?",
channelID, userID)
return err
}
// ListChannels returns all channels the user has joined.
func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) {
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.
@@ -44,6 +113,30 @@ type ChannelInfo struct {
Topic string `json:"topic"` Topic string `json:"topic"`
} }
// 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 rows.Close()
var members []MemberInfo
for rows.Next() {
var m MemberInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil {
return nil, err
}
members = append(members, m)
}
if members == nil {
members = []MemberInfo{}
}
return members, nil
}
// MemberInfo represents a channel member. // MemberInfo represents a channel member.
type MemberInfo struct { type MemberInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -51,573 +144,271 @@ type MemberInfo struct {
LastSeen time.Time `json:"lastSeen"` LastSeen time.Time `json:"lastSeen"`
} }
// CreateUser registers a new user with the given nick. // MessageInfo represents a chat message.
func (s *Database) CreateUser( type MessageInfo struct {
ctx context.Context, ID int64 `json:"id"`
nick string, Channel string `json:"channel,omitempty"`
) (int64, string, error) { Nick string `json:"nick"`
token := generateToken() Content string `json:"content"`
now := time.Now() IsDM bool `json:"isDm,omitempty"`
DMTarget string `json:"dmTarget,omitempty"`
res, err := s.db.ExecContext(ctx, CreatedAt time.Time `json:"createdAt"`
`INSERT INTO users
(nick, token, created_at, last_seen)
VALUES (?, ?, ?, ?)`,
nick, token, now, now)
if err != nil {
return 0, "", fmt.Errorf("create user: %w", err)
}
id, _ := res.LastInsertId()
return id, token, nil
} }
// GetUserByToken returns user id and nick for a token. // GetMessages returns messages for a channel, optionally after a given ID.
func (s *Database) GetUserByToken( func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) {
ctx context.Context,
token string,
) (int64, string, error) {
var id int64
var nick string
err := s.db.QueryRowContext(
ctx,
"SELECT id, nick FROM users WHERE token = ?",
token,
).Scan(&id, &nick)
if err != nil {
return 0, "", err
}
_, _ = s.db.ExecContext(
ctx,
"UPDATE users SET last_seen = ? WHERE id = ?",
time.Now(), id,
)
return id, nick, nil
}
// GetUserByNick returns user id for a given nick.
func (s *Database) GetUserByNick(
ctx context.Context,
nick string,
) (int64, error) {
var id int64
err := s.db.QueryRowContext(
ctx,
"SELECT id FROM users WHERE nick = ?",
nick,
).Scan(&id)
return id, err
}
// GetChannelByName returns the channel ID for a name.
func (s *Database) GetChannelByName(
ctx context.Context,
name string,
) (int64, error) {
var id int64
err := s.db.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&id)
return id, err
}
// GetOrCreateChannel returns channel id, creating if needed.
func (s *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
var id int64
err := s.db.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&id)
if err == nil {
return id, nil
}
now := time.Now()
res, err := s.db.ExecContext(ctx,
`INSERT INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
id, _ = res.LastInsertId()
return id, nil
}
// JoinChannel adds a user to a channel.
func (s *Database) JoinChannel(
ctx context.Context,
channelID, userID int64,
) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, user_id, joined_at)
VALUES (?, ?, ?)`,
channelID, userID, time.Now())
return err
}
// PartChannel removes a user from a channel.
func (s *Database) PartChannel(
ctx context.Context,
channelID, userID int64,
) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND user_id = ?`,
channelID, userID)
return err
}
// DeleteChannelIfEmpty removes a channel with no members.
func (s *Database) DeleteChannelIfEmpty(
ctx context.Context,
channelID int64,
) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM channels WHERE id = ?
AND NOT EXISTS
(SELECT 1 FROM channel_members
WHERE channel_id = ?)`,
channelID, channelID)
return err
}
// scanChannels scans rows into a ChannelInfo slice.
func scanChannels(
rows *sql.Rows,
) ([]ChannelInfo, error) {
defer func() { _ = rows.Close() }()
var out []ChannelInfo
for rows.Next() {
var ch ChannelInfo
err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic)
if err != nil {
return nil, err
}
out = append(out, ch)
}
err := rows.Err()
if err != nil {
return nil, err
}
if out == nil {
out = []ChannelInfo{}
}
return out, nil
}
// ListChannels returns channels the user has joined.
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
}
return scanChannels(rows)
}
// ListAllChannels returns every channel.
func (s *Database) ListAllChannels(
ctx context.Context,
) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, topic
FROM channels ORDER BY name`)
if err != nil {
return nil, err
}
return scanChannels(rows)
}
// 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
}
// scanInt64s scans rows into an int64 slice.
func scanInt64s(rows *sql.Rows) ([]int64, error) {
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var id int64
err := rows.Scan(&id)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
err := rows.Err()
if err != nil {
return nil, err
}
return ids, nil
}
// GetChannelMemberIDs returns user IDs in a channel.
func (s *Database) GetChannelMemberIDs(
ctx context.Context,
channelID int64,
) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT user_id FROM channel_members
WHERE channel_id = ?`, channelID)
if err != nil {
return nil, err
}
return scanInt64s(rows)
}
// GetUserChannelIDs returns channel IDs the user is in.
func (s *Database) GetUserChannelIDs(
ctx context.Context,
userID int64,
) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
return scanInt64s(rows)
}
// InsertMessage stores a message and returns its DB ID.
func (s *Database) InsertMessage(
ctx context.Context,
command, from, to string,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) {
msgUUID := uuid.New().String()
now := time.Now().UTC()
if body == nil {
body = json.RawMessage("[]")
}
if meta == nil {
meta = json.RawMessage("{}")
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO messages
(uuid, command, msg_from, msg_to,
body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, to,
string(body), string(meta), now)
if err != nil {
return 0, "", err
}
id, _ := res.LastInsertId()
return id, msgUUID, nil
}
// EnqueueMessage adds a message to a user's queue.
func (s *Database) EnqueueMessage(
ctx context.Context,
userID, messageID int64,
) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues
(user_id, message_id, created_at)
VALUES (?, ?, ?)`,
userID, messageID, time.Now())
return err
}
// PollMessages returns queued messages for a user.
func (s *Database) PollMessages(
ctx context.Context,
userID, afterQueueID int64,
limit int,
) ([]IRCMessage, int64, error) {
if limit <= 0 { if limit <= 0 {
limit = defaultPollLimit limit = 50
} }
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command, `SELECT m.id, c.name, u.nick, m.content, m.created_at
m.msg_from, m.msg_to, FROM messages m
m.body, m.meta, m.created_at INNER JOIN users u ON u.id = m.user_id
FROM client_queues cq INNER JOIN channels c ON c.id = m.channel_id
INNER JOIN messages m WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ?
ON m.id = cq.message_id ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit)
WHERE cq.user_id = ? AND cq.id > ?
ORDER BY cq.id ASC LIMIT ?`,
userID, afterQueueID, limit)
if err != nil {
return nil, afterQueueID, err
}
msgs, lastQID, scanErr := scanMessages(
rows, afterQueueID,
)
if scanErr != nil {
return nil, afterQueueID, scanErr
}
return msgs, lastQID, nil
}
// GetHistory returns message history for a target.
func (s *Database) GetHistory(
ctx context.Context,
target string,
beforeID int64,
limit int,
) ([]IRCMessage, error) {
if limit <= 0 {
limit = defaultHistLimit
}
rows, err := s.queryHistory(
ctx, target, beforeID, limit,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
msgs, _, scanErr := scanMessages(rows, 0) var msgs []MessageInfo
if scanErr != nil { for rows.Next() {
return nil, scanErr var m MessageInfo
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
msgs = append(msgs, m)
} }
if msgs == nil { if msgs == nil {
msgs = []IRCMessage{} msgs = []MessageInfo{}
} }
reverseMessages(msgs)
return msgs, nil return msgs, nil
} }
func (s *Database) queryHistory( // SendMessage inserts a channel message.
ctx context.Context, func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) {
target string, res, err := s.db.ExecContext(ctx,
beforeID int64, "INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)",
limit int, channelID, userID, content, time.Now())
) (*sql.Rows, error) { if err != nil {
if beforeID > 0 { return 0, err
return s.db.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at
FROM messages
WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`,
target, beforeID, limit)
} }
return res.LastInsertId()
return s.db.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at
FROM messages
WHERE msg_to = ?
AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`,
target, limit)
} }
func scanMessages( // SendDM inserts a direct message.
rows *sql.Rows, func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) {
fallbackQID int64, res, err := s.db.ExecContext(ctx,
) ([]IRCMessage, int64, error) { "INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)",
defer func() { _ = rows.Close() }() fromID, content, toID, time.Now())
var msgs []IRCMessage
lastQID := fallbackQID
for rows.Next() {
var (
m IRCMessage
qID int64
body, meta string
ts time.Time
)
err := rows.Scan(
&qID, &m.ID, &m.Command,
&m.From, &m.To,
&body, &meta, &ts,
)
if err != nil { if err != nil {
return nil, fallbackQID, err return 0, err
} }
return res.LastInsertId()
}
m.Body = json.RawMessage(body) // GetDMs returns direct messages between two users after a given ID.
m.Meta = json.RawMessage(meta) func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) {
m.TS = ts.Format(time.RFC3339Nano) if limit <= 0 {
m.DBID = qID limit = 50
lastQID = qID }
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, u.nick, m.content, t.nick, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN users t ON t.id = m.dm_target_id
WHERE m.is_dm = 1 AND m.id > ?
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?))
ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err
}
m.IsDM = true
msgs = append(msgs, m) msgs = append(msgs, m)
} }
err := rows.Err()
if err != nil {
return nil, fallbackQID, err
}
if msgs == nil { if msgs == nil {
msgs = []IRCMessage{} msgs = []MessageInfo{}
} }
return msgs, nil
return msgs, lastQID, nil
} }
func reverseMessages(msgs []IRCMessage) { // PollMessages returns all new messages (channel + DM) for a user after a given ID.
func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, m.is_dm, COALESCE(t.nick, ''), m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
LEFT JOIN channels c ON c.id = m.channel_id
LEFT JOIN users t ON t.id = m.dm_target_id
WHERE m.id > ? AND (
(m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?))
OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?))
)
ORDER BY m.id ASC LIMIT ?`, afterID, userID, userID, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
var isDM int
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err
}
m.IsDM = isDM == 1
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
return msgs, nil
}
// GetMessagesBefore returns channel messages before a given ID (for history scrollback).
func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
var query string
var args []any
if beforeID > 0 {
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN channels c ON c.id = m.channel_id
WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id < ?
ORDER BY m.id DESC LIMIT ?`
args = []any{channelID, beforeID, limit}
} else {
query = `SELECT m.id, c.name, u.nick, m.content, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN channels c ON c.id = m.channel_id
WHERE m.channel_id = ? AND m.is_dm = 0
ORDER BY m.id DESC LIMIT ?`
args = []any{channelID, limit}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
// Reverse to ascending order
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i] msgs[i], msgs[j] = msgs[j], msgs[i]
} }
return msgs, nil
}
// GetDMsBefore returns DMs between two users before a given ID (for history scrollback).
func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
var query string
var args []any
if beforeID > 0 {
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN users t ON t.id = m.dm_target_id
WHERE m.is_dm = 1 AND m.id < ?
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?))
ORDER BY m.id DESC LIMIT ?`
args = []any{beforeID, userA, userB, userB, userA, limit}
} else {
query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at
FROM messages m
INNER JOIN users u ON u.id = m.user_id
INNER JOIN users t ON t.id = m.dm_target_id
WHERE m.is_dm = 1
AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?))
ORDER BY m.id DESC LIMIT ?`
args = []any{userA, userB, userB, userA, limit}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []MessageInfo
for rows.Next() {
var m MessageInfo
if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil {
return nil, err
}
m.IsDM = true
msgs = append(msgs, m)
}
if msgs == nil {
msgs = []MessageInfo{}
}
// Reverse to ascending order
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
} }
// ChangeNick updates a user's nickname. // ChangeNick updates a user's nickname.
func (s *Database) ChangeNick( func (s *Database) ChangeNick(ctx context.Context, userID int64, newNick string) error {
ctx context.Context,
userID int64,
newNick string,
) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
"UPDATE users SET nick = ? WHERE id = ?", "UPDATE users SET nick = ? WHERE id = ?", newNick, userID)
newNick, userID)
return err return err
} }
// SetTopic sets the topic for a channel. // SetTopic sets the topic for a channel.
func (s *Database) SetTopic( func (s *Database) SetTopic(ctx context.Context, channelName string, _ int64, topic string) error {
ctx context.Context,
channelName, topic string,
) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`UPDATE channels SET topic = ?, "UPDATE channels SET topic = ? WHERE name = ?", topic, channelName)
updated_at = ? WHERE name = ?`,
topic, time.Now(), channelName)
return err return err
} }
// DeleteUser removes a user and all their data. // GetServerName returns the server name (unused, config provides this).
func (s *Database) DeleteUser( func (s *Database) GetServerName() string {
ctx context.Context, return ""
userID int64,
) error {
_, err := s.db.ExecContext(
ctx,
"DELETE FROM users WHERE id = ?",
userID,
)
return err
} }
// GetAllChannelMembershipsForUser returns channels // ListAllChannels returns all channels.
// a user belongs to. func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) {
func (s *Database) GetAllChannelMembershipsForUser(
ctx context.Context,
userID int64,
) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.topic "SELECT id, name, topic FROM channels ORDER BY name")
FROM channels c
INNER JOIN channel_members cm
ON cm.channel_id = c.id
WHERE cm.user_id = ?`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
return scanChannels(rows) 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
} }

View File

@@ -1,550 +0,0 @@
package db_test
import (
"context"
"encoding/json"
"testing"
"git.eeqj.de/sneak/chat/internal/db"
_ "modernc.org/sqlite"
)
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
d, err := db.NewTestDatabase()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
closeErr := d.Close()
if closeErr != nil {
t.Logf("close db: %v", closeErr)
}
})
return d
}
func TestCreateUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
id, token, err := database.CreateUser(ctx, "alice")
if err != nil {
t.Fatal(err)
}
if id == 0 || token == "" {
t.Fatal("expected valid id and token")
}
_, _, err = database.CreateUser(ctx, "alice")
if err == nil {
t.Fatal("expected error for duplicate nick")
}
}
func TestGetUserByToken(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, token, err := database.CreateUser(ctx, "bob")
if err != nil {
t.Fatal(err)
}
id, nick, err := database.GetUserByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "bob" || id == 0 {
t.Fatalf("expected bob, got %s", nick)
}
_, _, err = database.GetUserByToken(ctx, "badtoken")
if err == nil {
t.Fatal("expected error for bad token")
}
}
func TestGetUserByNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, _, err := database.CreateUser(ctx, "charlie")
if err != nil {
t.Fatal(err)
}
id, err := database.GetUserByNick(ctx, "charlie")
if err != nil || id == 0 {
t.Fatal("expected to find charlie")
}
_, err = database.GetUserByNick(ctx, "nobody")
if err == nil {
t.Fatal("expected error for unknown nick")
}
}
func TestChannelOperations(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
chID, err := database.GetOrCreateChannel(ctx, "#test")
if err != nil || chID == 0 {
t.Fatal("expected channel id")
}
chID2, err := database.GetOrCreateChannel(ctx, "#test")
if err != nil || chID2 != chID {
t.Fatal("expected same channel id")
}
chID3, err := database.GetChannelByName(ctx, "#test")
if err != nil || chID3 != chID {
t.Fatal("expected same channel id")
}
_, err = database.GetChannelByName(ctx, "#nope")
if err == nil {
t.Fatal("expected error for nonexistent channel")
}
}
func TestJoinAndPart(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, "user1")
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(ctx, "#chan")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
ids, err := database.GetChannelMemberIDs(ctx, chID)
if err != nil || len(ids) != 1 || ids[0] != uid {
t.Fatal("expected user in channel")
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
ids, _ = database.GetChannelMemberIDs(ctx, chID)
if len(ids) != 0 {
t.Fatal("expected empty channel")
}
}
func TestDeleteChannelIfEmpty(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
chID, err := database.GetOrCreateChannel(
ctx, "#empty",
)
if err != nil {
t.Fatal(err)
}
uid, _, err := database.CreateUser(ctx, "temp")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil {
t.Fatal(err)
}
_, err = database.GetChannelByName(ctx, "#empty")
if err == nil {
t.Fatal("expected channel to be deleted")
}
}
func createUserWithChannels(
t *testing.T,
database *db.Database,
nick, ch1Name, ch2Name string,
) (int64, int64, int64) {
t.Helper()
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, nick)
if err != nil {
t.Fatal(err)
}
ch1, err := database.GetOrCreateChannel(
ctx, ch1Name,
)
if err != nil {
t.Fatal(err)
}
ch2, err := database.GetOrCreateChannel(
ctx, ch2Name,
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch1, uid)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch2, uid)
if err != nil {
t.Fatal(err)
}
return uid, ch1, ch2
}
func TestListChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
t, database, "lister", "#a", "#b",
)
channels, err := database.ListChannels(
context.Background(), uid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
"expected 2 channels, got %d",
len(channels),
)
}
}
func TestListAllChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, err := database.GetOrCreateChannel(ctx, "#x")
if err != nil {
t.Fatal(err)
}
_, err = database.GetOrCreateChannel(ctx, "#y")
if err != nil {
t.Fatal(err)
}
channels, err := database.ListAllChannels(ctx)
if err != nil || len(channels) < 2 {
t.Fatalf(
"expected >= 2 channels, got %d",
len(channels),
)
}
}
func TestChangeNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, token, err := database.CreateUser(ctx, "old")
if err != nil {
t.Fatal(err)
}
err = database.ChangeNick(ctx, uid, "new")
if err != nil {
t.Fatal(err)
}
_, nick, err := database.GetUserByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "new" {
t.Fatalf("expected new, got %s", nick)
}
}
func TestSetTopic(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, err := database.GetOrCreateChannel(
ctx, "#topictest",
)
if err != nil {
t.Fatal(err)
}
err = database.SetTopic(ctx, "#topictest", "Hello")
if err != nil {
t.Fatal(err)
}
channels, err := database.ListAllChannels(ctx)
if err != nil {
t.Fatal(err)
}
for _, ch := range channels {
if ch.Name == "#topictest" &&
ch.Topic != "Hello" {
t.Fatalf(
"expected topic Hello, got %s",
ch.Topic,
)
}
}
}
func TestInsertAndPollMessages(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, "poller")
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil,
)
if err != nil || dbID == 0 || msgUUID == "" {
t.Fatal("insert failed")
}
err = database.EnqueueMessage(ctx, uid, dbID)
if err != nil {
t.Fatal(err)
}
const batchSize = 10
msgs, lastQID, err := database.PollMessages(
ctx, uid, 0, batchSize,
)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf(
"expected 1 message, got %d", len(msgs),
)
}
if msgs[0].Command != "PRIVMSG" {
t.Fatalf(
"expected PRIVMSG, got %s", msgs[0].Command,
)
}
if lastQID == 0 {
t.Fatal("expected nonzero lastQID")
}
msgs, _, _ = database.PollMessages(
ctx, uid, lastQID, batchSize,
)
if len(msgs) != 0 {
t.Fatalf(
"expected 0 messages, got %d", len(msgs),
)
}
}
func TestGetHistory(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
const msgCount = 10
for range msgCount {
_, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist",
json.RawMessage(`["msg"]`), nil,
)
if err != nil {
t.Fatal(err)
}
}
const histLimit = 5
msgs, err := database.GetHistory(
ctx, "#hist", 0, histLimit,
)
if err != nil {
t.Fatal(err)
}
if len(msgs) != histLimit {
t.Fatalf("expected %d, got %d",
histLimit, len(msgs))
}
if msgs[0].DBID > msgs[histLimit-1].DBID {
t.Fatal("expected ascending order")
}
}
func TestDeleteUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, "deleteme")
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(
ctx, "#delchan",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteUser(ctx, uid)
if err != nil {
t.Fatal(err)
}
_, err = database.GetUserByNick(ctx, "deleteme")
if err == nil {
t.Fatal("user should be deleted")
}
ids, _ := database.GetChannelMemberIDs(ctx, chID)
if len(ids) != 0 {
t.Fatal("expected no members after deletion")
}
}
func TestChannelMembers(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid1, _, err := database.CreateUser(ctx, "m1")
if err != nil {
t.Fatal(err)
}
uid2, _, err := database.CreateUser(ctx, "m2")
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(
ctx, "#members",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid1)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid2)
if err != nil {
t.Fatal(err)
}
members, err := database.ChannelMembers(ctx, chID)
if err != nil || len(members) != 2 {
t.Fatalf(
"expected 2 members, got %d",
len(members),
)
}
}
func TestGetAllChannelMembershipsForUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
t, database, "multi", "#m1", "#m2",
)
channels, err :=
database.GetAllChannelMembershipsForUser(
context.Background(), uid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
"expected 2 channels, got %d",
len(channels),
)
}
}

View File

@@ -1,54 +1,4 @@
-- Chat server schema (pre-1.0 consolidated) CREATE TABLE IF NOT EXISTS schema_migrations (
PRAGMA foreign_keys = ON; version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- Users: IRC-style sessions (no passwords, just nick + token)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
-- Channels
CREATE TABLE IF NOT EXISTS 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
);
-- Channel members
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, user_id)
);
-- Messages: IRC envelope format
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id);

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '',
modes TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,31 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_dm INTEGER NOT NULL DEFAULT 0,
dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at);
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,9 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
@@ -17,8 +15,6 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
var errUnauthorized = errors.New("unauthorized")
// Params defines the dependencies for creating Handlers. // Params defines the dependencies for creating Handlers.
type Params struct { type Params struct {
fx.In fx.In
@@ -35,19 +31,14 @@ type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
func New( func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
lc fx.Lifecycle,
params Params,
) (*Handlers, error) {
s := new(Handlers) s := new(Handlers)
s.params = &params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.hc = params.Healthcheck s.hc = params.Healthcheck
s.broker = broker.New()
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
@@ -58,17 +49,9 @@ func New(
return s, nil return s, nil
} }
func (s *Handlers) respondJSON( func (s *Handlers) respondJSON(w http.ResponseWriter, _ *http.Request, data any, status int) {
w http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
w.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
w.WriteHeader(status) w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
if data != nil { if data != nil {
err := json.NewEncoder(w).Encode(data) err := json.NewEncoder(w).Encode(data)

View File

@@ -0,0 +1,17 @@
package models
import (
"time"
)
// Channel represents a chat channel.
type Channel struct {
Base
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
Modes string `json:"modes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

20
internal/models/model.go Normal file
View File

@@ -0,0 +1,20 @@
// Package models defines the data models used by the chat application.
package models
import "database/sql"
// DB is the interface that models use to query relations.
// This avoids a circular import with the db package.
type DB interface {
GetDB() *sql.DB
}
// Base is embedded in all model structs to provide database access.
type Base struct {
db DB
}
// SetDB injects the database reference into a model.
func (b *Base) SetDB(d DB) {
b.db = d
}

View File

@@ -4,6 +4,6 @@ import "time"
const ( const (
httpReadTimeout = 10 * time.Second httpReadTimeout = 10 * time.Second
httpWriteTimeout = 60 * time.Second httpWriteTimeout = 10 * time.Second
maxHeaderBytes = 1 << 20 maxHeaderBytes = 1 << 20
) )

View File

@@ -16,7 +16,7 @@ import (
const routeTimeout = 60 * time.Second const routeTimeout = 60 * time.Second
// SetupRoutes configures the HTTP routes and middleware. // SetupRoutes configures the HTTP routes and middleware chain.
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
@@ -39,19 +39,13 @@ func (s *Server) SetupRoutes() {
} }
// Health check // Health check
s.router.Get( s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
"/.well-known/healthcheck.json",
s.h.HandleHealthCheck(),
)
// Protected metrics endpoint // Protected metrics endpoint
if viper.GetString("METRICS_USERNAME") != "" { if viper.GetString("METRICS_USERNAME") != "" {
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth()) r.Use(s.mw.MetricsAuth())
r.Get("/metrics", r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
))
}) })
} }
@@ -59,66 +53,35 @@ func (s *Server) SetupRoutes() {
s.router.Route("/api/v1", func(r chi.Router) { s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/server", s.h.HandleServerInfo()) r.Get("/server", s.h.HandleServerInfo())
r.Post("/session", s.h.HandleCreateSession()) r.Post("/session", s.h.HandleCreateSession())
// Unified state and message endpoints
r.Get("/state", s.h.HandleState()) r.Get("/state", s.h.HandleState())
r.Get("/messages", s.h.HandleGetMessages()) r.Get("/messages", s.h.HandleGetMessages())
r.Post("/messages", s.h.HandleSendCommand()) r.Post("/messages", s.h.HandleSendCommand())
r.Get("/history", s.h.HandleGetHistory()) r.Get("/history", s.h.HandleGetHistory())
// Channels
r.Get("/channels", s.h.HandleListAllChannels()) r.Get("/channels", s.h.HandleListAllChannels())
r.Get( r.Get("/channels/{channel}/members", s.h.HandleChannelMembers())
"/channels/{channel}/members",
s.h.HandleChannelMembers(),
)
}) })
// Serve embedded SPA // Serve embedded SPA
s.setupSPA()
}
func (s *Server) setupSPA() {
distFS, err := fs.Sub(web.Dist, "dist") distFS, err := fs.Sub(web.Dist, "dist")
if err != nil { if err != nil {
s.log.Error( s.log.Error("failed to get web dist filesystem", "error", err)
"failed to get web dist filesystem", } else {
"error", err,
)
return
}
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( // Try to serve the file; if not found, serve index.html for SPA routing
w http.ResponseWriter, f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:])
r *http.Request, if err != nil || len(f) == 0 {
) { indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html")
readFS, ok := distFS.(fs.ReadFileFS) w.Header().Set("Content-Type", "text/html; charset=utf-8")
if !ok {
fileServer.ServeHTTP(w, r)
return
}
f, readErr := readFS.ReadFile(r.URL.Path[1:])
if readErr != nil || len(f) == 0 {
indexHTML, indexErr := readFS.ReadFile(
"index.html",
)
if indexErr != nil {
http.NotFound(w, r)
return
}
w.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexHTML) _, _ = w.Write(indexHTML)
return return
} }
fileServer.ServeHTTP(w, r) fileServer.ServeHTTP(w, r)
}) })
}
} }

View File

@@ -148,9 +148,7 @@ func (s *Server) cleanupForExit() {
func (s *Server) cleanShutdown() { func (s *Server) cleanShutdown() {
s.exitCode = 0 s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
context.Background(), shutdownTimeout,
)
err := s.httpServer.Shutdown(ctxShutdown) err := s.httpServer.Shutdown(ctxShutdown)
if err != nil { if err != nil {

465
web/dist/app.js vendored

File diff suppressed because one or more lines are too long