Add embedded web chat client (closes #7) #8

Merged
clawbot merged 22 commits from feature/web-client into main 2026-02-11 03:02:42 +01:00
55 changed files with 5182 additions and 123 deletions

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ data.db
*.out
vendor/
debug.log
web/node_modules/
chat-cli

624
README.md
View File

@@ -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 <token>`.
- 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\<string\> | | 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 ac 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 <token>` 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/messagesRetrieve queued messages (long-poll supported)
Query params: ?after=<message-id>&timeout=30
POST /api/v1/messages — Send a message or command
GET /api/v1/history — Retrieve channel/DM history
Query params: ?target=#channel&before=<id>&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=<message-id>&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=<id>&limit=50
For DMs: ?target=nick&before=<id>&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

204
cmd/chat-cli/api/client.go Normal file
View File

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

83
cmd/chat-cli/api/types.go Normal file
View File

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

580
cmd/chat-cli/main.go Normal file
View File

@@ -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 <server-url>[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 <url>")
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 <server-url>")
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 <name>")
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 <nick> <text>")
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 <nick>")
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 <number>")
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 <url> — Connect to server",
" /nick <name> — Change nickname",
" /join #channel — Join channel",
" /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window",
" /topic [text] — View/set topic",
" /names — List channel members",
" /list — List channels",
" /window <n> — 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))
}
}
}

233
cmd/chat-cli/ui.go Normal file
View File

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

10
go.mod
View File

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

47
go.sum
View File

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

View File

@@ -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"),

414
internal/db/queries.go Normal file
View File

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

View File

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

400
internal/handlers/api.go Normal file
View File

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

View File

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

View File

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

97
schema/README.md Normal file
View File

@@ -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 (0xx3xx)
| 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.

16
schema/commands/JOIN.json Normal file
View File

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

29
schema/commands/KICK.json Normal file
View File

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

29
schema/commands/MODE.json Normal file
View File

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

22
schema/commands/NICK.json Normal file
View File

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

View File

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

22
schema/commands/PART.json Normal file
View File

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

20
schema/commands/PING.json Normal file
View File

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

21
schema/commands/PONG.json Normal file
View File

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

View File

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

View File

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

21
schema/commands/QUIT.json Normal file
View File

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

View File

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

72
schema/message.json Normal file
View File

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

37
schema/numerics/001.json Normal file
View File

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

36
schema/numerics/002.json Normal file
View File

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

36
schema/numerics/003.json Normal file
View File

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

39
schema/numerics/004.json Normal file
View File

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

47
schema/numerics/322.json Normal file
View File

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

27
schema/numerics/323.json Normal file
View File

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

47
schema/numerics/332.json Normal file
View File

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

48
schema/numerics/353.json Normal file
View File

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

35
schema/numerics/366.json Normal file
View File

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

36
schema/numerics/372.json Normal file
View File

@@ -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!"
]
}
]
}

26
schema/numerics/375.json Normal file
View File

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

27
schema/numerics/376.json Normal file
View File

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

47
schema/numerics/401.json Normal file
View File

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

47
schema/numerics/403.json Normal file
View File

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

47
schema/numerics/433.json Normal file
View File

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

35
schema/numerics/442.json Normal file
View File

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

35
schema/numerics/482.json Normal file
View File

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

41
web/build.sh Executable file
View File

@@ -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/"

1
web/dist/app.js vendored Normal file

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>

274
web/dist/style.css vendored Normal file
View File

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

9
web/embed.go Normal file
View File

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

513
web/package-lock.json generated Normal file
View File

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

18
web/package.json Normal file
View File

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

371
web/src/app.jsx Normal file
View File

@@ -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 (
<div class="login-screen">
<h1>{serverName}</h1>
{motd && <div class="motd">{motd}</div>}
<form onSubmit={submit}>
<input
ref={inputRef}
type="text"
placeholder="Choose a nickname..."
value={nick}
onInput={e => setNick(e.target.value)}
maxLength={32}
autoFocus
/>
<button type="submit">Connect</button>
</form>
{error && <div class="error">{error}</div>}
</div>
);
}
function Message({ msg }) {
return (
<div class={`message ${msg.system ? 'system' : ''}`}>
<span class="timestamp">{formatTime(msg.createdAt)}</span>
<span class="nick" style={{ color: msg.system ? undefined : nickColor(msg.nick) }}>{msg.nick}</span>
<span class="content">{msg.content}</span>
</div>
);
}
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 <LoginScreen onLogin={onLogin} />;
const currentTab = tabs[activeTab] || tabs[0];
const currentMessages = messages[currentTab.name] || [];
const currentMembers = members[currentTab.name] || [];
return (
<div class="app">
<div class="tab-bar">
{tabs.map((tab, i) => (
<div
class={`tab ${i === activeTab ? 'active' : ''}`}
onClick={() => setActiveTab(i)}
>
{tab.type === 'dm' ? `${tab.name}` : tab.name}
{tab.type !== 'server' && (
<span class="close-btn" onClick={(e) => { e.stopPropagation(); closeTab(i); }}>×</span>
)}
</div>
))}
<div class="join-dialog">
<input
placeholder="#channel"
value={joinInput}
onInput={e => setJoinInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)}
/>
<button onClick={() => joinChannel(joinInput)}>Join</button>
</div>
</div>
<div class="content">
<div class="messages-pane">
{currentTab.type === 'server' ? (
<div class="server-messages">
{currentMessages.map(m => <Message msg={m} />)}
<div ref={messagesEndRef} />
</div>
) : (
<>
<div class="messages">
{currentMessages.map(m => <Message msg={m} />)}
<div ref={messagesEndRef} />
</div>
<div class="input-bar">
<input
ref={inputRef}
placeholder={`Message ${currentTab.name}...`}
value={input}
onInput={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
</div>
</>
)}
</div>
{currentTab.type === 'channel' && (
<div class="user-list">
<h3>Users ({currentMembers.length})</h3>
{currentMembers.map(u => (
<div class="user" onClick={() => openDM(u.nick)} style={{ color: nickColor(u.nick) }}>
{u.nick}
</div>
))}
</div>
)}
</div>
</div>
);
}
render(<App />, document.getElementById('root'));

13
web/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>

274
web/src/style.css Normal file
View File

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