From df2217a38b870dff7862f0a83c0d8c5db9d2e3bb Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 11 Feb 2026 03:02:41 +0100 Subject: [PATCH] Add embedded web chat client (closes #7) (#8) --- .gitignore | 2 + README.md | 624 +++++++++++++++++++++++++------ cmd/chat-cli/api/client.go | 204 ++++++++++ cmd/chat-cli/api/types.go | 83 ++++ cmd/chat-cli/main.go | 580 ++++++++++++++++++++++++++++ cmd/chat-cli/ui.go | 233 ++++++++++++ go.mod | 10 +- go.sum | 47 +++ internal/config/config.go | 4 +- internal/db/queries.go | 414 ++++++++++++++++++++ internal/db/schema/003_users.sql | 31 ++ internal/handlers/api.go | 400 ++++++++++++++++++++ internal/handlers/handlers.go | 2 + internal/server/routes.go | 39 ++ schema/README.md | 97 +++++ schema/commands/JOIN.json | 16 + schema/commands/KICK.json | 29 ++ schema/commands/MODE.json | 29 ++ schema/commands/NICK.json | 22 ++ schema/commands/NOTICE.json | 21 ++ schema/commands/PART.json | 22 ++ schema/commands/PING.json | 20 + schema/commands/PONG.json | 21 ++ schema/commands/PRIVMSG.json | 24 ++ schema/commands/PUBKEY.json | 37 ++ schema/commands/QUIT.json | 21 ++ schema/commands/TOPIC.json | 22 ++ schema/message.json | 72 ++++ schema/numerics/001.json | 37 ++ schema/numerics/002.json | 36 ++ schema/numerics/003.json | 36 ++ schema/numerics/004.json | 39 ++ schema/numerics/322.json | 47 +++ schema/numerics/323.json | 27 ++ schema/numerics/332.json | 47 +++ schema/numerics/353.json | 48 +++ schema/numerics/366.json | 35 ++ schema/numerics/372.json | 36 ++ schema/numerics/375.json | 26 ++ schema/numerics/376.json | 27 ++ schema/numerics/401.json | 47 +++ schema/numerics/403.json | 47 +++ schema/numerics/433.json | 47 +++ schema/numerics/442.json | 35 ++ schema/numerics/482.json | 35 ++ web/build.sh | 41 ++ web/dist/app.js | 1 + web/dist/index.html | 13 + web/dist/style.css | 274 ++++++++++++++ web/embed.go | 9 + web/package-lock.json | 513 +++++++++++++++++++++++++ web/package.json | 18 + web/src/app.jsx | 371 ++++++++++++++++++ web/src/index.html | 13 + web/src/style.css | 274 ++++++++++++++ 55 files changed, 5182 insertions(+), 123 deletions(-) create mode 100644 cmd/chat-cli/api/client.go create mode 100644 cmd/chat-cli/api/types.go create mode 100644 cmd/chat-cli/main.go create mode 100644 cmd/chat-cli/ui.go create mode 100644 internal/db/queries.go create mode 100644 internal/db/schema/003_users.sql create mode 100644 internal/handlers/api.go create mode 100644 schema/README.md create mode 100644 schema/commands/JOIN.json create mode 100644 schema/commands/KICK.json create mode 100644 schema/commands/MODE.json create mode 100644 schema/commands/NICK.json create mode 100644 schema/commands/NOTICE.json create mode 100644 schema/commands/PART.json create mode 100644 schema/commands/PING.json create mode 100644 schema/commands/PONG.json create mode 100644 schema/commands/PRIVMSG.json create mode 100644 schema/commands/PUBKEY.json create mode 100644 schema/commands/QUIT.json create mode 100644 schema/commands/TOPIC.json create mode 100644 schema/message.json create mode 100644 schema/numerics/001.json create mode 100644 schema/numerics/002.json create mode 100644 schema/numerics/003.json create mode 100644 schema/numerics/004.json create mode 100644 schema/numerics/322.json create mode 100644 schema/numerics/323.json create mode 100644 schema/numerics/332.json create mode 100644 schema/numerics/353.json create mode 100644 schema/numerics/366.json create mode 100644 schema/numerics/372.json create mode 100644 schema/numerics/375.json create mode 100644 schema/numerics/376.json create mode 100644 schema/numerics/401.json create mode 100644 schema/numerics/403.json create mode 100644 schema/numerics/433.json create mode 100644 schema/numerics/442.json create mode 100644 schema/numerics/482.json create mode 100755 web/build.sh create mode 100644 web/dist/app.js create mode 100644 web/dist/index.html create mode 100644 web/dist/style.css create mode 100644 web/embed.go create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/app.jsx create mode 100644 web/src/index.html create mode 100644 web/src/style.css diff --git a/.gitignore b/.gitignore index 69f180c..e3ce31c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ data.db *.out vendor/ debug.log +web/node_modules/ +chat-cli diff --git a/README.md b/README.md index 57bb9d9..9971b1f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ # chat -A modern IRC-inspired chat server written in Go. Decouples session state from -transport connections, enabling mobile-friendly persistent sessions over HTTP. +**IRC plus message metadata, a signing system using it, and server-based +backlog queues for multiple connected clients on one nick. All via HTTP.** + +A chat server written in Go. Decouples session state from transport +connections, enabling mobile-friendly persistent sessions over HTTP. + +The **HTTP API is the primary interface**. It's designed to be simple enough +that writing a terminal IRC-style client against it is straightforward — just +`curl` and `jq` get you surprisingly far. The server also ships an embedded +web client as a convenience/reference implementation, but the API comes first. ## Motivation @@ -12,10 +20,166 @@ or pay for IRCCloud. This project builds a chat server that: - Holds session state server-side (message queues, presence, channel membership) -- Delivers messages over HTTP (JSON-RPC style) -- Supports multiple concurrent connections per user session +- Exposes a minimal, clean HTTP+JSON API — easy to build clients against +- Supports multiple concurrent clients per user session - Provides IRC-like semantics: channels, nicks, topics, modes -- Uses structured JSON messages with arbitrary extensibility +- Uses structured JSON messages with IRC command names and numeric reply codes + +## Why Not Just Use IRC / XMPP / Matrix? + +This isn't a new protocol that borrows IRC terminology for familiarity. This +**is** IRC — the same command model, the same semantics, the same numeric +reply codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. + +The question isn't "why build something new?" It's "what's the minimum set of +changes to make IRC work on modern devices?" The answer turned out to be four +things: + +### 1. HTTP transport instead of persistent TCP + +IRC requires a persistent TCP connection. That's fine on a desktop. On a phone, +the OS kills your background socket, you lose your session, you miss messages. +Bouncers exist but add complexity and a second point of failure. + +HTTP solves this cleanly: clients poll when they're awake, messages queue when +they're not. Works through firewalls, proxies, CDNs. Every language has an HTTP +client. No custom protocol parsers, no connection state machines. + +### 2. Server-held session state + +In IRC, the TCP connection *is* the session. Disconnect and you're gone — your +nick is released, you leave all channels, messages sent while you're offline +are lost forever. This is IRC's fundamental mobile problem. + +Here, sessions persist independently of connections. Your nick, channel +memberships, and message queue survive disconnects. Multiple devices can share +a session simultaneously, each with its own delivery queue. + +### 3. Structured message bodies + +IRC messages are single lines of text. That's a protocol constraint from 1988, +not a deliberate design choice. It forces multiline content through ugly +workarounds (multiple PRIVMSG commands, paste flood). + +Message bodies here are JSON arrays (one string per line) or objects (for +structured data like key material). This also enables deterministic +canonicalization via RFC 8785 JCS — you can't reliably sign something if the +wire representation is ambiguous. + +### 4. Key/value metadata on messages + +The `meta` field on every message envelope carries extensible attributes — +cryptographic signatures, content hashes, whatever clients want to attach. +IRC has no equivalent; bolting signatures onto IRC requires out-of-band +mechanisms or stuffing data into CTCP. + +### What didn't change + +Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, +`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. +Joining a nonexistent channel creates it. Channels disappear when empty. Nicks +are unique per server. There are no accounts — identity is a key, a nick is a +display name. + +### On the resemblance to JSON-RPC + +All C2S commands go through `POST /messages` with a `command` field that +dispatches the action. This looks like JSON-RPC, but the resemblance is +incidental. It's IRC's command model — `PRIVMSG #channel :hello` becomes +`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as +JSON rather than space-delimited text. The command vocabulary is IRC's, not +an invention. + +The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is +a `PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape +as the `JOIN` relayed to channel members. This keeps the protocol simple and +makes signing consistent — you sign the same structure you send. + +### Why not XMPP or Matrix? + +XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented +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 +they're solving different problems at different scales. + +This project wants IRC's simplicity with four specific fixes. That's it. + +## Design Decisions + +### Identity & Sessions — No Accounts + +There are no accounts, no registration, no passwords. Identity is a signing +key; a nick is just a display name. The two are decoupled. + +- **Session creation**: client connects → server assigns a **session UUID** + (user identity for this server), a **client UUID** (this specific device), + and an **opaque auth token** (random bytes, not JWT). +- The auth token implicitly identifies the client. Clients present it via + `Authorization: Bearer `. +- Nicks are changeable; the session UUID is the stable identity. +- Server-assigned UUIDs — clients do not choose their own IDs. + +### Multi-Client Model + +A single user session can have multiple clients (phone, laptop, terminal). + +- Each client gets a **separate server-to-client (S2C) message queue**. +- The server fans out all S2C messages to every active client queue for that + user session. +- `GET /api/v1/messages` delivers from the calling client's specific queue, + identified by the auth token. +- Client queues have **independent expiry/pruning** — one client going offline + doesn't affect others. + +``` +User (session UUID) +├── Client A (client UUID, token, queue) +├── Client B (client UUID, token, queue) +└── Client C (client UUID, token, queue) +``` + +### Message Immutability + +Messages are **immutable** — no editing, no deletion by clients. This is a +deliberate design choice that enables cryptographic signing: if a message could +be modified after signing, signatures would be meaningless. + +### Message Delivery + +- **Long-poll timeout**: 15 seconds +- **Queue depth**: server-configurable, default at least 48 hours worth of + messages +- **No delivery/read receipts** except in DMs +- **Bodies are structured** objects or arrays (never raw strings) — enables + deterministic canonicalization via RFC 8785 JCS for signing + +### Crypto & Signing + +- Servers **relay signatures verbatim** — signatures are key/value metadata on + message objects (`meta.sig`, `meta.alg`). Servers do not verify them. +- Clients handle key authentication via **TOFU** (trust on first use). +- **No key revocation mechanism** — keep your keys safe. +- **PUBKEY** message type for distributing signing keys to channel members. +- **E2E encryption for DMs** is planned for 1.0. + +### Channels + +- **Any user can create channels** — joining a nonexistent channel creates it, + like IRC. +- **Ephemeral** — channels disappear when the last member leaves. +- No channel size limits. +- No channel-level encryption. + +### Federation + +- **Manual server linking only** — no autodiscovery, no mesh. Operators + explicitly configure server links. +- Servers relay messages (including signatures) verbatim. + +### Web Client + +The SPA web client is a **convenience UI**. The primary interface is IRC-style +client apps talking directly to the HTTP API. ## Architecture @@ -24,117 +188,335 @@ This project builds a chat server that: All client↔server and server↔server communication uses HTTP/1.1+ with JSON request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. -- **Client polling**: Clients poll for new messages via `GET` with long-polling - support (server holds the connection open until messages arrive or timeout) -- **Client sending**: Clients send messages/commands via `POST` +- **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds + the connection for up to 15 seconds until messages arrive or timeout. + One endpoint for everything. +- **Client sending**: `POST /api/v1/messages` with a `to` field. That's it. - **Server federation**: Servers exchange messages via HTTP to enable multi-server networks (like IRC server linking) -### Core Concepts +The entire read/write loop for a client is two endpoints. Everything else is +channel management and history. -#### Users +### Session Model -- Identified by a unique user ID (UUID) -- Authenticate via token (issued at registration or login) -- Have a nick (changeable, unique per server at any point in time) -- Maintain a persistent message queue on the server - -#### Sessions - -- A session represents an authenticated user's connection context -- Session state is **server-held**, not connection-bound -- Multiple devices can share a session (messages delivered to all) -- Sessions persist across disconnects — messages queue until retrieved -- Sessions expire after a configurable idle timeout (default 24h) - -#### Channels - -- Named with `#` prefix (e.g. `#general`) -- Have a topic, mode flags, and member list -- Messages to a channel are queued for all members -- Channel history is stored server-side (configurable depth) -- No eternal logging by default — history rotates - -#### Messages - -Every message is a structured JSON object: - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "ts": "2026-02-09T20:00:00.000Z", - "from": "nick", - "to": "#channel", - "type": "message", - "body": "Hello, world!", - "meta": {} -} +``` +┌─────────────────────────────────┐ +│ User Session (UUID) │ +│ nick: "alice" │ +│ signing key: ed25519:... │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Client A │ │ Client B │ ... │ +│ │ UUID │ │ UUID │ │ +│ │ token │ │ token │ │ +│ │ queue │ │ queue │ │ +│ └──────────┘ └──────────┘ │ +└─────────────────────────────────┘ ``` -Fields: -- `id` — Server-assigned UUID, globally unique -- `ts` — Server-assigned timestamp (ISO 8601) -- `from` — Sender nick -- `to` — Destination: channel name (`#foo`) or nick (for DMs) -- `type` — Message type: `message`, `action`, `notice`, `join`, `part`, `quit`, - `topic`, `mode`, `nick`, `system` -- `body` — Message content (UTF-8 text) -- `meta` — Arbitrary extensible metadata (JSON object). Can carry: - - Cryptographic signatures - - Rich content hints (URLs, embeds) - - Client-specific extensions - - Reactions, edits, threading references +- **User session**: server-assigned UUID. Represents a user on this server. + Has a nick (changeable, unique per server at any point in time). +- **Client**: each device/connection gets its own UUID and opaque auth token. + The token is the credential — present it to authenticate. +- **Queue**: each client has an independent S2C message queue. The server fans + out messages to all active client queues for the session. + +Sessions persist across disconnects. Messages queue until retrieved. Client +queues expire independently after a configurable idle timeout. + +### Message Protocol + +All messages use **IRC command names and numeric reply codes** from RFC 1459/2812. +The `command` field identifies the message type. + +#### Message Envelope + +Every message is a JSON object with these fields: + +| Field | Type | Required | Description | +|-----------|-----------------|----------|-------------| +| `command` | string | ✓ | IRC command name or 3-digit numeric code | +| `from` | string | | Sender nick or server name | +| `to` | string | | Destination: `#channel` or nick | +| `params` | array\ | | Additional IRC-style parameters | +| `body` | array \| object | | Structured body (never a raw string — see below) | +| `id` | string (uuid) | | Server-assigned message UUID | +| `ts` | string | | Server-assigned ISO 8601 timestamp | +| `meta` | object | | Extensible metadata (signatures, hashes, etc.) | + +**Important:** Message bodies are **structured objects or arrays**, never raw +strings. This is a deliberate departure from IRC wire format that enables: + +- **Multiline messages** — body is a list of lines, no escape sequences +- **Deterministic canonicalization** — for hashing and signing (see below) +- **Structured data** — commands like PUBKEY carry key material as objects + +For text messages, `body` is an array of strings (one per line): + +```json +{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} +{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["line one", "line two"]} +``` + +For numeric replies with text trailing parameters: + +```json +{"command": "001", "to": "nick", "body": ["Welcome to the network, nick"]} +{"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["@op1 alice bob"]} +``` + +For structured data (keys, etc.), `body` is an object: + +```json +{"command": "PUBKEY", "from": "nick", "body": {"alg": "ed25519", "key": "base64..."}} +``` + +#### IRC Command Mapping + +**Commands (C2S and S2C):** + +| Command | RFC | Description | +|-----------|--------------|--------------------------------------| +| `PRIVMSG` | 1459 §4.4.1 | Message to channel or user | +| `NOTICE` | 1459 §4.4.2 | Notice (must not trigger auto-reply) | +| `JOIN` | 1459 §4.2.1 | Join a channel | +| `PART` | 1459 §4.2.2 | Leave a channel | +| `QUIT` | 1459 §4.1.6 | Disconnect from server | +| `NICK` | 1459 §4.1.2 | Change nickname | +| `MODE` | 1459 §4.2.3 | Set/query channel or user modes | +| `TOPIC` | 1459 §4.2.4 | Set/query channel topic | +| `KICK` | 1459 §4.2.8 | Kick user from channel | +| `PING` | 1459 §4.6.2 | Keepalive | +| `PONG` | 1459 §4.6.3 | Keepalive response | +| `PUBKEY` | (extension) | Announce/relay signing public key | + +All C2S commands may be relayed S2C to other users (e.g. JOIN, PART, PRIVMSG). + +**Numeric Reply Codes (S2C):** + +| Code | Name | Description | +|------|----------------------|-------------| +| 001 | RPL_WELCOME | Welcome after session creation | +| 002 | RPL_YOURHOST | Server host information | +| 003 | RPL_CREATED | Server creation date | +| 004 | RPL_MYINFO | Server info and modes | +| 322 | RPL_LIST | Channel list entry | +| 323 | RPL_LISTEND | End of channel list | +| 332 | RPL_TOPIC | Channel topic | +| 353 | RPL_NAMREPLY | Channel member list | +| 366 | RPL_ENDOFNAMES | End of NAMES list | +| 372 | RPL_MOTD | MOTD line | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 401 | ERR_NOSUCHNICK | No such nick/channel | +| 403 | ERR_NOSUCHCHANNEL | No such channel | +| 433 | ERR_NICKNAMEINUSE | Nickname already in use | +| 442 | ERR_NOTONCHANNEL | Not on that channel | +| 482 | ERR_CHANOPRIVSNEEDED | Not channel operator | + +**Server-to-Server (Federation):** + +Federated servers use the same IRC commands. After link establishment, servers +exchange a burst of JOIN, NICK, TOPIC, and MODE commands to sync state. +PING/PONG serve as inter-server keepalives. + +#### Message Examples + +```json +{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} + +{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"], "meta": {"sig": "base64...", "alg": "ed25519"}} + +{"command": "PRIVMSG", "from": "alice", "to": "bob", "body": ["hey, DM"]} + +{"command": "JOIN", "from": "bob", "to": "#general"} + +{"command": "PART", "from": "bob", "to": "#general", "body": ["later"]} + +{"command": "NICK", "from": "oldnick", "body": ["newnick"]} + +{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]} + +{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["@op1 alice bob +voiced1"]} + +{"command": "433", "to": "*", "params": ["alice"], "body": ["Nickname is already in use"]} + +{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64..."}} +``` + +#### JSON Schemas + +Full JSON Schema (draft 2020-12) definitions for all message types are in +[`schema/`](schema/). See [`schema/README.md`](schema/README.md) for the +complete index. + +### Canonicalization and Signing + +Messages support optional cryptographic signatures for integrity verification. +Servers relay signatures verbatim without verifying them — verification is +purely a client-side concern. + +#### Canonicalization (RFC 8785 JCS) + +To produce a deterministic byte representation of a message for signing: + +1. Remove `meta.sig` from the message (the signature itself is not signed) +2. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): + - Object keys sorted lexicographically + - No whitespace + - Numbers in shortest form + - UTF-8 encoding +3. The resulting byte string is the signing input + +This is why `body` must be an object or array — raw strings would be ambiguous +under canonicalization. + +#### Signing Flow + +1. Client generates an Ed25519 keypair +2. Client announces public key: `{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}` +3. Server relays PUBKEY to channel members / stores for the session +4. When sending a message, client: + a. Constructs the message without `meta.sig` + b. Canonicalizes per JCS + c. Signs with private key + d. Adds `meta.sig` (base64) and `meta.alg` +5. Recipients verify by repeating steps a–c and checking the signature + against the sender's announced public key + +#### PUBKEY Message + +```json +{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +``` + +Servers relay PUBKEY messages to all channel members. Clients cache public keys +and use them to verify `meta.sig` on incoming messages. Key distribution is +trust-on-first-use (TOFU). There is no key revocation mechanism. ### API Endpoints -All endpoints accept and return `application/json`. +All endpoints accept and return `application/json`. Authenticated endpoints +require `Authorization: Bearer ` header. -#### Authentication +The API is the primary interface — designed for IRC-style clients. The entire +client loop is: -``` -POST /api/v1/register — Create account (nick, password) → token -POST /api/v1/login — Authenticate → token -POST /api/v1/logout — Invalidate token +1. `POST /api/v1/session` — create a session, get a token +2. `GET /api/v1/state` — see who you are and what channels you're in +3. `GET /api/v1/messages?timeout=15` — long-poll for all messages (channel, DM, system) +4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"` + +That's the core. Everything else (join, part, history, members) is ancillary. + +#### Quick example (curl) + +```bash +# Create a session (get session UUID, client UUID, and auth token) +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ + -d '{"nick":"alice"}' | jq -r .token) + +# Join a channel (creates it if it doesn't exist) +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"command":"JOIN","to":"#general"}' + +# Send a message +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}' + +# Poll for messages (long-poll, 15s timeout) +curl -s "http://localhost:8080/api/v1/messages?timeout=15" \ + -H "Authorization: Bearer $TOKEN" ``` -#### Session & Messages +#### Session ``` -GET /api/v1/messages — Retrieve queued messages (long-poll supported) - Query params: ?after=&timeout=30 -POST /api/v1/messages — Send a message or command -GET /api/v1/history — Retrieve channel/DM history - Query params: ?target=#channel&before=&limit=50 +POST /api/v1/session — Create session { "nick": "..." } + → { id, nick, token } + Token is opaque (random), not JWT. + Token implicitly identifies the client. +``` + +#### State + +``` +GET /api/v1/state — User state: nick, session_id, client_id, + and list of joined channels +``` + +#### Messages (unified stream) + +``` +GET /api/v1/messages — Single message stream (long-poll, 15s timeout) + All message types: channel, DM, notices, events + Delivers from the calling client's queue + (identified by auth token) + Query params: ?after=&timeout=15 +POST /api/v1/messages — Send any C2S command (dispatched by "command" field) +``` + +All client-to-server commands use `POST /api/v1/messages` with a `command` +field. There are no separate endpoints for join, part, nick, topic, etc. + +| Command | Required Fields | Optional Fields | Description | +|-----------|---------------------|-----------------|-------------| +| `PRIVMSG` | `to`, `body` | `meta` | Message to channel (`#name`) or user (nick) | +| `NOTICE` | `to`, `body` | `meta` | Notice (must not trigger auto-reply) | +| `JOIN` | `to` | | Join a channel (creates if nonexistent) | +| `PART` | `to` | `body` | Leave a channel | +| `NICK` | `body` | | Change nickname — `body: ["newnick"]` | +| `TOPIC` | `to`, `body` | | Set channel topic | +| `MODE` | `to`, `params` | | Set channel/user modes | +| `KICK` | `to`, `params` | `body` | Kick user — `params: ["nick"]`, `body: ["reason"]` | +| `PING` | | | Keepalive (server responds with PONG) | +| `PUBKEY` | `body` | | Announce signing key — `body: {"alg":..., "key":...}` | + +Examples: + +```json +{"command": "PRIVMSG", "to": "#channel", "body": ["hello world"]} +{"command": "JOIN", "to": "#channel"} +{"command": "PART", "to": "#channel"} +{"command": "NICK", "body": ["newnick"]} +{"command": "TOPIC", "to": "#channel", "body": ["new topic text"]} +{"command": "PING"} +``` + +Messages are immutable — no edit or delete endpoints. + +#### History + +``` +GET /api/v1/history — Fetch history for a target (channel or DM) + Query params: ?target=#channel&before=&limit=50 + For DMs: ?target=nick&before=&limit=50 ``` #### Channels ``` -GET /api/v1/channels — List joined channels -POST /api/v1/channels/join — Join a channel -POST /api/v1/channels/part — Leave a channel -GET /api/v1/channels/{name} — Channel info (topic, members, modes) -POST /api/v1/channels/{name}/topic — Set channel topic +GET /api/v1/channels — List all server channels +GET /api/v1/channels/{name}/members — Channel member list ``` -#### Users - -``` -GET /api/v1/users/me — Current user info -POST /api/v1/users/nick — Change nick -GET /api/v1/users/{nick} — User info (online status, idle time) -``` +Join and part are handled via `POST /api/v1/messages` with `JOIN` and `PART` +commands (see Messages above). #### Server Info ``` -GET /api/v1/server — Server info (name, version, MOTD, user count) +GET /api/v1/server — Server info (name, MOTD) GET /.well-known/healthcheck.json — Health check ``` ### Federation (Server-to-Server) -Servers can link to form a network, similar to IRC server linking: +Servers can link to form a network, similar to IRC server linking. Links are +**manually configured** — there is no autodiscovery. ``` POST /api/v1/federation/link — Establish server link (mutual auth via shared key) @@ -142,8 +524,9 @@ POST /api/v1/federation/relay — Relay messages between linked servers GET /api/v1/federation/status — Link status ``` -Federation uses the same HTTP+JSON transport. Messages are relayed between -servers so users on different servers can share channels. +Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK, +UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are +relayed verbatim between servers so users on different servers can share channels. ### Channel Modes @@ -170,7 +553,9 @@ Via environment variables (Viper), following gohttpserver conventions: | `DEBUG` | `false` | Debug mode | | `MAX_HISTORY` | `10000` | Max messages per channel history | | `SESSION_TIMEOUT` | `86400` | Session idle timeout (seconds) | +| `QUEUE_MAX_AGE` | `172800` | Max client queue age in seconds (default 48h) | | `MAX_MESSAGE_SIZE` | `4096` | Max message body size (bytes) | +| `LONG_POLL_TIMEOUT` | `15` | Long-poll timeout in seconds | | `MOTD` | `""` | Message of the day | | `SERVER_NAME` | hostname | Server display name | | `FEDERATION_KEY` | `""` | Shared key for server linking | @@ -180,11 +565,12 @@ Via environment variables (Viper), following gohttpserver conventions: SQLite by default (single-file, zero-config), with Postgres support for larger deployments. Tables: -- `users` — accounts and auth tokens +- `sessions` — user sessions (UUID, nick, created_at) +- `clients` — client records (UUID, session_id, token_hash, last_seen) - `channels` — channel metadata and modes - `channel_members` — membership and user modes - `messages` — message history (rotated per `MAX_HISTORY`) -- `message_queue` — per-user pending delivery queue +- `client_queues` — per-client pending delivery queues - `server_links` — federation peer configuration ### Project Structure @@ -194,44 +580,31 @@ Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/s ``` chat/ ├── cmd/ -│ └── chat/ +│ └── chatd/ │ └── main.go ├── internal/ │ ├── config/ -│ │ └── config.go │ ├── database/ -│ │ └── database.go │ ├── globals/ -│ │ └── globals.go │ ├── handlers/ -│ │ ├── handlers.go -│ │ ├── auth.go -│ │ ├── channels.go -│ │ ├── federation.go -│ │ ├── healthcheck.go -│ │ ├── messages.go -│ │ └── users.go │ ├── healthcheck/ -│ │ └── healthcheck.go │ ├── logger/ -│ │ └── logger.go │ ├── middleware/ -│ │ └── middleware.go │ ├── models/ -│ │ ├── channel.go -│ │ ├── message.go -│ │ └── user.go │ ├── queue/ -│ │ └── queue.go │ └── server/ -│ ├── server.go -│ ├── http.go -│ └── routes.go +├── schema/ +│ ├── message.schema.json +│ ├── c2s/ +│ ├── s2c/ +│ ├── s2s/ +│ └── README.md +├── web/ ├── go.mod ├── go.sum ├── Makefile ├── Dockerfile -├── CONVENTIONS.md → (copy from gohttpserver) +├── CONVENTIONS.md └── README.md ``` @@ -252,20 +625,33 @@ Per gohttpserver conventions: ### Design Principles -1. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol +1. **API-first** — the HTTP API is the product. Clients are thin. If you can't + build a working IRC-style TUI client in an afternoon, the API is too complex. +2. **No accounts** — identity is a signing key, nick is a display name. No + registration, no passwords. Session creation is instant. +3. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812. + Familiar to anyone who's built IRC clients or bots. +4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, and works everywhere. -2. **Server holds state** — clients are stateless. Reconnect, switch devices, - lose connectivity — your messages are waiting. -3. **Structured messages** — JSON with extensible metadata. Enables signatures, - rich content, client extensions without protocol changes. -4. **Simple deployment** — single binary, SQLite default, zero mandatory +5. **Server holds state** — clients are stateless. Reconnect, switch devices, + lose connectivity — your messages are waiting in your client queue. +6. **Structured messages** — JSON with extensible metadata. Bodies are always + objects or arrays for deterministic canonicalization (JCS) and signing. +7. **Immutable messages** — no editing, no deletion. Fits naturally with + cryptographic signatures. +8. **Simple deployment** — single binary, SQLite default, zero mandatory external dependencies. -5. **No eternal logs** — history rotates. Chat should be ephemeral by default. -6. **Federation optional** — single server works standalone. Linking is opt-in. +9. **No eternal logs** — history rotates. Chat should be ephemeral by default. + Channels disappear when empty. +10. **Federation optional** — single server works standalone. Linking is manual + and opt-in. +11. **Signable messages** — optional Ed25519 signatures with TOFU key + distribution. Servers relay signatures without verification. ## Status -**Design phase.** This README is the spec. Implementation has not started. +**Implementation in progress.** Core API is functional with SQLite storage and +embedded web client. ## License diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go new file mode 100644 index 0000000..a20298c --- /dev/null +++ b/cmd/chat-cli/api/client.go @@ -0,0 +1,204 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client wraps HTTP calls to the chat server API. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// NewClient creates a new API client. +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) do(method, path string, body interface{}) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode >= 400 { + return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + return data, nil +} + +// CreateSession creates a new session on the server. +func (c *Client) CreateSession(nick string) (*SessionResponse, error) { + data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick}) + if err != nil { + return nil, err + } + var resp SessionResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode session: %w", err) + } + c.Token = resp.Token + return &resp, nil +} + +// GetState returns the current user state. +func (c *Client) GetState() (*StateResponse, error) { + data, err := c.do("GET", "/api/v1/state", nil) + if err != nil { + return nil, err + } + var resp StateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + return &resp, nil +} + +// SendMessage sends a message (any IRC command). +func (c *Client) SendMessage(msg *Message) error { + _, err := c.do("POST", "/api/v1/messages", msg) + return err +} + +// PollMessages long-polls for new messages. +func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { + // Use a longer HTTP timeout than the server long-poll timeout. + client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second} + + params := url.Values{} + if afterID != "" { + params.Set("after", afterID) + } + params.Set("timeout", fmt.Sprintf("%d", timeout)) + + path := "/api/v1/messages" + if len(params) > 0 { + path += "?" + params.Encode() + } + + req, err := http.NewRequest("GET", c.BaseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + // The server may return an array directly or wrapped. + var msgs []Message + if err := json.Unmarshal(data, &msgs); err != nil { + // Try wrapped format. + var wrapped MessagesResponse + if err2 := json.Unmarshal(data, &wrapped); err2 != nil { + return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data)) + } + msgs = wrapped.Messages + } + + return msgs, nil +} + +// JoinChannel joins a channel via the unified command endpoint. +func (c *Client) JoinChannel(channel string) error { + return c.SendMessage(&Message{Command: "JOIN", To: channel}) +} + +// PartChannel leaves a channel via the unified command endpoint. +func (c *Client) PartChannel(channel string) error { + return c.SendMessage(&Message{Command: "PART", To: channel}) +} + +// ListChannels returns all channels on the server. +func (c *Client) ListChannels() ([]Channel, error) { + data, err := c.do("GET", "/api/v1/channels", nil) + if err != nil { + return nil, err + } + var channels []Channel + if err := json.Unmarshal(data, &channels); err != nil { + return nil, err + } + return channels, nil +} + +// GetMembers returns members of a channel. +func (c *Client) GetMembers(channel string) ([]string, error) { + data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil) + if err != nil { + return nil, err + } + var members []string + if err := json.Unmarshal(data, &members); err != nil { + // Try object format. + var obj map[string]interface{} + if err2 := json.Unmarshal(data, &obj); err2 != nil { + return nil, err + } + // Extract member names from whatever format. + return nil, fmt.Errorf("unexpected members format: %s", string(data)) + } + return members, nil +} + +// GetServerInfo returns server info. +func (c *Client) GetServerInfo() (*ServerInfo, error) { + data, err := c.do("GET", "/api/v1/server", nil) + if err != nil { + return nil, err + } + var info ServerInfo + if err := json.Unmarshal(data, &info); err != nil { + return nil, err + } + return &info, nil +} diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go new file mode 100644 index 0000000..1655d79 --- /dev/null +++ b/cmd/chat-cli/api/types.go @@ -0,0 +1,83 @@ +package api + +import "time" + +// SessionRequest is the body for POST /api/v1/session. +type SessionRequest struct { + Nick string `json:"nick"` +} + +// SessionResponse is the response from POST /api/v1/session. +type SessionResponse struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Nick string `json:"nick"` + Token string `json:"token"` +} + +// StateResponse is the response from GET /api/v1/state. +type StateResponse struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Nick string `json:"nick"` + Channels []string `json:"channels"` +} + +// Message represents a chat message envelope. +type Message struct { + Command string `json:"command"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Params []string `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` + ID string `json:"id,omitempty"` + TS string `json:"ts,omitempty"` + Meta interface{} `json:"meta,omitempty"` +} + +// BodyLines returns the body as a slice of strings (for text messages). +func (m *Message) BodyLines() []string { + switch v := m.Body.(type) { + case []interface{}: + lines := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + return lines + case []string: + return v + default: + return nil + } +} + +// Channel represents a channel in the list response. +type Channel struct { + Name string `json:"name"` + Topic string `json:"topic"` + Members int `json:"members"` + CreatedAt string `json:"created_at"` +} + +// ServerInfo is the response from GET /api/v1/server. +type ServerInfo struct { + Name string `json:"name"` + MOTD string `json:"motd"` + Version string `json:"version"` +} + +// MessagesResponse wraps polling results. +type MessagesResponse struct { + Messages []Message `json:"messages"` +} + +// ParseTS parses the message timestamp. +func (m *Message) ParseTS() time.Time { + t, err := time.Parse(time.RFC3339Nano, m.TS) + if err != nil { + return time.Now() + } + return t +} diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go new file mode 100644 index 0000000..6dfa1f3 --- /dev/null +++ b/cmd/chat-cli/main.go @@ -0,0 +1,580 @@ +package main + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + "git.eeqj.de/sneak/chat/cmd/chat-cli/api" +) + +// App holds the application state. +type App struct { + ui *UI + client *api.Client + + mu sync.Mutex + nick string + target string // current target (#channel or nick for DM) + connected bool + lastMsgID string + stopPoll chan struct{} +} + +func main() { + app := &App{ + ui: NewUI(), + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + app.ui.SetStatus(app.nick, "", "disconnected") + + app.ui.AddStatus("Welcome to chat-cli — an IRC-style client") + app.ui.AddStatus("Type [yellow]/connect [white] to begin, or [yellow]/help[white] for commands") + + if err := app.ui.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func (a *App) handleInput(text string) { + if strings.HasPrefix(text, "/") { + a.handleCommand(text) + return + } + + // Plain text → PRIVMSG to current target. + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected. Use /connect ") + return + } + if target == "" { + a.ui.AddStatus("[red]No target. Use /join #channel or /query nick") + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "PRIVMSG", + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err)) + return + } + + // Echo locally. + ts := time.Now().Format("15:04") + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) +} + +func (a *App) handleCommand(text string) { + parts := strings.SplitN(text, " ", 2) + cmd := strings.ToLower(parts[0]) + args := "" + if len(parts) > 1 { + args = parts[1] + } + + switch cmd { + case "/connect": + a.cmdConnect(args) + case "/nick": + a.cmdNick(args) + case "/join": + a.cmdJoin(args) + case "/part": + a.cmdPart(args) + case "/msg": + a.cmdMsg(args) + case "/query": + a.cmdQuery(args) + case "/topic": + a.cmdTopic(args) + case "/names": + a.cmdNames() + case "/list": + a.cmdList() + case "/window", "/w": + a.cmdWindow(args) + case "/quit": + a.cmdQuit() + case "/help": + a.cmdHelp() + default: + a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd)) + } +} + +func (a *App) cmdConnect(serverURL string) { + if serverURL == "" { + a.ui.AddStatus("[red]Usage: /connect ") + return + } + serverURL = strings.TrimRight(serverURL, "/") + + a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL)) + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + client := api.NewClient(serverURL) + resp, err := client.CreateSession(nick) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err)) + return + } + + a.mu.Lock() + a.client = client + a.nick = resp.Nick + a.connected = true + a.lastMsgID = "" + a.mu.Unlock() + + a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID)) + a.ui.SetStatus(resp.Nick, "", "connected") + + // Start polling. + a.stopPoll = make(chan struct{}) + go a.pollLoop() +} + +func (a *App) cmdNick(nick string) { + if nick == "" { + a.ui.AddStatus("[red]Usage: /nick ") + return + } + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.mu.Lock() + a.nick = nick + a.mu.Unlock() + a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick)) + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "NICK", + Body: []string{nick}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err)) + return + } + + a.mu.Lock() + a.nick = nick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(nick, target, "connected") + a.ui.AddStatus(fmt.Sprintf("Nick changed to %s", nick)) +} + +func (a *App) cmdJoin(channel string) { + if channel == "" { + a.ui.AddStatus("[red]Usage: /join #channel") + return + } + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.JoinChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err)) + return + } + + a.mu.Lock() + a.target = channel + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(channel) + a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Joined %s", channel)) + a.ui.SetStatus(nick, channel, "connected") +} + +func (a *App) cmdPart(channel string) { + a.mu.Lock() + if channel == "" { + channel = a.target + } + connected := a.connected + a.mu.Unlock() + + if channel == "" || !strings.HasPrefix(channel, "#") { + a.ui.AddStatus("[red]No channel to part") + return + } + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.PartChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err)) + return + } + + a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", channel)) + + a.mu.Lock() + if a.target == channel { + a.target = "" + } + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchBuffer(0) + a.ui.SetStatus(nick, "", "connected") +} + +func (a *App) cmdMsg(args string) { + parts := strings.SplitN(args, " ", 2) + if len(parts) < 2 { + a.ui.AddStatus("[red]Usage: /msg ") + return + } + target, text := parts[0], parts[1] + + a.mu.Lock() + connected := a.connected + nick := a.nick + a.mu.Unlock() + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "PRIVMSG", + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err)) + return + } + + ts := time.Now().Format("15:04") + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) +} + +func (a *App) cmdQuery(nick string) { + if nick == "" { + a.ui.AddStatus("[red]Usage: /query ") + return + } + + a.mu.Lock() + a.target = nick + myNick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(nick) + a.ui.SetStatus(myNick, nick, "connected") +} + +func (a *App) cmdTopic(args string) { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + return + } + + if args == "" { + // Query topic. + err := a.client.SendMessage(&api.Message{ + Command: "TOPIC", + To: target, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err)) + } + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "TOPIC", + To: target, + Body: []string{args}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err)) + } +} + +func (a *App) cmdNames() { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + return + } + + members, err := a.client.GetMembers(target) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err)) + return + } + + a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " "))) +} + +func (a *App) cmdList() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + channels, err := a.client.ListChannels() + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err)) + return + } + + a.ui.AddStatus("[cyan]*** Channel list:") + for _, ch := range channels { + a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic)) + } + a.ui.AddStatus("[cyan]*** End of channel list") +} + +func (a *App) cmdWindow(args string) { + if args == "" { + a.ui.AddStatus("[red]Usage: /window ") + return + } + n := 0 + fmt.Sscanf(args, "%d", &n) + a.ui.SwitchBuffer(n) + + a.mu.Lock() + if n < a.ui.BufferCount() && n >= 0 { + // Update target to the buffer name. + // Needs to be done carefully. + } + nick := a.nick + a.mu.Unlock() + + // Update target based on buffer. + if n < a.ui.BufferCount() { + buf := a.ui.buffers[n] + if buf.Name != "(status)" { + a.mu.Lock() + a.target = buf.Name + a.mu.Unlock() + a.ui.SetStatus(nick, buf.Name, "connected") + } else { + a.ui.SetStatus(nick, "", "connected") + } + } +} + +func (a *App) cmdQuit() { + a.mu.Lock() + if a.connected && a.client != nil { + _ = a.client.SendMessage(&api.Message{Command: "QUIT"}) + } + if a.stopPoll != nil { + close(a.stopPoll) + } + a.mu.Unlock() + a.ui.Stop() +} + +func (a *App) cmdHelp() { + help := []string{ + "[cyan]*** chat-cli commands:", + " /connect — Connect to server", + " /nick — Change nickname", + " /join #channel — Join channel", + " /part [#chan] — Leave channel", + " /msg — Send DM", + " /query — Open DM window", + " /topic [text] — View/set topic", + " /names — List channel members", + " /list — List channels", + " /window — Switch buffer (Alt+0-9)", + " /quit — Disconnect and exit", + " /help — This help", + " Plain text sends to current target.", + } + for _, line := range help { + a.ui.AddStatus(line) + } +} + +// pollLoop long-polls for messages in the background. +func (a *App) pollLoop() { + for { + select { + case <-a.stopPoll: + return + default: + } + + a.mu.Lock() + client := a.client + lastID := a.lastMsgID + a.mu.Unlock() + + if client == nil { + return + } + + msgs, err := client.PollMessages(lastID, 15) + if err != nil { + // Transient error — retry after delay. + time.Sleep(2 * time.Second) + continue + } + + for _, msg := range msgs { + a.handleServerMessage(&msg) + if msg.ID != "" { + a.mu.Lock() + a.lastMsgID = msg.ID + a.mu.Unlock() + } + } + } +} + +func (a *App) handleServerMessage(msg *api.Message) { + ts := "" + if msg.TS != "" { + t := msg.ParseTS() + ts = t.Local().Format("15:04") + } else { + ts = time.Now().Format("15:04") + } + + a.mu.Lock() + myNick := a.nick + a.mu.Unlock() + + switch msg.Command { + case "PRIVMSG": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if msg.From == myNick { + // Skip our own echoed messages (already displayed locally). + return + } + target := msg.To + if !strings.HasPrefix(target, "#") { + // DM — use sender's nick as buffer name. + target = msg.From + } + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) + + case "JOIN": + target := msg.To + if target != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target)) + } + + case "PART": + target := msg.To + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + if target != "" { + if reason != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason)) + } else { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target)) + } + } + + case "QUIT": + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + if reason != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) + } else { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From)) + } + + case "NICK": + lines := msg.BodyLines() + newNick := "" + if len(lines) > 0 { + newNick = lines[0] + } + if msg.From == myNick && newNick != "" { + a.mu.Lock() + a.nick = newNick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(newNick, target, "connected") + } + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) + + case "NOTICE": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) + + case "TOPIC": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) + } + + default: + // Numeric replies and other messages → status window. + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if text != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) + } + } +} diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go new file mode 100644 index 0000000..16449f2 --- /dev/null +++ b/cmd/chat-cli/ui.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// Buffer holds messages for a channel/DM/status window. +type Buffer struct { + Name string + Lines []string + Unread int +} + +// UI manages the terminal interface. +type UI struct { + app *tview.Application + messages *tview.TextView + statusBar *tview.TextView + input *tview.InputField + layout *tview.Flex + + buffers []*Buffer + currentBuffer int + + onInput func(string) +} + +// NewUI creates the tview-based IRC-like UI. +func NewUI() *UI { + ui := &UI{ + app: tview.NewApplication(), + buffers: []*Buffer{ + {Name: "(status)", Lines: nil}, + }, + } + + // Message area. + ui.messages = tview.NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetWordWrap(true). + SetChangedFunc(func() { + ui.app.Draw() + }) + ui.messages.SetBorder(false) + + // Status bar. + ui.statusBar = tview.NewTextView(). + SetDynamicColors(true) + ui.statusBar.SetBackgroundColor(tcell.ColorNavy) + ui.statusBar.SetTextColor(tcell.ColorWhite) + + // Input field. + ui.input = tview.NewInputField(). + SetFieldBackgroundColor(tcell.ColorBlack). + SetFieldTextColor(tcell.ColorWhite) + ui.input.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + text := ui.input.GetText() + if text == "" { + return + } + ui.input.SetText("") + if ui.onInput != nil { + ui.onInput(text) + } + } + }) + + // Capture Alt+N for window switching. + ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Modifiers()&tcell.ModAlt != 0 { + r := event.Rune() + if r >= '0' && r <= '9' { + idx := int(r - '0') + ui.SwitchBuffer(idx) + return nil + } + } + return event + }) + + // Layout: messages on top, status bar, input at bottom. + ui.layout = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ui.messages, 0, 1, false). + AddItem(ui.statusBar, 1, 0, false). + AddItem(ui.input, 1, 0, true) + + ui.app.SetRoot(ui.layout, true) + ui.app.SetFocus(ui.input) + + return ui +} + +// Run starts the UI event loop (blocks). +func (ui *UI) Run() error { + return ui.app.Run() +} + +// Stop stops the UI. +func (ui *UI) Stop() { + ui.app.Stop() +} + +// OnInput sets the callback for user input. +func (ui *UI) OnInput(fn func(string)) { + ui.onInput = fn +} + +// AddLine adds a line to the specified buffer. +func (ui *UI) AddLine(bufferName string, line string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(bufferName) + buf.Lines = append(buf.Lines, line) + + // Mark unread if not currently viewing this buffer. + if ui.buffers[ui.currentBuffer] != buf { + buf.Unread++ + ui.refreshStatus() + } + + // If viewing this buffer, append to display. + if ui.buffers[ui.currentBuffer] == buf { + fmt.Fprintln(ui.messages, line) + } + }) +} + +// AddStatus adds a line to the status buffer (buffer 0). +func (ui *UI) AddStatus(line string) { + ts := time.Now().Format("15:04") + ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line)) +} + +// SwitchBuffer switches to the buffer at index n. +func (ui *UI) SwitchBuffer(n int) { + ui.app.QueueUpdateDraw(func() { + if n < 0 || n >= len(ui.buffers) { + return + } + ui.currentBuffer = n + buf := ui.buffers[n] + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { + fmt.Fprintln(ui.messages, line) + } + ui.messages.ScrollToEnd() + ui.refreshStatus() + }) +} + +// SwitchToBuffer switches to the named buffer, creating it if needed. +func (ui *UI) SwitchToBuffer(name string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(name) + for i, b := range ui.buffers { + if b == buf { + ui.currentBuffer = i + break + } + } + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { + fmt.Fprintln(ui.messages, line) + } + ui.messages.ScrollToEnd() + ui.refreshStatus() + }) +} + +// SetStatus updates the status bar text. +func (ui *UI) SetStatus(nick, target, connStatus string) { + ui.app.QueueUpdateDraw(func() { + ui.refreshStatusWith(nick, target, connStatus) + }) +} + +func (ui *UI) refreshStatus() { + // Will be called from the main goroutine via QueueUpdateDraw parent. + // Rebuild status from app state — caller must provide context. +} + +func (ui *UI) refreshStatusWith(nick, target, connStatus string) { + var unreadParts []string + for i, buf := range ui.buffers { + if buf.Unread > 0 { + unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread)) + } + } + unread := "" + if len(unreadParts) > 0 { + unread = " [Act: " + strings.Join(unreadParts, ",") + "]" + } + + bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name) + + ui.statusBar.Clear() + fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s", + connStatus, nick, bufInfo, target, unread) +} + +func (ui *UI) getOrCreateBuffer(name string) *Buffer { + for _, buf := range ui.buffers { + if buf.Name == name { + return buf + } + } + buf := &Buffer{Name: name} + ui.buffers = append(ui.buffers, buf) + return buf +} + +// BufferCount returns the number of buffers. +func (ui *UI) BufferCount() int { + return len(ui.buffers) +} + +// BufferIndex returns the index of a named buffer, or -1. +func (ui *UI) BufferIndex(name string) int { + for i, buf := range ui.buffers { + if buf.Name == name { + return i + } + } + return -1 +} diff --git a/go.mod b/go.mod index 68e8109..b1b1166 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,11 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.13.8 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -30,6 +33,8 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/tview v0.42.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -42,8 +47,9 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.8 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 4d55450..7d3b111 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= +github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= @@ -40,6 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -64,6 +70,10 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -86,6 +96,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= @@ -100,19 +111,55 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 1092ed2..7820a6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,7 +27,7 @@ type Config struct { Debug bool MaintenanceMode bool MetricsPassword string - MetricsUsername string + MetricsUsername string Port int SentryDSN string MaxHistory int @@ -80,7 +80,7 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) { Port: viper.GetInt("PORT"), SentryDSN: viper.GetString("SENTRY_DSN"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), - MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), MaxHistory: viper.GetInt("MAX_HISTORY"), SessionTimeout: viper.GetInt("SESSION_TIMEOUT"), diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..974f4db --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,414 @@ +package db + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +func generateToken() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// CreateUser registers a new user with the given nick and returns the user with token. +func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) { + token := generateToken() + now := time.Now() + res, err := s.db.ExecContext(ctx, + "INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)", + nick, token, now, now) + if err != nil { + return 0, "", fmt.Errorf("create user: %w", err) + } + id, _ := res.LastInsertId() + return id, token, nil +} + +// GetUserByToken returns user id and nick for a given auth token. +func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) { + var id int64 + var nick string + err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick) + if err != nil { + return 0, "", err + } + // Update last_seen + _, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id) + return id, nick, nil +} + +// GetUserByNick returns user id for a given nick. +func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id) + return id, err +} + +// GetOrCreateChannel returns the channel id, creating it if needed. +func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id) + if err == nil { + return id, nil + } + now := time.Now() + res, err := s.db.ExecContext(ctx, + "INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)", + name, now, now) + if err != nil { + return 0, fmt.Errorf("create channel: %w", err) + } + id, _ = res.LastInsertId() + return id, nil +} + +// JoinChannel adds a user to a channel. +func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error { + _, err := s.db.ExecContext(ctx, + "INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)", + channelID, userID, time.Now()) + return err +} + +// PartChannel removes a user from a channel. +func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error { + _, err := s.db.ExecContext(ctx, + "DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?", + channelID, userID) + return err +} + +// ListChannels returns all channels the user has joined. +func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT c.id, c.name, c.topic FROM channels c + INNER JOIN channel_members cm ON cm.channel_id = c.id + WHERE cm.user_id = ? ORDER BY c.name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var channels []ChannelInfo + for rows.Next() { + var ch ChannelInfo + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + return nil, err + } + channels = append(channels, ch) + } + if channels == nil { + channels = []ChannelInfo{} + } + return channels, nil +} + +// ChannelInfo is a lightweight channel representation. +type ChannelInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` +} + +// ChannelMembers returns all members of a channel. +func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT u.id, u.nick, u.last_seen FROM users u + INNER JOIN channel_members cm ON cm.user_id = u.id + WHERE cm.channel_id = ? ORDER BY u.nick`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + var members []MemberInfo + for rows.Next() { + var m MemberInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil { + return nil, err + } + members = append(members, m) + } + if members == nil { + members = []MemberInfo{} + } + return members, nil +} + +// MemberInfo represents a channel member. +type MemberInfo struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + LastSeen time.Time `json:"lastSeen"` +} + +// MessageInfo represents a chat message. +type MessageInfo struct { + ID int64 `json:"id"` + Channel string `json:"channel,omitempty"` + Nick string `json:"nick"` + Content string `json:"content"` + IsDM bool `json:"isDm,omitempty"` + DMTarget string `json:"dmTarget,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// GetMessages returns messages for a channel, optionally after a given ID. +func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ? + ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// SendMessage inserts a channel message. +func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) { + res, err := s.db.ExecContext(ctx, + "INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)", + channelID, userID, content, time.Now()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// SendDM inserts a direct message. +func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) { + res, err := s.db.ExecContext(ctx, + "INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)", + fromID, content, toID, time.Now()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetDMs returns direct messages between two users after a given ID. +func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 AND m.id > ? + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = true + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// PollMessages returns all new messages (channel + DM) for a user after a given ID. +func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, m.is_dm, COALESCE(t.nick, ''), m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + LEFT JOIN channels c ON c.id = m.channel_id + LEFT JOIN users t ON t.id = m.dm_target_id + WHERE m.id > ? AND ( + (m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?)) + OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?)) + ) + ORDER BY m.id ASC LIMIT ?`, afterID, userID, userID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + var isDM int + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = isDM == 1 + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// GetMessagesBefore returns channel messages before a given ID (for history scrollback). +func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + var query string + var args []any + if beforeID > 0 { + query = `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id < ? + ORDER BY m.id DESC LIMIT ?` + args = []any{channelID, beforeID, limit} + } else { + query = `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 + ORDER BY m.id DESC LIMIT ?` + args = []any{channelID, limit} + } + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + // Reverse to ascending order + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + return msgs, nil +} + +// GetDMsBefore returns DMs between two users before a given ID (for history scrollback). +func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + var query string + var args []any + if beforeID > 0 { + query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 AND m.id < ? + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id DESC LIMIT ?` + args = []any{beforeID, userA, userB, userB, userA, limit} + } else { + query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id DESC LIMIT ?` + args = []any{userA, userB, userB, userA, limit} + } + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = true + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + // Reverse to ascending order + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + return msgs, nil +} + +// ChangeNick updates a user's nickname. +func (s *Database) ChangeNick(ctx context.Context, userID int64, newNick string) error { + _, err := s.db.ExecContext(ctx, + "UPDATE users SET nick = ? WHERE id = ?", newNick, userID) + return err +} + +// SetTopic sets the topic for a channel. +func (s *Database) SetTopic(ctx context.Context, channelName string, _ int64, topic string) error { + _, err := s.db.ExecContext(ctx, + "UPDATE channels SET topic = ? WHERE name = ?", topic, channelName) + return err +} + +// GetServerName returns the server name (unused, config provides this). +func (s *Database) GetServerName() string { + return "" +} + +// ListAllChannels returns all channels. +func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) { + rows, err := s.db.QueryContext(ctx, + "SELECT id, name, topic FROM channels ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + var channels []ChannelInfo + for rows.Next() { + var ch ChannelInfo + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + return nil, err + } + channels = append(channels, ch) + } + if channels == nil { + channels = []ChannelInfo{} + } + return channels, nil +} diff --git a/internal/db/schema/003_users.sql b/internal/db/schema/003_users.sql new file mode 100644 index 0000000..f305aa0 --- /dev/null +++ b/internal/db/schema/003_users.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nick TEXT NOT NULL UNIQUE, + token TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS channel_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(channel_id, user_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + is_dm INTEGER NOT NULL DEFAULT 0, + dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at); +CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..975b7a1 --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,400 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + + "git.eeqj.de/sneak/chat/internal/db" + "github.com/go-chi/chi" +) + +// authUser extracts the user from the Authorization header (Bearer token). +func (s *Handlers) authUser(r *http.Request) (int64, string, error) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return 0, "", sql.ErrNoRows + } + token := strings.TrimPrefix(auth, "Bearer ") + return s.params.Database.GetUserByToken(r.Context(), token) +} + +func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, string, bool) { + uid, nick, err := s.authUser(r) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) + return 0, "", false + } + return uid, nick, true +} + +// HandleCreateSession creates a new user session and returns the auth token. +func (s *Handlers) HandleCreateSession() http.HandlerFunc { + type request struct { + Nick string `json:"nick"` + } + type response struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + Token string `json:"token"` + } + return func(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + req.Nick = strings.TrimSpace(req.Nick) + if req.Nick == "" || len(req.Nick) > 32 { + s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return + } + id, token, err := s.params.Database.CreateUser(r.Context(), req.Nick) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict) + return + } + s.log.Error("create user failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, &response{ID: id, Nick: req.Nick, Token: token}, http.StatusCreated) + } +} + +// HandleState returns the current user's info and joined channels. +func (s *Handlers) HandleState() http.HandlerFunc { + type response struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + Channels []db.ChannelInfo `json:"channels"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, nick, ok := s.requireAuth(w, r) + if !ok { + return + } + channels, err := s.params.Database.ListChannels(r.Context(), uid) + if err != nil { + s.log.Error("list channels failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, &response{ID: uid, Nick: nick, Channels: channels}, http.StatusOK) + } +} + +// HandleListAllChannels returns all channels on the server. +func (s *Handlers) HandleListAllChannels() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + channels, err := s.params.Database.ListAllChannels(r.Context()) + if err != nil { + s.log.Error("list all channels failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, channels, http.StatusOK) + } +} + +// HandleChannelMembers returns members of a channel. +func (s *Handlers) HandleChannelMembers() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + members, err := s.params.Database.ChannelMembers(r.Context(), chID) + if err != nil { + s.log.Error("channel members failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, members, http.StatusOK) + } +} + +// HandleGetMessages returns all new messages (channel + DM) for the user via long-polling. +// This is the single unified message stream — replaces separate channel/DM/poll endpoints. +func (s *Handlers) HandleGetMessages() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit) + if err != nil { + s.log.Error("get messages failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandleSendCommand handles all C2S commands via POST /messages. +// The "command" field dispatches to the appropriate logic. +func (s *Handlers) HandleSendCommand() http.HandlerFunc { + type request struct { + Command string `json:"command"` + To string `json:"to"` + Params []string `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, nick, ok := s.requireAuth(w, r) + if !ok { + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + req.Command = strings.ToUpper(strings.TrimSpace(req.Command)) + req.To = strings.TrimSpace(req.To) + + // Helper to extract body as string lines. + bodyLines := func() []string { + switch v := req.Body.(type) { + case []interface{}: + lines := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + return lines + case []string: + return v + default: + return nil + } + } + + switch req.Command { + case "PRIVMSG", "NOTICE": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + lines := bodyLines() + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required"}, http.StatusBadRequest) + return + } + content := strings.Join(lines, "\n") + + if strings.HasPrefix(req.To, "#") { + // Channel message + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", req.To).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } else { + // DM + targetID, err := s.params.Database.GetUserByNick(r.Context(), req.To) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } + + case "JOIN": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + channel := req.To + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + chID, err := s.params.Database.GetOrCreateChannel(r.Context(), channel) + if err != nil { + s.log.Error("get/create channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { + s.log.Error("join channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "joined", "channel": channel}, http.StatusOK) + + case "PART": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + channel := req.To + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { + s.log.Error("part channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "parted", "channel": channel}, http.StatusOK) + + case "NICK": + lines := bodyLines() + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required (new nick)"}, http.StatusBadRequest) + return + } + newNick := strings.TrimSpace(lines[0]) + if newNick == "" || len(newNick) > 32 { + s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return + } + if err := s.params.Database.ChangeNick(r.Context(), uid, newNick); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.respondJSON(w, r, map[string]string{"error": "nick already in use"}, http.StatusConflict) + return + } + s.log.Error("change nick failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "ok", "nick": newNick}, http.StatusOK) + + case "TOPIC": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + lines := bodyLines() + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest) + return + } + topic := strings.Join(lines, " ") + channel := req.To + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + if err := s.params.Database.SetTopic(r.Context(), channel, uid, topic); err != nil { + s.log.Error("set topic failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "ok", "topic": topic}, http.StatusOK) + + case "PING": + s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) + + default: + _ = nick // suppress unused warning + s.respondJSON(w, r, map[string]string{"error": "unknown command: " + req.Command}, http.StatusBadRequest) + } + } +} + +// HandleGetHistory returns message history for a specific target (channel or DM). +func (s *Handlers) HandleGetHistory() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + target := r.URL.Query().Get("target") + if target == "" { + s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest) + return + } + beforeID, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 50 + } + + if strings.HasPrefix(target, "#") { + // Channel history + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", target).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeID, limit) + if err != nil { + s.log.Error("get history failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } else { + // DM history + targetID, err := s.params.Database.GetUserByNick(r.Context(), target) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetID, beforeID, limit) + if err != nil { + s.log.Error("get dm history failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } + } +} + +// HandleServerInfo returns server metadata (MOTD, name). +func (s *Handlers) HandleServerInfo() http.HandlerFunc { + type response struct { + Name string `json:"name"` + MOTD string `json:"motd"` + } + return func(w http.ResponseWriter, r *http.Request) { + s.respondJSON(w, r, &response{ + Name: s.params.Config.ServerName, + MOTD: s.params.Config.MOTD, + }, http.StatusOK) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8d0816b..11b8942 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" + "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/chat/internal/healthcheck" @@ -20,6 +21,7 @@ type Params struct { Logger *logger.Logger Globals *globals.Globals + Config *config.Config Database *db.Database Healthcheck *healthcheck.Healthcheck } diff --git a/internal/server/routes.go b/internal/server/routes.go index 313f186..e211492 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,9 +1,12 @@ package server import ( + "io/fs" "net/http" "time" + "git.eeqj.de/sneak/chat/web" + sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" @@ -45,4 +48,40 @@ func (s *Server) SetupRoutes() { r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) }) } + + // API v1 + s.router.Route("/api/v1", func(r chi.Router) { + r.Get("/server", s.h.HandleServerInfo()) + r.Post("/session", s.h.HandleCreateSession()) + + // Unified state and message endpoints + r.Get("/state", s.h.HandleState()) + r.Get("/messages", s.h.HandleGetMessages()) + r.Post("/messages", s.h.HandleSendCommand()) + r.Get("/history", s.h.HandleGetHistory()) + + // Channels + r.Get("/channels", s.h.HandleListAllChannels()) + r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) + }) + + // Serve embedded SPA + distFS, err := fs.Sub(web.Dist, "dist") + if err != nil { + s.log.Error("failed to get web dist filesystem", "error", err) + } else { + fileServer := http.FileServer(http.FS(distFS)) + s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + // Try to serve the file; if not found, serve index.html for SPA routing + f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:]) + if err != nil || len(f) == 0 { + indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(indexHTML) + return + } + fileServer.ServeHTTP(w, r) + }) + } } diff --git a/schema/README.md b/schema/README.md new file mode 100644 index 0000000..250010b --- /dev/null +++ b/schema/README.md @@ -0,0 +1,97 @@ +# Message Schemas + +JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use +**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON +over HTTP. + +## Envelope + +Every message is a JSON object with a `command` field. The format maps directly +to IRC wire format: + +``` +IRC: :nick PRIVMSG #channel :hello world +JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} + +IRC: :server 353 nick = #channel :user1 @op1 +voice1 +JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]} + +Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]} +Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}} +``` + +Common fields (see `message.json` for full schema): + +| Field | Type | Description | +|-----------|----------------|------------------------------------------------------| +| `id` | string (uuid) | Server-assigned message UUID | +| `command` | string | IRC command or 3-digit numeric code | +| `from` | string | Source nick or server name (IRC prefix) | +| `to` | string | Target: #channel or nick | +| `params` | string[] | Middle parameters (mainly for numerics) | +| `body` | array \| object | Structured body — never a raw string (see below) | +| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | +| `meta` | object | Extensible metadata (signatures, hashes, etc.) | + +**Structured bodies:** `body` is always an array of strings (for text) or an +object (for structured data like PUBKEY). Never a raw string. This enables: +- Multiline messages without escape sequences +- Deterministic canonicalization via RFC 8785 JCS for signing +- Structured data where needed + +## Commands + +IRC commands used for client↔server and server↔server communication. + +| Command | File | RFC | Description | +|-----------|---------------------------|-----------|--------------------------------| +| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user | +| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) | +| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel | +| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel | +| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected | +| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname | +| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic | +| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes | +| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel | +| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive | +| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response | +| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key | + +## Numeric Replies + +Three-digit codes for server responses, per IRC convention. + +### Success / Informational (0xx–3xx) + +| Code | Name | File | Description | +|-------|-------------------|-----------------------|--------------------------------| +| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | +| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | +| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | +| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | +| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | +| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | +| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | +| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | +| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | +| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | +| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | +| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | + +### Errors (4xx) + +| Code | Name | File | Description | +|-------|----------------------|-----------------------|--------------------------------| +| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel | +| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel | +| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use | +| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel | +| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator | + +## Federation (S2S) + +Server-to-server messages use the same command format. Federated servers relay +messages with an additional `origin` field in `meta` to track the source server. +The PING/PONG commands serve as inter-server keepalives. State sync after link +establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands. diff --git a/schema/commands/JOIN.json b/schema/commands/JOIN.json new file mode 100644 index 0000000..ca7f6c4 --- /dev/null +++ b/schema/commands/JOIN.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json", + "title": "JOIN", + "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "JOIN" }, + "from": { "type": "string", "description": "Nick that joined (S2C)." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" } + }, + "required": ["command", "to"], + "examples": [ + { "command": "JOIN", "from": "alice", "to": "#general" } + ] +} diff --git a/schema/commands/KICK.json b/schema/commands/KICK.json new file mode 100644 index 0000000..25af8a8 --- /dev/null +++ b/schema/commands/KICK.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json", + "title": "KICK", + "description": "Kick a user from a channel. RFC 1459 §4.2.8.", + "$ref": "../message.json", + "properties": { + "command": { "const": "KICK" }, + "from": { "type": "string", "description": "Nick that performed the kick." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Kicked nick. e.g. [\"alice\"].", + "minItems": 1, + "maxItems": 1 + }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional kick reason.", + "maxItems": 1 + } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] } + ] +} diff --git a/schema/commands/MODE.json b/schema/commands/MODE.json new file mode 100644 index 0000000..e2da7f0 --- /dev/null +++ b/schema/commands/MODE.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json", + "title": "MODE", + "description": "Set or query channel/user modes. RFC 1459 §4.2.3.", + "$ref": "../message.json", + "properties": { + "command": { "const": "MODE" }, + "from": { + "type": "string", + "description": "Nick that set the mode (S2C only)." + }, + "to": { + "type": "string", + "description": "Channel name.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].", + "examples": [["+o", "alice"], ["-m"], ["+v", "bob"]] + } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] } + ] +} diff --git a/schema/commands/NICK.json b/schema/commands/NICK.json new file mode 100644 index 0000000..e6e9bef --- /dev/null +++ b/schema/commands/NICK.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json", + "title": "NICK", + "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "NICK" }, + "from": { "type": "string", "description": "Old nick (S2C)." }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "New nick (single-element array).", + "minItems": 1, + "maxItems": 1 + } + }, + "required": ["command", "body"], + "examples": [ + { "command": "NICK", "from": "oldnick", "body": ["newnick"] } + ] +} diff --git a/schema/commands/NOTICE.json b/schema/commands/NOTICE.json new file mode 100644 index 0000000..092e825 --- /dev/null +++ b/schema/commands/NOTICE.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json", + "title": "NOTICE", + "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "NOTICE" }, + "from": { "type": "string" }, + "to": { "type": "string", "description": "Target: #channel, nick, or * (global)." }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Notice text lines." + } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] } + ] +} diff --git a/schema/commands/PART.json b/schema/commands/PART.json new file mode 100644 index 0000000..a2bd9ca --- /dev/null +++ b/schema/commands/PART.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json", + "title": "PART", + "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PART" }, + "from": { "type": "string", "description": "Nick that left (S2C)." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional part reason.", + "maxItems": 1 + } + }, + "required": ["command", "to"], + "examples": [ + { "command": "PART", "from": "alice", "to": "#general", "body": ["later"] } + ] +} diff --git a/schema/commands/PING.json b/schema/commands/PING.json new file mode 100644 index 0000000..b911dc8 --- /dev/null +++ b/schema/commands/PING.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json", + "title": "PING", + "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PING" }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Opaque token to be echoed in PONG (single-element array).", + "maxItems": 1 + } + }, + "required": ["command"], + "examples": [ + { "command": "PING", "body": ["1707580000"] } + ] +} diff --git a/schema/commands/PONG.json b/schema/commands/PONG.json new file mode 100644 index 0000000..6e04620 --- /dev/null +++ b/schema/commands/PONG.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json", + "title": "PONG", + "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PONG" }, + "from": { "type": "string", "description": "Responding server name." }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Echoed token from PING (single-element array).", + "maxItems": 1 + } + }, + "required": ["command"], + "examples": [ + { "command": "PONG", "from": "server.example.com", "body": ["1707580000"] } + ] +} diff --git a/schema/commands/PRIVMSG.json b/schema/commands/PRIVMSG.json new file mode 100644 index 0000000..9204705 --- /dev/null +++ b/schema/commands/PRIVMSG.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json", + "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.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PRIVMSG" }, + "from": { "type": "string", "description": "Sender nick (set by server on relay)." }, + "to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Message lines. One string per line.", + "minItems": 1 + } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] }, + { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] }, + { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } } + ] +} diff --git a/schema/commands/PUBKEY.json b/schema/commands/PUBKEY.json new file mode 100644 index 0000000..90fdc30 --- /dev/null +++ b/schema/commands/PUBKEY.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json", + "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.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PUBKEY" }, + "from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." }, + "to": { + "type": "string", + "description": "Target: #channel to announce to channel members, or omit for server-wide announcement." + }, + "body": { + "type": "object", + "description": "Key material.", + "properties": { + "alg": { + "type": "string", + "description": "Key algorithm.", + "enum": ["ed25519"] + }, + "key": { + "type": "string", + "description": "Base64-encoded public key." + } + }, + "required": ["alg", "key"], + "additionalProperties": false + } + }, + "required": ["command", "body"], + "examples": [ + { "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }, + { "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } } + ] +} diff --git a/schema/commands/QUIT.json b/schema/commands/QUIT.json new file mode 100644 index 0000000..cfb3a45 --- /dev/null +++ b/schema/commands/QUIT.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json", + "title": "QUIT", + "description": "User disconnected. S2C only. RFC 1459 §4.1.6.", + "$ref": "../message.json", + "properties": { + "command": { "const": "QUIT" }, + "from": { "type": "string", "description": "Nick that quit." }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional quit reason.", + "maxItems": 1 + } + }, + "required": ["command", "from"], + "examples": [ + { "command": "QUIT", "from": "alice", "body": ["Connection reset"] } + ] +} diff --git a/schema/commands/TOPIC.json b/schema/commands/TOPIC.json new file mode 100644 index 0000000..8ba1365 --- /dev/null +++ b/schema/commands/TOPIC.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json", + "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.", + "$ref": "../message.json", + "properties": { + "command": { "const": "TOPIC" }, + "from": { "type": "string", "description": "Nick that changed the topic (S2C)." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "New topic text (single-element array). Empty array clears the topic.", + "maxItems": 1 + } + }, + "required": ["command", "to"], + "examples": [ + { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] } + ] +} diff --git a/schema/message.json b/schema/message.json new file mode 100644 index 0000000..875a492 --- /dev/null +++ b/schema/message.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/message.json", + "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.).", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Server-assigned message UUID. Present on all server-originated messages." + }, + "command": { + "type": "string", + "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).", + "examples": ["PRIVMSG", "JOIN", "001", "353", "433"] + }, + "from": { + "type": "string", + "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix." + }, + "to": { + "type": "string", + "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands." + }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." + }, + "body": { + "oneOf": [ + { + "type": "array", + "items": { "type": "string" }, + "description": "Array of strings (one per line for text messages)." + }, + { + "type": "object", + "description": "Structured data (e.g. PUBKEY key material).", + "additionalProperties": true + } + ], + "description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing." + }, + "ts": { + "type": "string", + "format": "date-time", + "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport." + }, + "meta": { + "type": "object", + "description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.", + "properties": { + "sig": { + "type": "string", + "description": "Base64-encoded cryptographic signature over the canonical message form." + }, + "alg": { + "type": "string", + "description": "Signature algorithm (e.g. 'ed25519')." + }, + "hash": { + "type": "string", + "description": "Hash of the canonical message form (e.g. 'sha256:base64...')." + } + }, + "additionalProperties": true + } + }, + "required": ["command"] +} diff --git a/schema/numerics/001.json b/schema/numerics/001.json new file mode 100644 index 0000000..feb2ba0 --- /dev/null +++ b/schema/numerics/001.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json", + "title": "001 RPL_WELCOME", + "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "001" + }, + "to": { + "type": "string", + "description": "Target nick." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Welcome text lines." + } + }, + "required": [ + "command", + "to", + "body" + ], + "examples": [ + { + "command": "001", + "to": "alice", + "body": [ + "Welcome to the network, alice" + ] + } + ] +} diff --git a/schema/numerics/002.json b/schema/numerics/002.json new file mode 100644 index 0000000..13fc73c --- /dev/null +++ b/schema/numerics/002.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json", + "title": "002 RPL_YOURHOST", + "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "002" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Host info lines." + } + }, + "required": [ + "command", + "to", + "body" + ], + "examples": [ + { + "command": "002", + "to": "alice", + "body": [ + "Your host is chat.example.com, running version 0.1.0" + ] + } + ] +} diff --git a/schema/numerics/003.json b/schema/numerics/003.json new file mode 100644 index 0000000..d448a8c --- /dev/null +++ b/schema/numerics/003.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json", + "title": "003 RPL_CREATED", + "description": "Server creation date. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "003" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines." + } + }, + "required": [ + "command", + "to", + "body" + ], + "examples": [ + { + "command": "003", + "to": "alice", + "body": [ + "This server was created 2026-02-01" + ] + } + ] +} diff --git a/schema/numerics/004.json b/schema/numerics/004.json new file mode 100644 index 0000000..2aaf116 --- /dev/null +++ b/schema/numerics/004.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json", + "title": "004 RPL_MYINFO", + "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "004" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[server_name, version, user_modes, channel_modes]." + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "004", + "to": "alice", + "params": [ + "chat.example.com", + "0.1.0", + "o", + "imnst+ov" + ] + } + ] +} diff --git a/schema/numerics/322.json b/schema/numerics/322.json new file mode 100644 index 0000000..3f4d288 --- /dev/null +++ b/schema/numerics/322.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json", + "title": "322 RPL_LIST", + "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "322" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel, visible_count]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Channel topic." + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "322", + "to": "alice", + "params": [ + "#general", + "12" + ], + "body": [ + "General discussion" + ] + } + ] +} diff --git a/schema/numerics/323.json b/schema/numerics/323.json new file mode 100644 index 0000000..310a061 --- /dev/null +++ b/schema/numerics/323.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json", + "title": "323 RPL_LISTEND", + "description": "End of channel list. RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "323" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "End of /LIST", + "maxItems": 1 + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/numerics/332.json b/schema/numerics/332.json new file mode 100644 index 0000000..aebb9c6 --- /dev/null +++ b/schema/numerics/332.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json", + "title": "332 RPL_TOPIC", + "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "332" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Topic text." + } + }, + "required": [ + "command", + "to", + "params", + "body" + ], + "examples": [ + { + "command": "332", + "to": "alice", + "params": [ + "#general" + ], + "body": [ + "Welcome to the chat" + ] + } + ] +} diff --git a/schema/numerics/353.json b/schema/numerics/353.json new file mode 100644 index 0000000..ddf7f4e --- /dev/null +++ b/schema/numerics/353.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json", + "title": "353 RPL_NAMREPLY", + "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "353" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced." + } + }, + "required": [ + "command", + "to", + "params", + "body" + ], + "examples": [ + { + "command": "353", + "to": "alice", + "params": [ + "=", + "#general" + ], + "body": [ + "@op1 alice bob +voiced1" + ] + } + ] +} diff --git a/schema/numerics/366.json b/schema/numerics/366.json new file mode 100644 index 0000000..2b5d17a --- /dev/null +++ b/schema/numerics/366.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json", + "title": "366 RPL_ENDOFNAMES", + "description": "End of NAMES list. RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "366" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "End of /NAMES list", + "maxItems": 1 + } + }, + "required": [ + "command", + "to", + "params" + ] +} diff --git a/schema/numerics/372.json b/schema/numerics/372.json new file mode 100644 index 0000000..58ddb05 --- /dev/null +++ b/schema/numerics/372.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json", + "title": "372 RPL_MOTD", + "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "372" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "MOTD line text (prefixed with '- ')." + } + }, + "required": [ + "command", + "to", + "body" + ], + "examples": [ + { + "command": "372", + "to": "alice", + "body": [ + "- Welcome to our server!" + ] + } + ] +} diff --git a/schema/numerics/375.json b/schema/numerics/375.json new file mode 100644 index 0000000..4fbe45a --- /dev/null +++ b/schema/numerics/375.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json", + "title": "375 RPL_MOTDSTART", + "description": "Start of MOTD. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "375" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines." + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/numerics/376.json b/schema/numerics/376.json new file mode 100644 index 0000000..5082517 --- /dev/null +++ b/schema/numerics/376.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json", + "title": "376 RPL_ENDOFMOTD", + "description": "End of MOTD. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "376" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "End of /MOTD command", + "maxItems": 1 + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/numerics/401.json b/schema/numerics/401.json new file mode 100644 index 0000000..3213156 --- /dev/null +++ b/schema/numerics/401.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json", + "title": "401 ERR_NOSUCHNICK", + "description": "No such nick/channel. RFC 1459 \u00a76.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "401" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[target_nick]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "No such nick/channel", + "maxItems": 1 + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "401", + "to": "alice", + "params": [ + "bob" + ], + "body": [ + "No such nick/channel" + ] + } + ] +} diff --git a/schema/numerics/403.json b/schema/numerics/403.json new file mode 100644 index 0000000..bf06774 --- /dev/null +++ b/schema/numerics/403.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json", + "title": "403 ERR_NOSUCHCHANNEL", + "description": "No such channel. RFC 1459 \u00a76.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "403" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel_name]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "No such channel", + "maxItems": 1 + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "403", + "to": "alice", + "params": [ + "#nonexistent" + ], + "body": [ + "No such channel" + ] + } + ] +} diff --git a/schema/numerics/433.json b/schema/numerics/433.json new file mode 100644 index 0000000..1f4930e --- /dev/null +++ b/schema/numerics/433.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json", + "title": "433 ERR_NICKNAMEINUSE", + "description": "Nickname is already in use. RFC 1459 \u00a76.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "433" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[requested_nick]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Nickname is already in use", + "maxItems": 1 + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "433", + "to": "*", + "params": [ + "alice" + ], + "body": [ + "Nickname is already in use" + ] + } + ] +} diff --git a/schema/numerics/442.json b/schema/numerics/442.json new file mode 100644 index 0000000..a36518b --- /dev/null +++ b/schema/numerics/442.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json", + "title": "442 ERR_NOTONCHANNEL", + "description": "You're not on that channel. RFC 1459 \u00a76.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "442" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "You're not on that channel", + "maxItems": 1 + } + }, + "required": [ + "command", + "to", + "params" + ] +} diff --git a/schema/numerics/482.json b/schema/numerics/482.json new file mode 100644 index 0000000..fb03924 --- /dev/null +++ b/schema/numerics/482.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json", + "title": "482 ERR_CHANOPRIVSNEEDED", + "description": "You're not channel operator. RFC 1459 \u00a76.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "482" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "You're not channel operator", + "maxItems": 1 + } + }, + "required": [ + "command", + "to", + "params" + ] +} diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..655225d --- /dev/null +++ b/web/build.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" + +# Install esbuild if not present +if ! command -v esbuild >/dev/null 2>&1; then + if command -v npx >/dev/null 2>&1; then + NPX="npx" + else + echo "esbuild not found. Install it: npm install -g esbuild" + exit 1 + fi +else + NPX="" +fi + +mkdir -p dist + +# Build JS bundle +${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 \ + --bundle \ + --minify \ + --jsx-factory=h \ + --jsx-fragment=Fragment \ + --define:process.env.NODE_ENV=\"production\" \ + --outfile=dist/app.js + +# Copy static files +cp src/index.html dist/index.html +cp src/style.css dist/style.css + +echo "Build complete: web/dist/" diff --git a/web/dist/app.js b/web/dist/app.js new file mode 100644 index 0000000..2a5d789 --- /dev/null +++ b/web/dist/app.js @@ -0,0 +1 @@ +(()=>{var te,b,Ce,Ge,O,ge,Se,xe,Te,ae,ie,se,Qe,q={},Ee=[],Xe=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ne=Array.isArray;function U(e,t){for(var n in t)e[n]=t[n];return e}function le(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var _,i,o,s={};for(o in t)o=="key"?_=t[o]:o=="ref"?i=t[o]:s[o]=t[o];if(arguments.length>2&&(s.children=arguments.length>3?te.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)s[o]===void 0&&(s[o]=e.defaultProps[o]);return Y(e,s,_,i,null)}function Y(e,t,n,_,i){var o={type:e,props:t,key:n,ref:_,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++Ce,__i:-1,__u:0};return i==null&&b.vnode!=null&&b.vnode(o),o}function _e(e){return e.children}function Z(e,t){this.props=e,this.context=t}function j(e,t){if(t==null)return e.__?j(e.__,e.__i+1):null;for(var n;tl&&O.sort(xe),e=O.shift(),l=O.length,e.__d&&(n=void 0,_=void 0,i=(_=(t=e).__v).__e,o=[],s=[],t.__P&&((n=U({},_)).__v=_.__v+1,b.vnode&&b.vnode(n),ue(t.__P,n,_,t.__n,t.__P.namespaceURI,32&_.__u?[i]:null,o,i??j(_),!!(32&_.__u),s),n.__v=_.__v,n.__.__k[n.__i]=n,He(o,n,s),_.__e=_.__=null,n.__e!=i&&Pe(n)));ee.__r=0}function Ie(e,t,n,_,i,o,s,l,d,c,h){var r,u,f,k,P,w,g,y=_&&_.__k||Ee,D=t.length;for(d=Ye(n,t,y,d,D),r=0;r0?s=e.__k[o]=Y(s.type,s.props,s.key,s.ref?s.ref:null,s.__v):e.__k[o]=s,d=o+u,s.__=e,s.__b=e.__b+1,l=null,(c=s.__i=Ze(s,n,d,r))!=-1&&(r--,(l=n[c])&&(l.__u|=2)),l==null||l.__v==null?(c==-1&&(i>h?u--:id?u--:u++,s.__u|=4))):e.__k[o]=null;if(r)for(o=0;o(h?1:0)){for(i=n-1,o=n+1;i>=0||o=0?i--:o++])!=null&&(2&c.__u)==0&&l==c.key&&d==c.type)return s}return-1}function ke(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Xe.test(t)?n:n+"px"}function X(e,t,n,_,i){var o,s;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof _=="string"&&(e.style.cssText=_=""),_)for(t in _)n&&t in n||ke(e.style,t,"");if(n)for(t in n)_&&n[t]==_[t]||ke(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(Te,"$1")),s=t.toLowerCase(),t=s in e||t=="onFocusOut"||t=="onFocusIn"?s.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?_?n.u=_.u:(n.u=ae,e.addEventListener(t,o?se:ie,o)):e.removeEventListener(t,o?se:ie,o);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function we(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ae++;else if(t.t0?e:ne(e)?e.map(De):U({},e)}function et(e,t,n,_,i,o,s,l,d){var c,h,r,u,f,k,P,w=n.props||q,g=t.props,y=t.type;if(y=="svg"?i="http://www.w3.org/2000/svg":y=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),o!=null){for(c=0;c=n.__.length&&n.__.push({}),n.__[e]}function I(e){return K=1,nt(qe,e)}function nt(e,t,n){var _=he(z++,2);if(_.t=e,!_.__c&&(_.__=[n?n(t):qe(void 0,t),function(l){var d=_.__N?_.__N[0]:_.__[0],c=_.t(d,l);d!==c&&(_.__N=[c,_.__[1]],_.__c.setState({}))}],_.__c=S,!S.__f)){var i=function(l,d,c){if(!_.__c.__H)return!0;var h=_.__c.__H.__.filter(function(u){return!!u.__c});if(h.every(function(u){return!u.__N}))return!o||o.call(this,l,d,c);var r=_.__c.props!==l;return h.forEach(function(u){if(u.__N){var f=u.__[0];u.__=u.__N,u.__N=void 0,f!==u.__[0]&&(r=!0)}}),o&&o.call(this,l,d,c)||r};S.__f=!0;var o=S.shouldComponentUpdate,s=S.componentWillUpdate;S.componentWillUpdate=function(l,d,c){if(this.__e){var h=o;o=void 0,i(l,d,c),o=h}s&&s.call(this,l,d,c)},S.shouldComponentUpdate=i}return _.__N||_.__}function W(e,t){var n=he(z++,3);!x.__s&&Ve(n.__H,t)&&(n.__=e,n.u=t,S.__H.__h.push(n))}function G(e){return K=5,Be(function(){return{current:e}},[])}function Be(e,t){var n=he(z++,7);return Ve(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function oe(e,t){return K=8,Be(function(){return e},t)}function _t(){for(var e;e=Je.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(re),e.__H.__h.forEach(de),e.__H.__h=[]}catch(t){e.__H.__h=[],x.__e(t,e.__v)}}x.__b=function(e){S=null,Ue&&Ue(e)},x.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),We&&We(e,t)},x.__r=function(e){Le&&Le(e),z=0;var t=(S=e.__c).__H;t&&(pe===S?(t.__h=[],S.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(re),t.__h.forEach(de),t.__h=[],z=0)),pe=S},x.diffed=function(e){Fe&&Fe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Je.push(t)!==1&&Ae===x.requestAnimationFrame||((Ae=x.requestAnimationFrame)||rt)(_t)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),pe=S=null},x.__c=function(e,t){t.some(function(n){try{n.__h.forEach(re),n.__h=n.__h.filter(function(_){return!_.__||de(_)})}catch(_){t.some(function(i){i.__h&&(i.__h=[])}),t=[],x.__e(_,n.__v)}}),Oe&&Oe(e,t)},x.unmount=function(e){je&&je(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(_){try{re(_)}catch(i){t=i}}),n.__H=void 0,t&&x.__e(t,n.__v))};var Re=typeof requestAnimationFrame=="function";function rt(e){var t,n=function(){clearTimeout(_),Re&&cancelAnimationFrame(t),setTimeout(e)},_=setTimeout(n,35);Re&&(t=requestAnimationFrame(n))}function re(e){var t=S,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),S=t}function de(e){var t=S;e.__c=e.__(),S=t}function Ve(e,t){return!e||e.length!==t.length||t.some(function(n,_){return n!==e[_]})}function qe(e,t){return typeof t=="function"?t(e):t}var ot="/api/v1";function H(e,t={}){let n=localStorage.getItem("chat_token"),_={"Content-Type":"application/json",...t.headers||{}};return n&&(_.Authorization=`Bearer ${n}`),fetch(ot+e,{...t,headers:_}).then(async i=>{let o=await i.json().catch(()=>null);if(!i.ok)throw{status:i.status,data:o};return o})}function it(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function Ke(e){let t=0;for(let _=0;_{H("/server").then(u=>{u.name&&d(u.name),u.motd&&s(u.motd)}).catch(()=>{});let r=localStorage.getItem("chat_token");r&&H("/me").then(u=>e(u.nick,r)).catch(()=>localStorage.removeItem("chat_token")),c.current?.focus()},[]),m("div",{class:"login-screen"},m("h1",null,l),o&&m("div",{class:"motd"},o),m("form",{onSubmit:async r=>{r.preventDefault(),i("");try{let u=await H("/register",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("chat_token",u.token),e(u.nick,u.token)}catch(u){i(u.data?.error||"Connection failed")}}},m("input",{ref:c,type:"text",placeholder:"Choose a nickname...",value:t,onInput:r=>n(r.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),_&&m("div",{class:"error"},_))}function ze({msg:e}){return m("div",{class:`message ${e.system?"system":""}`},m("span",{class:"timestamp"},it(e.createdAt)),m("span",{class:"nick",style:{color:e.system?void 0:Ke(e.nick)}},e.nick),m("span",{class:"content"},e.content))}function ct(){let[e,t]=I(!1),[n,_]=I(""),[i,o]=I([{type:"server",name:"Server"}]),[s,l]=I(0),[d,c]=I({server:[]}),[h,r]=I({}),[u,f]=I(""),[k,P]=I(""),[w,g]=I(0),y=G(),D=G(),N=G(),$=oe((a,p)=>{c(v=>({...v,[a]:[...v[a]||[],p]}))},[]),E=oe((a,p)=>{$(a,{id:Date.now(),nick:"*",content:p,createdAt:new Date().toISOString(),system:!0})},[$]),Q=oe((a,p)=>{_(a),t(!0),E("server",`Connected as ${a}`),H("/server").then(v=>{v.motd&&E("server",`MOTD: ${v.motd}`)}).catch(()=>{})},[E]);W(()=>{if(!e)return;let a=!0,p=async()=>{try{let v=await H(`/poll?after=${w}`);if(!a)return;let T=w;for(let C of v)if(C.id>T&&(T=C.id),C.isDm){let B=C.nick===n?C.dmTarget:C.nick;o(V=>V.find(ye=>ye.type==="dm"&&ye.name===B)?V:[...V,{type:"dm",name:B}]),$(B,C)}else C.channel&&$(C.channel,C);T>w&&g(T)}catch{}};return N.current=setInterval(p,1500),p(),()=>{a=!1,clearInterval(N.current)}},[e,w,n,$]),W(()=>{if(!e)return;let a=i[s];if(!a||a.type!=="channel")return;let p=a.name.replace("#","");H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{});let v=setInterval(()=>{H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{})},5e3);return()=>clearInterval(v)},[e,s,i]),W(()=>{y.current?.scrollIntoView({behavior:"smooth"})},[d,s]),W(()=>{D.current?.focus()},[s]);let L=async a=>{if(a){a=a.trim(),a.startsWith("#")||(a="#"+a);try{await H("/channels/join",{method:"POST",body:JSON.stringify({channel:a})}),o(p=>p.find(v=>v.type==="channel"&&v.name===a)?p:[...p,{type:"channel",name:a}]),l(i.length),E(a,`Joined ${a}`),P("")}catch(p){E("server",`Failed to join ${a}: ${p.data?.error||"error"}`)}}},F=async a=>{let p=a.replace("#","");try{await H(`/channels/${p}/part`,{method:"DELETE"})}catch{}o(v=>v.filter(C=>!(C.type==="channel"&&C.name===a))),l(0)},R=a=>{let p=i[a];p.type==="channel"?F(p.name):p.type==="dm"&&(o(v=>v.filter((T,C)=>C!==a)),s>=a&&l(Math.max(0,s-1)))},M=a=>{o(p=>p.find(v=>v.type==="dm"&&v.name===a)?p:[...p,{type:"dm",name:a}]),l(i.findIndex(p=>p.type==="dm"&&p.name===a)||i.length)},A=async()=>{let a=u.trim();if(!a)return;f("");let p=i[s];if(!(!p||p.type==="server")){if(a.startsWith("/")){let v=a.split(" "),T=v[0].toLowerCase();if(T==="/join"&&v[1]){L(v[1]);return}if(T==="/part"){p.type==="channel"&&F(p.name);return}if(T==="/msg"&&v[1]&&v.slice(2).join(" ")){let C=v[1],B=v.slice(2).join(" ");try{await H(`/dm/${C}/messages`,{method:"POST",body:JSON.stringify({content:B})}),M(C)}catch(V){E("server",`Failed to send DM: ${V.data?.error||"error"}`)}return}if(T==="/nick"){E("server","Nick changes not yet supported");return}E("server",`Unknown command: ${T}`);return}if(p.type==="channel"){let v=p.name.replace("#","");try{await H(`/channels/${v}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(T){E(p.name,`Send failed: ${T.data?.error||"error"}`)}}else if(p.type==="dm")try{await H(`/dm/${p.name}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(v){E(p.name,`Send failed: ${v.data?.error||"error"}`)}}};if(!e)return m(st,{onLogin:Q});let J=i[s]||i[0],me=d[J.name]||[],ve=h[J.name]||[];return m("div",{class:"app"},m("div",{class:"tab-bar"},i.map((a,p)=>m("div",{class:`tab ${p===s?"active":""}`,onClick:()=>l(p)},a.type==="dm"?`\u2192${a.name}`:a.name,a.type!=="server"&&m("span",{class:"close-btn",onClick:v=>{v.stopPropagation(),R(p)}},"\xD7"))),m("div",{class:"join-dialog"},m("input",{placeholder:"#channel",value:k,onInput:a=>P(a.target.value),onKeyDown:a=>a.key==="Enter"&&L(k)}),m("button",{onClick:()=>L(k)},"Join"))),m("div",{class:"content"},m("div",{class:"messages-pane"},J.type==="server"?m("div",{class:"server-messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})):m(Fragment,null,m("div",{class:"messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})),m("div",{class:"input-bar"},m("input",{ref:D,placeholder:`Message ${J.name}...`,value:u,onInput:a=>f(a.target.value),onKeyDown:a=>a.key==="Enter"&&A()}),m("button",{onClick:A},"Send")))),J.type==="channel"&&m("div",{class:"user-list"},m("h3",null,"Users (",ve.length,")"),ve.map(a=>m("div",{class:"user",onClick:()=>M(a.nick),style:{color:Ke(a.nick)}},a.nick)))))}$e(m(ct,null),document.getElementById("root"));})(); diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..fee4d65 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + Chat + + + +
+ + + diff --git a/web/dist/style.css b/web/dist/style.css new file mode 100644 index 0000000..929111c --- /dev/null +++ b/web/dist/style.css @@ -0,0 +1,274 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #1a1a2e; + --bg-secondary: #16213e; + --bg-input: #0f3460; + --text: #e0e0e0; + --text-muted: #888; + --accent: #e94560; + --accent2: #0f3460; + --border: #2a2a4a; + --nick: #53a8b6; + --timestamp: #666; + --tab-active: #e94560; + --tab-bg: #16213e; + --tab-hover: #1a1a3e; +} + +html, body, #root { + height: 100%; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +/* Login screen */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.login-screen h1 { + color: var(--accent); + font-size: 2em; +} + +.login-screen input { + padding: 10px 16px; + font-size: 16px; + font-family: inherit; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + width: 280px; +} + +.login-screen button { + padding: 10px 24px; + font-size: 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + border-radius: 4px; + cursor: pointer; +} + +.login-screen .error { + color: var(--accent); +} + +.login-screen .motd { + color: var(--text-muted); + max-width: 400px; + text-align: center; + white-space: pre-wrap; +} + +/* Main layout */ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Tab bar */ +.tab-bar { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + color: var(--text-muted); + user-select: none; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--tab-active); +} + +.tab .close-btn { + margin-left: 8px; + color: var(--text-muted); + font-size: 12px; +} + +.tab .close-btn:hover { + color: var(--accent); +} + +/* Content area */ +.content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Messages */ +.messages-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.message { + padding: 2px 0; + line-height: 1.4; + word-wrap: break-word; +} + +.message .timestamp { + color: var(--timestamp); + font-size: 12px; + margin-right: 8px; +} + +.message .nick { + color: var(--nick); + font-weight: bold; + margin-right: 8px; +} + +.message .nick::before { content: '<'; } +.message .nick::after { content: '>'; } + +.message.system { + color: var(--text-muted); + font-style: italic; +} + +.message.system .nick { + color: var(--text-muted); +} + +.message.system .nick::before, +.message.system .nick::after { content: ''; } + +/* Input */ +.input-bar { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.input-bar input { + flex: 1; + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + background: var(--bg-input); + border: none; + color: var(--text); + outline: none; +} + +.input-bar button { + padding: 10px 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + cursor: pointer; +} + +/* User list */ +.user-list { + width: 160px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + flex-shrink: 0; +} + +.user-list h3 { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.user-list .user { + padding: 3px 4px; + color: var(--nick); + font-size: 13px; + cursor: pointer; +} + +.user-list .user:hover { + background: var(--tab-hover); +} + +/* Server tab */ +.server-messages { + color: var(--text-muted); + padding: 12px; + white-space: pre-wrap; + overflow-y: auto; + flex: 1; +} + +/* Channel join dialog */ +.join-dialog { + padding: 12px; + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.join-dialog input { + padding: 6px 10px; + font-family: inherit; + font-size: 13px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 3px; + width: 200px; +} + +.join-dialog button { + padding: 6px 14px; + font-family: inherit; + font-size: 13px; + background: var(--accent2); + border: none; + color: var(--text); + border-radius: 3px; + cursor: pointer; +} + +/* Responsive */ +@media (max-width: 600px) { + .user-list { display: none; } + .tab { padding: 6px 10px; font-size: 13px; } +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..0daf0e2 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +// Package web embeds the built SPA static files. +package web + +import "embed" + +// Dist contains the built web client files. +// +//go:embed dist/* +var Dist embed.FS diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..f45ba3e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,513 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "preact": "^10.28.3" + }, + "devDependencies": { + "esbuild": "^0.27.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/preact": { + "version": "10.28.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", + "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..52e2446 --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "web", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "esbuild": "^0.27.3" + }, + "dependencies": { + "preact": "^10.28.3" + } +} diff --git a/web/src/app.jsx b/web/src/app.jsx new file mode 100644 index 0000000..846dd51 --- /dev/null +++ b/web/src/app.jsx @@ -0,0 +1,371 @@ +import { h, render, Component } from 'preact'; +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; + +const API = '/api/v1'; + +function api(path, opts = {}) { + const token = localStorage.getItem('chat_token'); + const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + return fetch(API + path, { ...opts, headers }).then(async r => { + const data = await r.json().catch(() => null); + if (!r.ok) throw { status: r.status, data }; + return data; + }); +} + +function formatTime(ts) { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +// Nick color hashing +function nickColor(nick) { + let h = 0; + for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); + const hue = Math.abs(h) % 360; + return `hsl(${hue}, 70%, 65%)`; +} + +function LoginScreen({ onLogin }) { + const [nick, setNick] = useState(''); + const [error, setError] = useState(''); + const [motd, setMotd] = useState(''); + const [serverName, setServerName] = useState('Chat'); + const inputRef = useRef(); + + useEffect(() => { + api('/server').then(s => { + if (s.name) setServerName(s.name); + if (s.motd) setMotd(s.motd); + }).catch(() => {}); + // Check for saved token + const saved = localStorage.getItem('chat_token'); + if (saved) { + api('/state').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); + } + inputRef.current?.focus(); + }, []); + + const submit = async (e) => { + e.preventDefault(); + setError(''); + try { + const res = await api('/session', { + method: 'POST', + body: JSON.stringify({ nick: nick.trim() }) + }); + localStorage.setItem('chat_token', res.token); + onLogin(res.nick, res.token); + } catch (err) { + setError(err.data?.error || 'Connection failed'); + } + }; + + return ( + + ); +} + +function Message({ msg }) { + return ( +
+ {formatTime(msg.createdAt)} + {msg.nick} + {msg.content} +
+ ); +} + +function App() { + const [loggedIn, setLoggedIn] = useState(false); + const [nick, setNick] = useState(''); + const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]); + const [activeTab, setActiveTab] = useState(0); + const [messages, setMessages] = useState({ server: [] }); // keyed by tab name + const [members, setMembers] = useState({}); // keyed by channel name + const [input, setInput] = useState(''); + const [joinInput, setJoinInput] = useState(''); + const [lastMsgId, setLastMsgId] = useState(0); + const messagesEndRef = useRef(); + const inputRef = useRef(); + const pollRef = useRef(); + + const addMessage = useCallback((tabName, msg) => { + setMessages(prev => ({ + ...prev, + [tabName]: [...(prev[tabName] || []), msg] + })); + }, []); + + const addSystemMessage = useCallback((tabName, text) => { + addMessage(tabName, { + id: Date.now(), + nick: '*', + content: text, + createdAt: new Date().toISOString(), + system: true + }); + }, [addMessage]); + + const onLogin = useCallback((userNick, token) => { + setNick(userNick); + setLoggedIn(true); + addSystemMessage('server', `Connected as ${userNick}`); + // Fetch server info + api('/server').then(s => { + if (s.motd) addSystemMessage('server', `MOTD: ${s.motd}`); + }).catch(() => {}); + }, [addSystemMessage]); + + // Poll for new messages + useEffect(() => { + if (!loggedIn) return; + let alive = true; + const poll = async () => { + try { + const msgs = await api(`/messages?after=${lastMsgId}`); + if (!alive) return; + let maxId = lastMsgId; + for (const msg of msgs) { + if (msg.id > maxId) maxId = msg.id; + if (msg.isDm) { + const dmTab = msg.nick === nick ? msg.dmTarget : msg.nick; + // Ensure DM tab exists + setTabs(prev => { + if (!prev.find(t => t.type === 'dm' && t.name === dmTab)) { + return [...prev, { type: 'dm', name: dmTab }]; + } + return prev; + }); + addMessage(dmTab, msg); + } else if (msg.channel) { + addMessage(msg.channel, msg); + } + } + if (maxId > lastMsgId) setLastMsgId(maxId); + } catch (err) { + // silent + } + }; + pollRef.current = setInterval(poll, 1500); + poll(); + return () => { alive = false; clearInterval(pollRef.current); }; + }, [loggedIn, lastMsgId, nick, addMessage]); + + // Fetch members for active channel tab + useEffect(() => { + if (!loggedIn) return; + const tab = tabs[activeTab]; + if (!tab || tab.type !== 'channel') return; + const chName = tab.name.replace('#', ''); + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [tab.name]: m })); + }).catch(() => {}); + const iv = setInterval(() => { + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [tab.name]: m })); + }).catch(() => {}); + }, 5000); + return () => clearInterval(iv); + }, [loggedIn, activeTab, tabs]); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, activeTab]); + + // Focus input on tab change + useEffect(() => { + inputRef.current?.focus(); + }, [activeTab]); + + const joinChannel = async (name) => { + if (!name) return; + name = name.trim(); + if (!name.startsWith('#')) name = '#' + name; + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: name }) }); + setTabs(prev => { + if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; + return [...prev, { type: 'channel', name }]; + }); + setActiveTab(tabs.length); // switch to new tab + addSystemMessage(name, `Joined ${name}`); + setJoinInput(''); + } catch (err) { + addSystemMessage('server', `Failed to join ${name}: ${err.data?.error || 'error'}`); + } + }; + + const partChannel = async (name) => { + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) }); + } catch (err) { /* ignore */ } + setTabs(prev => { + const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); + return next; + }); + setActiveTab(0); + }; + + const closeTab = (idx) => { + const tab = tabs[idx]; + if (tab.type === 'channel') { + partChannel(tab.name); + } else if (tab.type === 'dm') { + setTabs(prev => prev.filter((_, i) => i !== idx)); + if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1)); + } + }; + + const openDM = (targetNick) => { + setTabs(prev => { + if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev; + return [...prev, { type: 'dm', name: targetNick }]; + }); + setActiveTab(tabs.findIndex(t => t.type === 'dm' && t.name === targetNick) || tabs.length); + }; + + const sendMessage = async () => { + const text = input.trim(); + if (!text) return; + setInput(''); + const tab = tabs[activeTab]; + if (!tab || tab.type === 'server') return; + + // Handle /commands + if (text.startsWith('/')) { + const parts = text.split(' '); + const cmd = parts[0].toLowerCase(); + if (cmd === '/join' && parts[1]) { + joinChannel(parts[1]); + return; + } + if (cmd === '/part') { + if (tab.type === 'channel') partChannel(tab.name); + return; + } + if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { + const target = parts[1]; + const msg = parts.slice(2).join(' '); + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [msg] }) }); + openDM(target); + } catch (err) { + addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); + } + return; + } + if (cmd === '/nick' && parts[1]) { + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) }); + setNick(parts[1]); + addSystemMessage('server', `Nick changed to ${parts[1]}`); + } catch (err) { + addSystemMessage('server', `Nick change failed: ${err.data?.error || 'error'}`); + } + return; + } + addSystemMessage('server', `Unknown command: ${cmd}`); + return; + } + + const to = tab.type === 'channel' ? tab.name : tab.name; + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to, body: [text] }) }); + } catch (err) { + addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); + } + }; + + if (!loggedIn) return ; + + const currentTab = tabs[activeTab] || tabs[0]; + const currentMessages = messages[currentTab.name] || []; + const currentMembers = members[currentTab.name] || []; + + return ( +
+
+ {tabs.map((tab, i) => ( +
setActiveTab(i)} + > + {tab.type === 'dm' ? `→${tab.name}` : tab.name} + {tab.type !== 'server' && ( + { e.stopPropagation(); closeTab(i); }}>× + )} +
+ ))} +
+ setJoinInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)} + /> + +
+
+ +
+
+ {currentTab.type === 'server' ? ( +
+ {currentMessages.map(m => )} +
+
+ ) : ( + <> +
+ {currentMessages.map(m => )} +
+
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendMessage()} + /> + +
+ + )} +
+ + {currentTab.type === 'channel' && ( +
+

Users ({currentMembers.length})

+ {currentMembers.map(u => ( +
openDM(u.nick)} style={{ color: nickColor(u.nick) }}> + {u.nick} +
+ ))} +
+ )} +
+
+ ); +} + +render(, document.getElementById('root')); diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..fee4d65 --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,13 @@ + + + + + + Chat + + + +
+ + + diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..929111c --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,274 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #1a1a2e; + --bg-secondary: #16213e; + --bg-input: #0f3460; + --text: #e0e0e0; + --text-muted: #888; + --accent: #e94560; + --accent2: #0f3460; + --border: #2a2a4a; + --nick: #53a8b6; + --timestamp: #666; + --tab-active: #e94560; + --tab-bg: #16213e; + --tab-hover: #1a1a3e; +} + +html, body, #root { + height: 100%; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +/* Login screen */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.login-screen h1 { + color: var(--accent); + font-size: 2em; +} + +.login-screen input { + padding: 10px 16px; + font-size: 16px; + font-family: inherit; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + width: 280px; +} + +.login-screen button { + padding: 10px 24px; + font-size: 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + border-radius: 4px; + cursor: pointer; +} + +.login-screen .error { + color: var(--accent); +} + +.login-screen .motd { + color: var(--text-muted); + max-width: 400px; + text-align: center; + white-space: pre-wrap; +} + +/* Main layout */ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Tab bar */ +.tab-bar { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + color: var(--text-muted); + user-select: none; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--tab-active); +} + +.tab .close-btn { + margin-left: 8px; + color: var(--text-muted); + font-size: 12px; +} + +.tab .close-btn:hover { + color: var(--accent); +} + +/* Content area */ +.content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Messages */ +.messages-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.message { + padding: 2px 0; + line-height: 1.4; + word-wrap: break-word; +} + +.message .timestamp { + color: var(--timestamp); + font-size: 12px; + margin-right: 8px; +} + +.message .nick { + color: var(--nick); + font-weight: bold; + margin-right: 8px; +} + +.message .nick::before { content: '<'; } +.message .nick::after { content: '>'; } + +.message.system { + color: var(--text-muted); + font-style: italic; +} + +.message.system .nick { + color: var(--text-muted); +} + +.message.system .nick::before, +.message.system .nick::after { content: ''; } + +/* Input */ +.input-bar { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.input-bar input { + flex: 1; + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + background: var(--bg-input); + border: none; + color: var(--text); + outline: none; +} + +.input-bar button { + padding: 10px 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + cursor: pointer; +} + +/* User list */ +.user-list { + width: 160px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + flex-shrink: 0; +} + +.user-list h3 { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.user-list .user { + padding: 3px 4px; + color: var(--nick); + font-size: 13px; + cursor: pointer; +} + +.user-list .user:hover { + background: var(--tab-hover); +} + +/* Server tab */ +.server-messages { + color: var(--text-muted); + padding: 12px; + white-space: pre-wrap; + overflow-y: auto; + flex: 1; +} + +/* Channel join dialog */ +.join-dialog { + padding: 12px; + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.join-dialog input { + padding: 6px 10px; + font-family: inherit; + font-size: 13px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 3px; + width: 200px; +} + +.join-dialog button { + padding: 6px 14px; + font-family: inherit; + font-size: 13px; + background: var(--accent2); + border: none; + color: var(--text); + border-radius: 3px; + cursor: pointer; +} + +/* Responsive */ +@media (max-width: 600px) { + .user-list { display: none; } + .tab { padding: 6px 10px; font-size: 13px; } +}