5 Commits

Author SHA1 Message Date
clawbot
f9c145ad09 refactor: replace HTTP error codes with IRC numeric replies for all IRC commands
All checks were successful
check / check (push) Successful in 58s
IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, etc.) now respond with
proper IRC numeric replies delivered through the message queue instead of
HTTP status codes. HTTP error codes are now reserved exclusively for
transport-level concerns: auth failures (401), malformed requests (400),
and server errors (500).

Changes:
- Add params column to messages table for IRC-style parameters
- Add Params field to IRCMessage struct and update all queries
- Add respondIRCError helper for consistent IRC error delivery
- Add RPL_WELCOME (001) on session creation and login
- Add RPL_TOPIC/RPL_NOTOPIC (332/331), RPL_NAMREPLY (353),
  RPL_ENDOFNAMES (366) on JOIN
- Add RPL_TOPIC (332) on TOPIC set
- Replace HTTP 404 with ERR_NOSUCHCHANNEL (403) and ERR_NOSUCHNICK (401)
- Replace HTTP 409 with ERR_NICKNAMEINUSE (433)
- Replace HTTP 403 with ERR_NOTONCHANNEL (442)
- Replace HTTP 400 with ERR_NEEDMOREPARAMS (461), ERR_ERRONEUSNICKNAME (432),
  and ERR_UNKNOWNCOMMAND (421) where appropriate
- Change PRIVMSG/NOTICE success from HTTP 201 to HTTP 200
- Update all tests to verify IRC numerics in message queue
- Add new tests for RPL_WELCOME and JOIN numerics
- Update README to document new numeric reply behavior

closes #54
2026-03-08 01:32:02 -08:00
c0e344d6fc Fix SPA: bundle preact instead of leaving as external require (closes #48) (#49)
All checks were successful
check / check (push) Successful in 5s
## Problem

The SPA fails to load with:
```
Uncaught Error: Dynamic require of "preact" is not supported
```

The esbuild config in `web/build.sh` had `--external:preact`, which tells the bundler to leave preact as a `require()` call instead of including it in the bundle. Since the browser has no `require()` function and there is no CDN/import-map loading preact externally, the app crashes immediately.

## Fix

- Remove `--external:preact` from `build.sh` so preact is bundled into `app.js`
- Add `--format=esm` to output proper ESM instead of IIFE with CJS require shims
- Update `index.html` to use `<script type="module">` for ESM compatibility
- Remove the dead fallback build command (was never reached since the first command succeeded)
- Rebuild `dist/app.js` with preact properly inlined (21.1KB minified)

closes #48

Reviewed-on: sneak/chat#49
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-07 14:53:13 +01:00
2da7f11484 Rename app from chat to neoirc, binary to neoircd (closes #46) (#47)
All checks were successful
check / check (push) Successful in 2m24s
Complete rename of the application from `chat` to `neoirc` with binary name `neoircd`.

closes sneak/chat#46

## Changes

- **Go module path**: `git.eeqj.de/sneak/chat` → `git.eeqj.de/sneak/neoirc`
- **Server binary**: `chatd` → `neoircd`
- **CLI binary**: `chat-cli` → `neoirc-cli`
- **Cmd directories**: `cmd/chatd` → `cmd/neoircd`, `cmd/chat-cli` → `cmd/neoirc-cli`
- **Go package**: `chatapi` → `neoircapi`
- **Makefile**: binary name, build targets, docker image tag, clean target
- **Dockerfile**: binary paths, user/group names (`chat` → `neoirc`), ENTRYPOINT
- **`.gitignore`/`.dockerignore`**: artifact names
- **All Go imports and doc comments**
- **Default server name**: `chat` → `neoirc`
- **Web client**: localStorage keys (`chat_token`/`chat_channels` → `neoirc_token`/`neoirc_channels`), page title, default server display name
- **Schema files**: all `$id` URLs and example hostnames
- **README.md**: project name, all binary references, examples, directory tree
- **AGENTS.md**: build command reference
- **Test fixtures**: app name and channel names

Docker build passes. All tests pass.

<!-- session: agent:sdlc-manager:subagent:a4b8dbd3-a7c8-4fad-8239-bb5a64a9b3d6 -->

Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Reviewed-on: sneak/chat#47
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-07 14:43:58 +01:00
6e7bf028c1 fix: change appname to neoirc, default DB to /var/lib/neoirc/state.db (#45)
All checks were successful
check / check (push) Successful in 6s
## Changes

- Change `Appname` from `"chat"` to `"neoirc"` in `cmd/chatd/main.go`
- Change default `DBURL` from `file:./data.db?_journal_mode=WAL` to `file:///var/lib/neoirc/state.db?_journal_mode=WAL` in both `internal/config/config.go` and the `internal/db/db.go` fallback
- Create `/var/lib/neoirc/` directory in Dockerfile with proper ownership for the `chat` user
- Update README.md to reflect new defaults (DBURL table, `.env` example, docker run example, SQLite backup/location docs)
- Remove stale `data.db` reference from Makefile `clean` target

The DB path remains configurable via the `DBURL` environment variable. No Go packages were renamed.

Closes sneak/chat#44

Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Reviewed-on: sneak/chat#45
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-06 12:40:28 +01:00
2761ee156a feat: split Dockerfile into dedicated lint stage for faster CI (#32)
All checks were successful
check / check (push) Successful in 3m4s
## Summary

Split the Dockerfile into a dedicated lint stage using the prebuilt `golangci/golangci-lint:v2.1.6` image, so lint failures are reported faster without needing to download/compile golangci-lint first.

## Changes

- **New lint stage** (`AS lint`): Uses the prebuilt `golangci/golangci-lint` image (pinned by sha256). Runs `make fmt-check` and `make lint`.
- **Build stage** (`AS builder`): Runs `make test` + compilation. No longer installs golangci-lint via `go install`.
- **`COPY --from=lint`**: Forces BuildKit to execute the lint stage before proceeding with the build.
- **Runtime stage**: Unchanged.

All base images remain pinned by sha256 hash.

closes sneak/chat#27

<!-- session: agent:sdlc-manager:subagent:76cebdf6-86f0-4383-93e3-ff3e10fbc7a6 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: sneak/chat#32
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:05:08 +01:00
63 changed files with 811 additions and 354 deletions

View File

@@ -1,8 +1,8 @@
.git .git
*.md *.md
!README.md !README.md
chatd neoircd
chat-cli neoirc-cli
data.db data.db
data.db-wal data.db-wal
data.db-shm data.db-shm

4
.gitignore vendored
View File

@@ -21,7 +21,7 @@ node_modules/
*.key *.key
# Build artifacts # Build artifacts
/chatd /neoircd
/bin/ /bin/
*.exe *.exe
*.dll *.dll
@@ -34,5 +34,5 @@ vendor/
# Project # Project
data.db data.db
debug.log debug.log
/chat-cli /neoirc-cli
web/node_modules/ web/node_modules/

View File

@@ -5,7 +5,7 @@
1. **Format**: `gofmt -s -w .` and `goimports -w .` 1. **Format**: `gofmt -s -w .` and `goimports -w .`
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues 2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
3. **Test**: `go test -race ./...` — all passing 3. **Test**: `go test -race ./...` — all passing
4. **Build**: `go build ./cmd/chatd` — compiles clean 4. **Build**: `go build ./cmd/neoircd` — compiles clean
No commit lands on main with lint errors, test failures, or formatting issues. No commit lands on main with lint errors, test failures, or formatting issues.

View File

@@ -1,32 +1,45 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.24-alpine, 2026-02-26 # golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
WORKDIR /src WORKDIR /src
RUN apk add --no-cache git build-base make RUN apk add --no-cache git build-base make
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26 # Force BuildKit to run the lint stage before proceeding
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Run all checks — build fails if branch is not green RUN make test
RUN make check
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go) # Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
# Runtime stage
# alpine:3.21, 2026-02-26 # alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates \ RUN apk add --no-cache ca-certificates \
&& addgroup -S chat && adduser -S chat -G chat && addgroup -S neoirc && adduser -S neoirc -G neoirc \
COPY --from=builder /chatd /usr/local/bin/chatd && mkdir -p /var/lib/neoirc \
&& chown neoirc:neoirc /var/lib/neoirc
COPY --from=builder /neoircd /usr/local/bin/neoircd
USER chat USER neoirc
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1 CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["chatd"] ENTRYPOINT ["neoircd"]

View File

@@ -1,6 +1,6 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks .PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := chatd BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH) BUILDARCH := $(shell go env GOARCH)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH) LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build all: check build
build: build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: lint:
golangci-lint run --config .golangci.yml ./... golangci-lint run --config .golangci.yml ./...
@@ -27,7 +27,7 @@ test:
# Used by CI and Docker build — fails if anything is wrong # Used by CI and Docker build — fails if anything is wrong
check: test lint fmt-check check: test lint fmt-check
@echo "==> Building..." @echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
@echo "==> All checks passed!" @echo "==> All checks passed!"
run: build run: build
@@ -37,10 +37,10 @@ debug: build
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY) DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
clean: clean:
rm -rf bin/ chatd data.db rm -rf bin/ neoircd
docker: docker:
docker build -t chat . docker build -t neoirc .
hooks: hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit

128
README.md
View File

@@ -1,9 +1,9 @@
# chat # neoirc
**IRC semantics, structured message metadata, cryptographic signing, and **IRC semantics, structured message metadata, cryptographic signing, and
server-held session state with per-client delivery queues. All over HTTP+JSON.** server-held session state with per-client delivery queues. All over HTTP+JSON.**
A chat server written in Go that decouples session state from transport An IRC-inspired server written in Go that decouples session state from transport
connections, enabling mobile-friendly persistent sessions over plain HTTP. connections, enabling mobile-friendly persistent sessions over plain HTTP.
The **HTTP API is the primary interface**. It's designed to be simple enough The **HTTP API is the primary interface**. It's designed to be simple enough
@@ -44,7 +44,7 @@ IRC is in decline because session state is tied to the TCP connection. In a
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
or pay for IRCCloud. or pay for IRCCloud.
This project builds a chat server that: This project builds a server that:
- Holds session state server-side (message queues, presence, channel membership) - Holds session state server-side (message queues, presence, channel membership)
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against - Exposes a minimal, clean HTTP+JSON API — easy to build clients against
@@ -132,7 +132,7 @@ makes signing consistent — you sign the same structure you send.
### Why not XMPP or Matrix? ### Why not XMPP or Matrix?
XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented XMPP is XML-based, overengineered for messaging, and the ecosystem is fragmented
across incompatible extensions (XEPs). Matrix is a federated append-only event across incompatible extensions (XEPs). Matrix is a federated append-only event
graph with a spec that runs to hundreds of pages. Both are fine protocols, but graph with a spec that runs to hundreds of pages. Both are fine protocols, but
they're solving different problems at different scales. they're solving different problems at different scales.
@@ -828,16 +828,16 @@ the server to the client (never C2S) and use 3-digit string codes in the
| Code | Name | When Sent | Example | | Code | Name | When Sent | Example |
|------|----------------------|-----------|---------| |------|----------------------|-----------|---------|
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | | `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is chatserver, running version 0.1"]}` | | `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc-server, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | | `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["chatserver","0.1","","imnst"]}` | | `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc-server","0.1","","imnst"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` | | `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | | `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | | `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` | | `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | | `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | | `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- chatserver Message of the Day -"]}` | | `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | | `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | | `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | | `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
@@ -845,10 +845,11 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | | `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | | `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
**Note:** Numeric replies are planned for full implementation. The current MVP **Note:** Numeric replies are now implemented. All IRC command responses
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead (success and error) are delivered as numeric replies through the message queue.
of numeric replies for error conditions. Numeric replies in the message queue HTTP error codes are reserved for transport-level issues (auth failures,
will be added post-MVP. malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
### Channel Modes ### Channel Modes
@@ -1054,8 +1055,8 @@ reference with all required and optional fields.
| Command | Required Fields | Optional | Response Status | | Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------| |-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 201 Created | | `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `JOIN` | `to` | | 200 OK | | `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK | | `NICK` | `body` | | 200 OK |
@@ -1063,18 +1064,44 @@ reference with all required and optional fields.
| `QUIT` | | `body` | 200 OK | | `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK | | `PING` | | | 200 OK |
**Errors (all commands):** All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth tokens (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `invalid request` | Malformed JSON | | 400 | `invalid request` | Malformed JSON or empty command |
| 400 | `to field required` | Missing `to` for commands that need it |
| 400 | `body required` | Missing `body` for commands that need it |
| 400 | `unknown command: X` | Unrecognized command |
| 401 | `unauthorized` | Missing or invalid auth token | | 401 | `unauthorized` | Missing or invalid auth token |
| 404 | `channel not found` | Target channel doesn't exist | | 500 | `internal error` | Server-side failure |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken | **IRC numeric error replies (delivered via message queue):**
| Numeric | Name | When |
|---------|------|------|
| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist |
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
**IRC numeric success replies (delivered via message queue):**
| Numeric | Name | When |
|---------|------|------|
| 001 | RPL_WELCOME | Sent on session creation/login |
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
| 353 | RPL_NAMREPLY | Channel member list (on JOIN) |
| 366 | RPL_ENDOFNAMES | End of NAMES list (on JOIN) |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 372 | RPL_MOTD | MOTD line |
| 376 | RPL_ENDOFMOTD | End of MOTD |
### GET /api/v1/history — Message History ### GET /api/v1/history — Message History
@@ -1214,7 +1241,7 @@ Return server metadata. No authentication required.
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
{ {
"name": "My Chat Server", "name": "My NeoIRC Server",
"motd": "Welcome! Be nice.", "motd": "Welcome! Be nice.",
"users": 42 "users": 42
} }
@@ -1468,7 +1495,7 @@ authenticity.
## Federation (Server-to-Server) ## Federation (Server-to-Server)
Federation allows multiple chat servers to link together, forming a network Federation allows multiple neoirc servers to link together, forming a network
where users on different servers can share channels — similar to IRC server where users on different servers can share channels — similar to IRC server
linking. linking.
@@ -1645,7 +1672,7 @@ directory is also loaded automatically via
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
|--------------------|---------|--------------------------------------|-------------| |--------------------|---------|--------------------------------------|-------------|
| `PORT` | int | `8080` | HTTP listen port | | `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) | | `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. | | `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
@@ -1664,10 +1691,10 @@ directory is also loaded automatically via
```bash ```bash
PORT=8080 PORT=8080
SERVER_NAME=My Chat Server SERVER_NAME=My NeoIRC Server
MOTD=Welcome! Be excellent to each other. MOTD=Welcome! Be excellent to each other.
DEBUG=false DEBUG=false
DBURL=file:./data.db?_journal_mode=WAL DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=24h SESSION_IDLE_TIMEOUT=24h
``` ```
@@ -1677,25 +1704,24 @@ SESSION_IDLE_TIMEOUT=24h
### Docker (Recommended) ### Docker (Recommended)
The Docker image contains a single static binary (`chatd`) and nothing else. The Docker image contains a single static binary (`neoircd`) and nothing else.
```bash ```bash
# Build # Build
docker build -t chat . docker build -t neoirc .
# Run # Run
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-v chat-data:/data \ -v neoirc-data:/var/lib/neoirc \
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
-e SERVER_NAME="My Server" \ -e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \ -e MOTD="Welcome!" \
chat neoirc
``` ```
The Dockerfile is a multi-stage build: The Dockerfile is a multi-stage build:
1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify 1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
compilation, not included in final image) compilation, not included in final image)
2. **Final stage**: Alpine Linux + `chatd` binary only 2. **Final stage**: Alpine Linux + `neoircd` binary only
```dockerfile ```dockerfile
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
@@ -1704,13 +1730,13 @@ RUN apk add --no-cache make
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN go build -o /chatd ./cmd/chatd/ RUN go build -o /neoircd ./cmd/neoircd/
RUN go build -o /chat-cli ./cmd/chat-cli/ RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
FROM alpine:latest FROM alpine:latest
COPY --from=builder /chatd /usr/local/bin/chatd COPY --from=builder /neoircd /usr/local/bin/neoircd
EXPOSE 8080 EXPOSE 8080
CMD ["chatd"] CMD ["neoircd"]
``` ```
### Binary ### Binary
@@ -1718,11 +1744,11 @@ CMD ["chatd"]
```bash ```bash
# Build from source # Build from source
make build make build
# Binary at ./bin/chatd # Binary at ./bin/neoircd
# Run # Run
./bin/chatd ./bin/neoircd
# Listens on :8080, creates ./data.db # Listens on :8080, writes to /var/lib/neoirc/state.db
``` ```
### Reverse Proxy (Production) ### Reverse Proxy (Production)
@@ -1731,7 +1757,7 @@ For production, run behind a TLS-terminating reverse proxy.
**Caddy:** **Caddy:**
``` ```
chat.example.com { neoirc.example.com {
reverse_proxy localhost:8080 reverse_proxy localhost:8080
} }
``` ```
@@ -1740,7 +1766,7 @@ chat.example.com {
```nginx ```nginx
server { server {
listen 443 ssl; listen 443 ssl;
server_name chat.example.com; server_name neoirc.example.com;
ssl_certificate /path/to/cert.pem; ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
@@ -1763,15 +1789,15 @@ seconds to accommodate long-poll connections.
string). This allows concurrent reads during writes. string). This allows concurrent reads during writes.
- **Single writer**: SQLite allows only one writer at a time. For high-traffic - **Single writer**: SQLite allows only one writer at a time. For high-traffic
servers, Postgres support is planned. servers, Postgres support is planned.
- **Backup**: The database is a single file. Back it up with `sqlite3 data.db ".backup backup.db"` or just copy the file (safe with WAL mode). - **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode).
- **Location**: By default, `data.db` is created in the working directory. - **Location**: By default, `state.db` is created in `/var/lib/neoirc/`.
Use the `DBURL` env var to place it elsewhere. Use the `DBURL` env var to place it elsewhere.
--- ---
## Client Development Guide ## Client Development Guide
This section explains how to write a client against the chat API. The API is This section explains how to write a client against the neoirc API. The API is
designed to be simple enough that a basic client can be written in any language designed to be simple enough that a basic client can be written in any language
with an HTTP client library. with an HTTP client library.
@@ -2061,7 +2087,7 @@ GET /api/v1/challenge
- [x] NICK change with broadcast - [x] NICK change with broadcast
- [x] QUIT with broadcast and cleanup - [x] QUIT with broadcast and cleanup
- [x] Embedded web SPA client - [x] Embedded web SPA client
- [x] CLI client (chat-cli) - [x] CLI client (neoirc-cli)
- [x] SQLite storage with WAL mode - [x] SQLite storage with WAL mode
- [x] Docker deployment - [x] Docker deployment
- [x] Prometheus metrics endpoint - [x] Prometheus metrics endpoint
@@ -2114,11 +2140,11 @@ GET /api/v1/challenge
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
``` ```
chat/ neoirc/
├── cmd/ ├── cmd/
│ ├── chatd/ # Server binary entry point │ ├── neoircd/ # Server binary entry point
│ │ └── main.go │ │ └── main.go
│ └── chat-cli/ # TUI client │ └── neoirc-cli/ # TUI client
│ ├── main.go # Command handling, poll loop │ ├── main.go # Command handling, poll loop
│ ├── ui.go # tview-based terminal UI │ ├── ui.go # tview-based terminal UI
│ └── api/ │ └── api/
@@ -2249,7 +2275,7 @@ chat/
- IRC message envelope format with per-client queue fan-out - IRC message envelope format with per-client queue fan-out
- Long-polling with in-memory broker - Long-polling with in-memory broker
- Embedded web SPA client - Embedded web SPA client
- TUI client (chat-cli) - TUI client (neoirc-cli)
- Docker image - Docker image
- Prometheus metrics - Prometheus metrics

View File

@@ -1,5 +1,5 @@
// Package chatapi provides a client for the chat server API. // Package neoircapi provides a client for the neoirc server API.
package chatapi package neoircapi
import ( import (
"bytes" "bytes"
@@ -23,7 +23,7 @@ const (
var errHTTP = errors.New("HTTP error") var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the chat server API. // Client wraps HTTP calls to the neoirc server API.
type Client struct { type Client struct {
BaseURL string BaseURL string
Token string Token string

View File

@@ -1,4 +1,4 @@
package chatapi package neoircapi
import "time" import "time"
@@ -21,7 +21,7 @@ type StateResponse struct {
Channels []string `json:"channels"` Channels []string `json:"channels"`
} }
// Message represents a chat message envelope. // Message represents a neoirc message envelope.
type Message struct { type Message struct {
Command string `json:"command"` Command string `json:"command"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`

View File

@@ -1,4 +1,4 @@
// Package main is the entry point for the chat-cli client. // Package main is the entry point for the neoirc-cli client.
package main package main
import ( import (
@@ -8,7 +8,7 @@ import (
"sync" "sync"
"time" "time"
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api" api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
) )
const ( const (
@@ -41,7 +41,7 @@ func main() {
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 neoirc-cli — an IRC-style client",
) )
app.ui.AddStatus( app.ui.AddStatus(
"Type [yellow]/connect <server-url>" + "Type [yellow]/connect <server-url>" +
@@ -564,7 +564,7 @@ func (a *App) cmdQuit() {
func (a *App) cmdHelp() { func (a *App) cmdHelp() {
help := []string{ help := []string{
"[cyan]*** chat-cli commands:", "[cyan]*** neoirc-cli commands:",
" /connect <url> — Connect to server", " /connect <url> — Connect to server",
" /nick <name> — Change nickname", " /nick <name> — Change nickname",
" /join #channel — Join channel", " /join #channel — Join channel",

View File

@@ -1,21 +1,21 @@
// Package main is the entry point for the chatd server. // Package main is the entry point for the neoircd server.
package main package main
import ( import (
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers" "git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/chat/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/chat/internal/server" "git.eeqj.de/sneak/neoirc/internal/server"
"go.uber.org/fx" "go.uber.org/fx"
) )
var ( var (
// Appname is the application name, set at build time. // Appname is the application name, set at build time.
Appname = "chat" //nolint:gochecknoglobals Appname = "neoirc" //nolint:gochecknoglobals
// Version is the application version, set at build time. // Version is the application version, set at build time.
Version string //nolint:gochecknoglobals Version string //nolint:gochecknoglobals

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/chat module git.eeqj.de/sneak/neoirc
go 1.24.0 go 1.24.0

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/chat/internal/broker" "git.eeqj.de/sneak/neoirc/internal/broker"
) )
func TestNewBroker(t *testing.T) { func TestNewBroker(t *testing.T) {

View File

@@ -5,8 +5,8 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -56,7 +56,7 @@ func New(
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL") viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")

View File

@@ -12,8 +12,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx" "go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // .env _ "github.com/joho/godotenv/autoload" // .env
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000" dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
} }
database.log.Info( database.log.Info(

View File

@@ -35,6 +35,7 @@ type IRCMessage struct {
Command string `json:"command"` Command string `json:"command"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Body json.RawMessage `json:"body,omitempty"` Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"` TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"` Meta json.RawMessage `json:"meta,omitempty"`
@@ -491,12 +492,17 @@ func (database *Database) GetSessionChannelIDs(
func (database *Database) InsertMessage( func (database *Database) InsertMessage(
ctx context.Context, ctx context.Context,
command, from, target string, command, from, target string,
params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
if params == nil {
params = json.RawMessage("[]")
}
if body == nil { if body == nil {
body = json.RawMessage("[]") body = json.RawMessage("[]")
} }
@@ -508,10 +514,10 @@ func (database *Database) InsertMessage(
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
body, meta, created_at) params, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target, msgUUID, command, from, target,
string(body), string(meta), now) string(params), string(body), string(meta), now)
if err != nil { if err != nil {
return 0, "", fmt.Errorf( return 0, "", fmt.Errorf(
"insert message: %w", err, "insert message: %w", err,
@@ -578,7 +584,7 @@ func (database *Database) PollMessages(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command, `SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to, m.msg_from, m.msg_to,
m.body, m.meta, m.created_at m.params, m.body, m.meta, m.created_at
FROM client_queues cq FROM client_queues cq
INNER JOIN messages m INNER JOIN messages m
ON m.id = cq.message_id ON m.id = cq.message_id
@@ -642,7 +648,7 @@ func (database *Database) queryHistory(
if beforeID > 0 { if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? AND id < ? WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -659,7 +665,7 @@ func (database *Database) queryHistory(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? WHERE msg_to = ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -684,16 +690,16 @@ func scanMessages(
for rows.Next() { for rows.Next() {
var ( var (
msg IRCMessage msg IRCMessage
qID int64 qID int64
body, meta string params, body, meta string
createdAt time.Time createdAt time.Time
) )
err := rows.Scan( err := rows.Scan(
&qID, &msg.ID, &msg.Command, &qID, &msg.ID, &msg.Command,
&msg.From, &msg.To, &msg.From, &msg.To,
&body, &meta, &createdAt, &params, &body, &meta, &createdAt,
) )
if err != nil { if err != nil {
return nil, fallbackQID, fmt.Errorf( return nil, fallbackQID, fmt.Errorf(
@@ -701,6 +707,10 @@ func scanMessages(
) )
} }
if params != "" && params != "[]" {
msg.Params = json.RawMessage(params)
}
msg.Body = json.RawMessage(body) msg.Body = json.RawMessage(body)
msg.Meta = json.RawMessage(meta) msg.Meta = json.RawMessage(meta)
msg.TS = createdAt.Format(time.RFC3339Nano) msg.TS = createdAt.Format(time.RFC3339Nano)

View File

@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage( dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
for range msgCount { for range msgCount {
_, _, err := database.InsertMessage( _, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", ctx, "PRIVMSG", "user", "#hist",
json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`) body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", body, nil, ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS messages (
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP

View File

@@ -91,7 +91,7 @@ func (hdlr *Handlers) fanOut(
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, body, nil, request.Context(), command, from, target, nil, body, nil,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -185,7 +185,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
@@ -219,49 +219,76 @@ func (hdlr *Handlers) handleCreateSessionError(
) )
} }
// deliverWelcome sends the RPL_WELCOME (001) numeric to a
// new client.
func (hdlr *Handlers) deliverWelcome(
request *http.Request,
clientID int64,
nick string,
) {
ctx := request.Context()
hdlr.enqueueNumeric(
ctx, clientID, "001", nick, nil,
"Welcome to the network, "+nick,
)
}
// deliverMOTD sends the MOTD as IRC numeric messages to a // deliverMOTD sends the MOTD as IRC numeric messages to a
// new client. // new client.
func (hdlr *Handlers) deliverMOTD( func (hdlr *Handlers) deliverMOTD(
request *http.Request, request *http.Request,
clientID, sessionID int64, clientID, sessionID int64,
nick string,
) { ) {
motd := hdlr.params.Config.MOTD motd := hdlr.params.Config.MOTD
serverName := hdlr.params.Config.ServerName srvName := hdlr.serverName()
if serverName == "" {
serverName = "chat"
}
if motd == "" {
return
}
ctx := request.Context() ctx := request.Context()
hdlr.deliverWelcome(request, clientID, nick)
if motd == "" {
hdlr.broker.Notify(sessionID)
return
}
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "375", serverName, ctx, clientID, "375", nick, nil,
"- "+serverName+" Message of the Day -", "- "+srvName+" Message of the Day -",
) )
for line := range strings.SplitSeq(motd, "\n") { for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "372", serverName, ctx, clientID, "372", nick, nil,
"- "+line, "- "+line,
) )
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "376", serverName, ctx, clientID, "376", nick, nil,
"End of /MOTD command.", "End of /MOTD command.",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
} }
func (hdlr *Handlers) serverName() string {
name := hdlr.params.Config.ServerName
if name == "" {
return "neoirc"
}
return name
}
func (hdlr *Handlers) enqueueNumeric( func (hdlr *Handlers) enqueueNumeric(
ctx context.Context, ctx context.Context,
clientID int64, clientID int64,
command, serverName, text string, command, nick string,
params []string,
text string,
) { ) {
body, err := json.Marshal([]string{text}) body, err := json.Marshal([]string{text})
if err != nil { if err != nil {
@@ -272,9 +299,22 @@ func (hdlr *Handlers) enqueueNumeric(
return return
} }
var paramsJSON json.RawMessage
if len(params) > 0 {
paramsJSON, err = json.Marshal(params)
if err != nil {
hdlr.log.Error(
"marshal numeric params", "error", err,
)
return
}
}
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, serverName, "", ctx, command, hdlr.serverName(), nick,
json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -532,7 +572,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request.Body, hdlr.maxBodySize(), writer, request.Body, hdlr.maxBodySize(),
) )
sessionID, _, nick, ok := sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request) hdlr.requireAuth(writer, request)
if !ok { if !ok {
return return
@@ -582,7 +622,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
} }
hdlr.dispatchCommand( hdlr.dispatchCommand(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
payload.Command, payload.To, payload.Command, payload.To,
payload.Body, bodyLines, payload.Body, bodyLines,
) )
@@ -592,7 +633,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
func (hdlr *Handlers) dispatchCommand( func (hdlr *Handlers) dispatchCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
@@ -600,24 +641,30 @@ func (hdlr *Handlers) dispatchCommand(
switch command { switch command {
case cmdPrivmsg, "NOTICE": case cmdPrivmsg, "NOTICE":
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines, command, target, body, bodyLines,
) )
case "JOIN": case "JOIN":
hdlr.handleJoin( hdlr.handleJoin(
writer, request, sessionID, nick, target, writer, request,
sessionID, clientID, nick, target,
) )
case "PART": case "PART":
hdlr.handlePart( hdlr.handlePart(
writer, request, sessionID, nick, target, body, writer, request,
sessionID, clientID, nick, target, body,
) )
case "NICK": case "NICK":
hdlr.handleNick( hdlr.handleNick(
writer, request, sessionID, nick, bodyLines, writer, request,
sessionID, clientID, nick, bodyLines,
) )
case "TOPIC": case "TOPIC":
hdlr.handleTopic( hdlr.handleTopic(
writer, request, nick, target, body, bodyLines, writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
) )
case "QUIT": case "QUIT":
hdlr.handleQuit( hdlr.handleQuit(
@@ -627,50 +674,63 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"command": "PONG", "command": "PONG",
"from": hdlr.params.Config.ServerName, "from": hdlr.serverName(),
}, },
http.StatusOK) http.StatusOK)
default: default:
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"unknown command: "+command, "421", nick, []string{command},
http.StatusBadRequest, "Unknown command",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
} }
} }
func (hdlr *Handlers) handlePrivmsg( func (hdlr *Handlers) handlePrivmsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"to field required", "461", nick, []string{command},
http.StatusBadRequest, "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"body required", "461", nick, []string{command},
http.StatusBadRequest, "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg( hdlr.handleChannelMsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, command, target, body,
) )
@@ -678,15 +738,36 @@ func (hdlr *Handlers) handlePrivmsg(
} }
hdlr.handleDirectMsg( hdlr.handleDirectMsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, command, target, body,
) )
} }
// respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
numeric, nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
numeric, nick, params, text,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
func (hdlr *Handlers) handleChannelMsg( func (hdlr *Handlers) handleChannelMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
@@ -694,10 +775,10 @@ func (hdlr *Handlers) handleChannelMsg(
request.Context(), target, request.Context(), target,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"channel not found", "403", nick, []string{target},
http.StatusNotFound, "No such channel",
) )
return return
@@ -720,15 +801,27 @@ func (hdlr *Handlers) handleChannelMsg(
} }
if !isMember { if !isMember {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"not a member of this channel", "442", nick, []string{target},
http.StatusForbidden, "You're not on that channel",
) )
return return
} }
hdlr.sendChannelMsg(
writer, request, command, nick, target, body, chID,
)
}
func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter,
request *http.Request,
command, nick, target string,
body json.RawMessage,
chID int64,
) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID, request.Context(), chID,
) )
@@ -761,13 +854,13 @@ func (hdlr *Handlers) handleChannelMsg(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"}, map[string]string{"id": msgUUID, "status": "sent"},
http.StatusCreated) http.StatusOK)
} }
func (hdlr *Handlers) handleDirectMsg( func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
@@ -775,11 +868,15 @@ func (hdlr *Handlers) handleDirectMsg(
request.Context(), target, request.Context(), target,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"user not found", "401", nick, []string{target},
http.StatusNotFound, "No such nick/channel",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -805,20 +902,20 @@ func (hdlr *Handlers) handleDirectMsg(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"}, map[string]string{"id": msgUUID, "status": "sent"},
http.StatusCreated) http.StatusOK)
} }
func (hdlr *Handlers) handleJoin( func (hdlr *Handlers) handleJoin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, target string, nick, target string,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"to field required", "461", nick, []string{"JOIN"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
@@ -830,15 +927,27 @@ func (hdlr *Handlers) handleJoin(
} }
if !validChannelRe.MatchString(channel) { if !validChannelRe.MatchString(channel) {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"invalid channel name", "403", nick, []string{channel},
http.StatusBadRequest, "No such channel",
) )
return return
} }
hdlr.executeJoin(
writer, request,
sessionID, clientID, nick, channel,
)
}
func (hdlr *Handlers) executeJoin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
) {
chID, err := hdlr.params.Database.GetOrCreateChannel( chID, err := hdlr.params.Database.GetOrCreateChannel(
request.Context(), channel, request.Context(), channel,
) )
@@ -879,6 +988,10 @@ func (hdlr *Handlers) handleJoin(
request, "JOIN", nick, channel, nil, memberIDs, request, "JOIN", nick, channel, nil, memberIDs,
) )
hdlr.deliverJoinNumerics(
request, clientID, sessionID, nick, channel, chID,
)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"status": "joined", "status": "joined",
@@ -887,19 +1000,96 @@ func (hdlr *Handlers) handleJoin(
http.StatusOK) http.StatusOK)
} }
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics(
request *http.Request,
clientID, sessionID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
chInfo, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err == nil {
_ = chInfo // chInfo is the ID; topic comes from DB.
}
// Get topic from channel info.
channels, listErr := hdlr.params.Database.ListChannels(
ctx, sessionID,
)
topic := ""
if listErr == nil {
for _, ch := range channels {
if ch.Name == channel {
topic = ch.Topic
break
}
}
}
if topic != "" {
hdlr.enqueueNumeric(
ctx, clientID, "332", nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, "331", nick,
[]string{channel}, "No topic is set",
)
}
// Get member list for NAMES reply.
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handlePart( func (hdlr *Handlers) handlePart(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, target string, nick, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"to field required", "461", nick, []string{"PART"},
http.StatusBadRequest, "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -913,11 +1103,15 @@ func (hdlr *Handlers) handlePart(
request.Context(), channel, request.Context(), channel,
) )
if err != nil { if err != nil {
hdlr.respondError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
"channel not found", "403", nick, []string{channel},
http.StatusNotFound, "No such channel",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -961,16 +1155,16 @@ func (hdlr *Handlers) handlePart(
func (hdlr *Handlers) handleNick( func (hdlr *Handlers) handleNick(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"body required (new nick)", "461", nick, []string{"NICK"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
@@ -979,10 +1173,10 @@ func (hdlr *Handlers) handleNick(
newNick := strings.TrimSpace(lines[0]) newNick := strings.TrimSpace(lines[0])
if !validNickRe.MatchString(newNick) { if !validNickRe.MatchString(newNick) {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"invalid nick", "432", nick, []string{newNick},
http.StatusBadRequest, "Erroneous nickname",
) )
return return
@@ -998,15 +1192,27 @@ func (hdlr *Handlers) handleNick(
return return
} }
hdlr.executeNickChange(
writer, request,
sessionID, clientID, nick, newNick,
)
}
func (hdlr *Handlers) executeNickChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, newNick string,
) {
err := hdlr.params.Database.ChangeNick( err := hdlr.params.Database.ChangeNick(
request.Context(), sessionID, newNick, request.Context(), sessionID, newNick,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"nick already in use", "433", nick, []string{newNick},
http.StatusConflict, "Nickname is already in use",
) )
return return
@@ -1056,7 +1262,7 @@ func (hdlr *Handlers) broadcastNick(
dbID, _, _ := hdlr.params.Database.InsertMessage( dbID, _, _ := hdlr.params.Database.InsertMessage(
request.Context(), "NICK", oldNick, "", request.Context(), "NICK", oldNick, "",
json.RawMessage(nickBody), nil, nil, json.RawMessage(nickBody), nil,
) )
_ = hdlr.params.Database.EnqueueToSession( _ = hdlr.params.Database.EnqueueToSession(
@@ -1088,15 +1294,16 @@ func (hdlr *Handlers) broadcastNick(
func (hdlr *Handlers) handleTopic( func (hdlr *Handlers) handleTopic(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64,
nick, target string, nick, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"to field required", "461", nick, []string{"TOPIC"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
@@ -1104,46 +1311,60 @@ func (hdlr *Handlers) handleTopic(
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
"body required (topic text)", "461", nick, []string{"TOPIC"},
http.StatusBadRequest, "Not enough parameters",
) )
return return
} }
topic := strings.Join(lines, " ")
channel := target channel := target
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
channel = "#" + channel channel = "#" + channel
} }
err := hdlr.params.Database.SetTopic( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), channel, topic, request.Context(), channel,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.respondIRCError(
"set topic failed", "error", err, writer, request, clientID, sessionID,
) "403", nick, []string{channel},
hdlr.respondError( "No such channel",
writer, request,
"internal error",
http.StatusInternalServerError,
) )
return return
} }
chID, err := hdlr.params.Database.GetChannelByName( hdlr.executeTopic(
request.Context(), channel, writer, request,
sessionID, clientID, nick,
channel, strings.Join(lines, " "),
body, chID,
) )
if err != nil { }
func (hdlr *Handlers) executeTopic(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel, topic string,
body json.RawMessage,
chID int64,
) {
setErr := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
)
if setErr != nil {
hdlr.log.Error(
"set topic failed", "error", setErr,
)
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"channel not found", "internal error",
http.StatusNotFound, http.StatusInternalServerError,
) )
return return
@@ -1157,6 +1378,12 @@ func (hdlr *Handlers) handleTopic(
request, "TOPIC", nick, channel, body, memberIDs, request, "TOPIC", nick, channel, body, memberIDs,
) )
hdlr.enqueueNumeric(
request.Context(), clientID,
"332", nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"status": "ok", "topic": topic, "status": "ok", "topic": topic,
@@ -1182,7 +1409,8 @@ func (hdlr *Handlers) handleQuit(
if len(channels) > 0 { if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage( dbID, _, _ = hdlr.params.Database.InsertMessage(
request.Context(), "QUIT", nick, "", body, nil, request.Context(), "QUIT", nick, "",
nil, body, nil,
) )
} }
@@ -1431,7 +1659,8 @@ func (hdlr *Handlers) cleanupUser(
if len(channels) > 0 { if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage( quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, "QUIT", nick, "", nil, nil, ctx, "QUIT", nick, "",
nil, nil, nil,
) )
} }

View File

@@ -17,14 +17,14 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers" "git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/chat/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/chat/internal/server" "git.eeqj.de/sneak/neoirc/internal/server"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
) )
@@ -115,7 +115,7 @@ func newTestServer(
func newTestGlobals() *globals.Globals { func newTestGlobals() *globals.Globals {
return &globals.Globals{ return &globals.Globals{
Appname: "chat-test", Appname: "neoirc-test",
Version: "test", Version: "test",
} }
} }
@@ -462,6 +462,19 @@ func findMessage(
return false return false
} }
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
for _, msg := range msgs {
if msg[commandKey] == numeric {
return true
}
}
return false
}
// --- Tests --- // --- Tests ---
func TestCreateSessionValid(t *testing.T) { func TestCreateSessionValid(t *testing.T) {
@@ -473,6 +486,47 @@ func TestCreateSessionValid(t *testing.T) {
} }
} }
func TestWelcomeNumeric(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("welcomer")
msgs, _ := tserver.pollMessages(token, 0)
if !findNumeric(msgs, "001") {
t.Fatalf(
"expected RPL_WELCOME (001), got %v",
msgs,
)
}
}
func TestJoinNumerics(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("jnumtest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#numtest",
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "353") {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
if !findNumeric(msgs, "366") {
t.Fatalf(
"expected RPL_ENDOFNAMES (366), got %v",
msgs,
)
}
}
func TestCreateSessionDuplicate(t *testing.T) { func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("alice") tserver.createSession("alice")
@@ -668,11 +722,23 @@ func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("joiner3") token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd}, token, map[string]any{commandKey: joinCmd},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -682,10 +748,10 @@ func TestChannelMessage(t *testing.T) {
bobToken := tserver.createSession("bob_msg") bobToken := tserver.createSession("bob_msg")
tserver.sendCommand(aliceToken, map[string]any{ tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#chat", commandKey: joinCmd, toKey: "#test",
}) })
tserver.sendCommand(bobToken, map[string]any{ tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#chat", commandKey: joinCmd, toKey: "#test",
}) })
_, _ = tserver.pollMessages(aliceToken, 0) _, _ = tserver.pollMessages(aliceToken, 0)
@@ -695,13 +761,13 @@ func TestChannelMessage(t *testing.T) {
aliceToken, aliceToken,
map[string]any{ map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
toKey: "#chat", toKey: "#test",
bodyKey: []string{"hello world"}, bodyKey: []string{"hello world"},
}, },
) )
if status != http.StatusCreated { if status != http.StatusOK {
t.Fatalf( t.Fatalf(
"expected 201, got %d: %v", status, result, "expected 200, got %d: %v", status, result,
) )
} }
@@ -725,14 +791,25 @@ func TestMessageMissingBody(t *testing.T) {
token := tserver.createSession("nobody") token := tserver.createSession("nobody")
tserver.sendCommand(token, map[string]any{ tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#chat", commandKey: joinCmd, toKey: "#test",
}) })
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#chat", commandKey: privmsgCmd, toKey: "#test",
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -740,12 +817,23 @@ func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("noto") token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
bodyKey: []string{"hello"}, bodyKey: []string{"hello"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -759,6 +847,8 @@ func TestNonMemberCannotSend(t *testing.T) {
commandKey: joinCmd, toKey: "#private", commandKey: joinCmd, toKey: "#private",
}) })
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining. // Alice tries to send without joining.
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
aliceToken, aliceToken,
@@ -768,8 +858,17 @@ func TestNonMemberCannotSend(t *testing.T) {
bodyKey: []string{"sneaky"}, bodyKey: []string{"sneaky"},
}, },
) )
if status != http.StatusForbidden { if status != http.StatusOK {
t.Fatalf("expected 403, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
} }
} }
@@ -786,9 +885,9 @@ func TestDirectMessage(t *testing.T) {
bodyKey: []string{"hey bob"}, bodyKey: []string{"hey bob"},
}, },
) )
if status != http.StatusCreated { if status != http.StatusOK {
t.Fatalf( t.Fatalf(
"expected 201, got %d: %v", status, result, "expected 200, got %d: %v", status, result,
) )
} }
@@ -818,13 +917,24 @@ func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("dmsender") token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
toKey: "nobody", toKey: "nobody",
bodyKey: []string{"hello?"}, bodyKey: []string{"hello?"},
}) })
if status != http.StatusNotFound { if status != http.StatusOK {
t.Fatalf("expected 404, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
} }
} }
@@ -871,12 +981,23 @@ func TestNickCollision(t *testing.T) {
tserver.createSession("taken_nick") tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"taken_nick"}, bodyKey: []string{"taken_nick"},
}) })
if status != http.StatusConflict { if status != http.StatusOK {
t.Fatalf("expected 409, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf(
"expected ERR_NICKNAMEINUSE (433), got %v",
msgs,
)
} }
} }
@@ -884,12 +1005,23 @@ func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nickval") token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"bad nick!"}, bodyKey: []string{"bad nick!"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf(
"expected ERR_ERRONEUSNICKNAME (432), got %v",
msgs,
)
} }
} }
@@ -897,11 +1029,22 @@ func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nicknobody") token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "NICK"}, token, map[string]any{commandKey: "NICK"},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -938,12 +1081,23 @@ func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("topicnoto") token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", commandKey: "TOPIC",
bodyKey: []string{"topic"}, bodyKey: []string{"topic"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -955,11 +1109,22 @@ func TestTopicMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#topictest", commandKey: joinCmd, toKey: "#topictest",
}) })
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest", commandKey: "TOPIC", toKey: "#topictest",
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -1027,11 +1192,22 @@ func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("cmdtest") token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"}, token, map[string]any{commandKey: "BOGUS"},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf(
"expected ERR_UNKNOWNCOMMAND (421), got %v",
msgs,
)
} }
} }
@@ -1278,12 +1454,18 @@ func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("lp_timeout") token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now() start := time.Now()
resp, err := doRequestAuth( resp, err := doRequestAuth(
t, t,
http.MethodGet, http.MethodGet,
tserver.url(apiMessages+"?timeout=1"), tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
token, token,
nil, nil,
) )

View File

@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
return return
} }
sessionID, _, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser( hdlr.params.Database.LoginUser(
request.Context(), request.Context(),
payload.Nick, payload.Nick,
@@ -178,6 +178,10 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,

View File

@@ -1,4 +1,4 @@
// Package handlers provides HTTP request handlers for the chat server. // Package handlers provides HTTP request handlers for the neoirc server.
package handlers package handlers
import ( import (
@@ -9,12 +9,12 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/chat/internal/broker" "git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx" "go.uber.org/fx"
) )

View File

@@ -6,10 +6,10 @@ import (
"log/slog" "log/slog"
"time" "time"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx" "go.uber.org/fx"
) )

View File

@@ -5,7 +5,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"go.uber.org/fx" "go.uber.org/fx"
) )

View File

@@ -1,4 +1,4 @@
// Package middleware provides HTTP middleware for the chat server. // Package middleware provides HTTP middleware for the neoirc server.
package middleware package middleware
import ( import (
@@ -7,9 +7,9 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/middleware" chimw "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"

View File

@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/chat/web" "git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"

View File

@@ -1,4 +1,4 @@
// Package server implements the main HTTP server for the chat application. // Package server implements the main HTTP server for the neoirc application.
package server package server
import ( import (
@@ -12,11 +12,11 @@ import (
"syscall" "syscall"
"time" "time"
"git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers" "git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/chat/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
"go.uber.org/fx" "go.uber.org/fx"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"

View File

@@ -1,6 +1,6 @@
# Message Schemas # Message Schemas
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON **IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
over HTTP. over HTTP.

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
"title": "JOIN", "title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
"title": "KICK", "title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.", "description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
"title": "MODE", "title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.", "description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
"title": "NICK", "title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE", "title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
"title": "PART", "title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
"title": "PING", "title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
"title": "PONG", "title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG", "title": "PRIVMSG",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY", "title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.", "description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
"title": "QUIT", "title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.", "description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC", "title": "TOPIC",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.", "description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -17,6 +17,6 @@
}, },
"required": ["command", "to"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] } { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
"title": "IRC Message Envelope", "title": "IRC Message Envelope",
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"type": "object", "type": "object",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME", "title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST", "title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -29,7 +29,7 @@
"command": "002", "command": "002",
"to": "alice", "to": "alice",
"body": [ "body": [
"Your host is chat.example.com, running version 0.1.0" "Your host is neoirc.example.com, running version 0.1.0"
] ]
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED", "title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.", "description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO", "title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -29,7 +29,7 @@
"command": "004", "command": "004",
"to": "alice", "to": "alice",
"params": [ "params": [
"chat.example.com", "neoirc.example.com",
"0.1.0", "0.1.0",
"o", "o",
"imnst+ov" "imnst+ov"

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST", "title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND", "title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.", "description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC", "title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -40,7 +40,7 @@
"#general" "#general"
], ],
"body": [ "body": [
"Welcome to the chat" "Welcome to the channel"
] ]
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY", "title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES", "title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.", "description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD", "title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART", "title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.", "description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD", "title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.", "description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK", "title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.", "description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL", "title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.", "description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE", "title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.", "description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL", "title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.", "description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json", "$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED", "title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.", "description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -16,19 +16,11 @@ fi
mkdir -p dist mkdir -p dist
# Build JS bundle # Build JS bundle — preact must be bundled (no CDN/external loader)
${NPX:+$NPX} esbuild src/app.jsx \
--bundle \
--minify \
--jsx-factory=h \
--jsx-fragment=Fragment \
--define:process.env.NODE_ENV=\"production\" \
--external:preact \
--outfile=dist/app.js \
2>/dev/null || \
${NPX:+$NPX} esbuild src/app.jsx \ ${NPX:+$NPX} esbuild src/app.jsx \
--bundle \ --bundle \
--minify \ --minify \
--format=esm \
--jsx-factory=h \ --jsx-factory=h \
--jsx-fragment=Fragment \ --jsx-fragment=Fragment \
--define:process.env.NODE_ENV=\"production\" \ --define:process.env.NODE_ENV=\"production\" \

4
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View File

@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title> <title>NeoIRC</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="/app.js"></script> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,7 @@ const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000; const MEMBER_REFRESH_INTERVAL = 10000;
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem('chat_token'); const token = localStorage.getItem('neoirc_token');
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
@@ -34,7 +34,7 @@ function LoginScreen({ onLogin }) {
const [nick, setNick] = useState(''); const [nick, setNick] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [motd, setMotd] = useState(''); const [motd, setMotd] = useState('');
const [serverName, setServerName] = useState('Chat'); const [serverName, setServerName] = useState('NeoIRC');
const inputRef = useRef(); const inputRef = useRef();
useEffect(() => { useEffect(() => {
@@ -42,9 +42,9 @@ function LoginScreen({ onLogin }) {
if (s.name) setServerName(s.name); if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd); if (s.motd) setMotd(s.motd);
}).catch(() => {}); }).catch(() => {});
const saved = localStorage.getItem('chat_token'); const saved = localStorage.getItem('neoirc_token');
if (saved) { if (saved) {
api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('chat_token')); api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('neoirc_token'));
} }
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -57,7 +57,7 @@ function LoginScreen({ onLogin }) {
method: 'POST', method: 'POST',
body: JSON.stringify({ nick: nick.trim() }) body: JSON.stringify({ nick: nick.trim() })
}); });
localStorage.setItem('chat_token', res.token); localStorage.setItem('neoirc_token', res.token);
onLogin(res.nick); onLogin(res.nick);
} catch (err) { } catch (err) {
setError(err.data?.error || 'Connection failed'); setError(err.data?.error || 'Connection failed');
@@ -132,7 +132,7 @@ function App() {
// Persist joined channels // Persist joined channels
useEffect(() => { useEffect(() => {
const channels = tabs.filter(t => t.type === 'channel').map(t => t.name); const channels = tabs.filter(t => t.type === 'channel').map(t => t.name);
localStorage.setItem('chat_channels', JSON.stringify(channels)); localStorage.setItem('neoirc_channels', JSON.stringify(channels));
}, [tabs]); }, [tabs]);
// Clear unread on tab switch // Clear unread on tab switch
@@ -321,7 +321,7 @@ function App() {
setLoggedIn(true); setLoggedIn(true);
addSystemMessage('Server', `Connected as ${userNick}`); addSystemMessage('Server', `Connected as ${userNick}`);
// Auto-rejoin saved channels // Auto-rejoin saved channels
const saved = JSON.parse(localStorage.getItem('chat_channels') || '[]'); const saved = JSON.parse(localStorage.getItem('neoirc_channels') || '[]');
for (const ch of saved) { for (const ch of saved) {
try { try {
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) }); await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) });

View File

@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title> <title>NeoIRC</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="/app.js"></script> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>