This commit was merged in pull request #8.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ data.db
|
|||||||
*.out
|
*.out
|
||||||
vendor/
|
vendor/
|
||||||
debug.log
|
debug.log
|
||||||
|
web/node_modules/
|
||||||
|
chat-cli
|
||||||
|
|||||||
622
README.md
622
README.md
@@ -1,7 +1,15 @@
|
|||||||
# chat
|
# chat
|
||||||
|
|
||||||
A modern IRC-inspired chat server written in Go. Decouples session state from
|
**IRC plus message metadata, a signing system using it, and server-based
|
||||||
transport connections, enabling mobile-friendly persistent sessions over HTTP.
|
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
|
## Motivation
|
||||||
|
|
||||||
@@ -12,10 +20,166 @@ or pay for IRCCloud.
|
|||||||
This project builds a chat server that:
|
This project builds a chat server that:
|
||||||
|
|
||||||
- Holds session state server-side (message queues, presence, channel membership)
|
- Holds session state server-side (message queues, presence, channel membership)
|
||||||
- Delivers messages over HTTP (JSON-RPC style)
|
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
||||||
- Supports multiple concurrent connections per user session
|
- Supports multiple concurrent clients per user session
|
||||||
- Provides IRC-like semantics: channels, nicks, topics, modes
|
- 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
|
## 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
|
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.
|
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
|
- **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds
|
||||||
support (server holds the connection open until messages arrive or timeout)
|
the connection for up to 15 seconds until messages arrive or timeout.
|
||||||
- **Client sending**: Clients send messages/commands via `POST`
|
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
|
- **Server federation**: Servers exchange messages via HTTP to enable multi-server
|
||||||
networks (like IRC server linking)
|
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)
|
│ User Session (UUID) │
|
||||||
- Maintain a persistent message queue on the server
|
│ nick: "alice" │
|
||||||
|
│ signing key: ed25519:... │
|
||||||
#### Sessions
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ │
|
||||||
- A session represents an authenticated user's connection context
|
│ │ Client A │ │ Client B │ ... │
|
||||||
- Session state is **server-held**, not connection-bound
|
│ │ UUID │ │ UUID │ │
|
||||||
- Multiple devices can share a session (messages delivered to all)
|
│ │ token │ │ token │ │
|
||||||
- Sessions persist across disconnects — messages queue until retrieved
|
│ │ queue │ │ queue │ │
|
||||||
- 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": {}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Fields:
|
- **User session**: server-assigned UUID. Represents a user on this server.
|
||||||
- `id` — Server-assigned UUID, globally unique
|
Has a nick (changeable, unique per server at any point in time).
|
||||||
- `ts` — Server-assigned timestamp (ISO 8601)
|
- **Client**: each device/connection gets its own UUID and opaque auth token.
|
||||||
- `from` — Sender nick
|
The token is the credential — present it to authenticate.
|
||||||
- `to` — Destination: channel name (`#foo`) or nick (for DMs)
|
- **Queue**: each client has an independent S2C message queue. The server fans
|
||||||
- `type` — Message type: `message`, `action`, `notice`, `join`, `part`, `quit`,
|
out messages to all active client queues for the session.
|
||||||
`topic`, `mode`, `nick`, `system`
|
|
||||||
- `body` — Message content (UTF-8 text)
|
Sessions persist across disconnects. Messages queue until retrieved. Client
|
||||||
- `meta` — Arbitrary extensible metadata (JSON object). Can carry:
|
queues expire independently after a configurable idle timeout.
|
||||||
- Cryptographic signatures
|
|
||||||
- Rich content hints (URLs, embeds)
|
### Message Protocol
|
||||||
- Client-specific extensions
|
|
||||||
- Reactions, edits, threading references
|
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 a–c and checking the signature
|
||||||
|
against the sender's announced public key
|
||||||
|
|
||||||
|
#### PUBKEY Message
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Servers relay PUBKEY messages to all channel members. Clients cache public keys
|
||||||
|
and use them to verify `meta.sig` on incoming messages. Key distribution is
|
||||||
|
trust-on-first-use (TOFU). There is no key revocation mechanism.
|
||||||
|
|
||||||
### API Endpoints
|
### 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:
|
||||||
|
|
||||||
```
|
1. `POST /api/v1/session` — create a session, get a token
|
||||||
POST /api/v1/register — Create account (nick, password) → token
|
2. `GET /api/v1/state` — see who you are and what channels you're in
|
||||||
POST /api/v1/login — Authenticate → token
|
3. `GET /api/v1/messages?timeout=15` — long-poll for all messages (channel, DM, system)
|
||||||
POST /api/v1/logout — Invalidate token
|
4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"`
|
||||||
|
|
||||||
|
That's the core. Everything else (join, part, history, members) is ancillary.
|
||||||
|
|
||||||
|
#### Quick example (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a session (get session UUID, client UUID, and auth token)
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
|
-d '{"nick":"alice"}' | jq -r .token)
|
||||||
|
|
||||||
|
# Join a channel (creates it if it doesn't exist)
|
||||||
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"command":"JOIN","to":"#general"}'
|
||||||
|
|
||||||
|
# Send a message
|
||||||
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}'
|
||||||
|
|
||||||
|
# Poll for messages (long-poll, 15s timeout)
|
||||||
|
curl -s "http://localhost:8080/api/v1/messages?timeout=15" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Session & Messages
|
#### Session
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/messages — Retrieve queued messages (long-poll supported)
|
POST /api/v1/session — Create session { "nick": "..." }
|
||||||
Query params: ?after=<message-id>&timeout=30
|
→ { id, nick, token }
|
||||||
POST /api/v1/messages — Send a message or command
|
Token is opaque (random), not JWT.
|
||||||
GET /api/v1/history — Retrieve channel/DM history
|
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
|
Query params: ?target=#channel&before=<id>&limit=50
|
||||||
|
For DMs: ?target=nick&before=<id>&limit=50
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Channels
|
#### Channels
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/channels — List joined channels
|
GET /api/v1/channels — List all server channels
|
||||||
POST /api/v1/channels/join — Join a channel
|
GET /api/v1/channels/{name}/members — Channel member list
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Users
|
Join and part are handled via `POST /api/v1/messages` with `JOIN` and `PART`
|
||||||
|
commands (see Messages above).
|
||||||
```
|
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Server Info
|
#### 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
|
GET /.well-known/healthcheck.json — Health check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Federation (Server-to-Server)
|
### 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)
|
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
|
GET /api/v1/federation/status — Link status
|
||||||
```
|
```
|
||||||
|
|
||||||
Federation uses the same HTTP+JSON transport. Messages are relayed between
|
Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK,
|
||||||
servers so users on different servers can share channels.
|
UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are
|
||||||
|
relayed verbatim between servers so users on different servers can share channels.
|
||||||
|
|
||||||
### Channel Modes
|
### Channel Modes
|
||||||
|
|
||||||
@@ -170,7 +553,9 @@ Via environment variables (Viper), following gohttpserver conventions:
|
|||||||
| `DEBUG` | `false` | Debug mode |
|
| `DEBUG` | `false` | Debug mode |
|
||||||
| `MAX_HISTORY` | `10000` | Max messages per channel history |
|
| `MAX_HISTORY` | `10000` | Max messages per channel history |
|
||||||
| `SESSION_TIMEOUT` | `86400` | Session idle timeout (seconds) |
|
| `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) |
|
| `MAX_MESSAGE_SIZE` | `4096` | Max message body size (bytes) |
|
||||||
|
| `LONG_POLL_TIMEOUT` | `15` | Long-poll timeout in seconds |
|
||||||
| `MOTD` | `""` | Message of the day |
|
| `MOTD` | `""` | Message of the day |
|
||||||
| `SERVER_NAME` | hostname | Server display name |
|
| `SERVER_NAME` | hostname | Server display name |
|
||||||
| `FEDERATION_KEY` | `""` | Shared key for server linking |
|
| `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
|
SQLite by default (single-file, zero-config), with Postgres support for
|
||||||
larger deployments. Tables:
|
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
|
- `channels` — channel metadata and modes
|
||||||
- `channel_members` — membership and user modes
|
- `channel_members` — membership and user modes
|
||||||
- `messages` — message history (rotated per `MAX_HISTORY`)
|
- `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
|
- `server_links` — federation peer configuration
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -194,44 +580,31 @@ Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/s
|
|||||||
```
|
```
|
||||||
chat/
|
chat/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── chat/
|
│ └── chatd/
|
||||||
│ └── main.go
|
│ └── main.go
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/
|
│ ├── config/
|
||||||
│ │ └── config.go
|
|
||||||
│ ├── database/
|
│ ├── database/
|
||||||
│ │ └── database.go
|
|
||||||
│ ├── globals/
|
│ ├── globals/
|
||||||
│ │ └── globals.go
|
|
||||||
│ ├── handlers/
|
│ ├── handlers/
|
||||||
│ │ ├── handlers.go
|
|
||||||
│ │ ├── auth.go
|
|
||||||
│ │ ├── channels.go
|
|
||||||
│ │ ├── federation.go
|
|
||||||
│ │ ├── healthcheck.go
|
|
||||||
│ │ ├── messages.go
|
|
||||||
│ │ └── users.go
|
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
│ │ └── healthcheck.go
|
|
||||||
│ ├── logger/
|
│ ├── logger/
|
||||||
│ │ └── logger.go
|
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── middleware.go
|
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── channel.go
|
|
||||||
│ │ ├── message.go
|
|
||||||
│ │ └── user.go
|
|
||||||
│ ├── queue/
|
│ ├── queue/
|
||||||
│ │ └── queue.go
|
|
||||||
│ └── server/
|
│ └── server/
|
||||||
│ ├── server.go
|
├── schema/
|
||||||
│ ├── http.go
|
│ ├── message.schema.json
|
||||||
│ └── routes.go
|
│ ├── c2s/
|
||||||
|
│ ├── s2c/
|
||||||
|
│ ├── s2s/
|
||||||
|
│ └── README.md
|
||||||
|
├── web/
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
├── Makefile
|
├── Makefile
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── CONVENTIONS.md → (copy from gohttpserver)
|
├── CONVENTIONS.md
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -252,20 +625,33 @@ Per gohttpserver conventions:
|
|||||||
|
|
||||||
### Design Principles
|
### 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.
|
negotiation. HTTP is universal, proxy-friendly, and works everywhere.
|
||||||
2. **Server holds state** — clients are stateless. Reconnect, switch devices,
|
5. **Server holds state** — clients are stateless. Reconnect, switch devices,
|
||||||
lose connectivity — your messages are waiting.
|
lose connectivity — your messages are waiting in your client queue.
|
||||||
3. **Structured messages** — JSON with extensible metadata. Enables signatures,
|
6. **Structured messages** — JSON with extensible metadata. Bodies are always
|
||||||
rich content, client extensions without protocol changes.
|
objects or arrays for deterministic canonicalization (JCS) and signing.
|
||||||
4. **Simple deployment** — single binary, SQLite default, zero mandatory
|
7. **Immutable messages** — no editing, no deletion. Fits naturally with
|
||||||
|
cryptographic signatures.
|
||||||
|
8. **Simple deployment** — single binary, SQLite default, zero mandatory
|
||||||
external dependencies.
|
external dependencies.
|
||||||
5. **No eternal logs** — history rotates. Chat should be ephemeral by default.
|
9. **No eternal logs** — history rotates. Chat should be ephemeral by default.
|
||||||
6. **Federation optional** — single server works standalone. Linking is opt-in.
|
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
|
## 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
|
## License
|
||||||
|
|
||||||
|
|||||||
204
cmd/chat-cli/api/client.go
Normal file
204
cmd/chat-cli/api/client.go
Normal 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
83
cmd/chat-cli/api/types.go
Normal 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
580
cmd/chat-cli/main.go
Normal 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
233
cmd/chat-cli/ui.go
Normal 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
10
go.mod
@@ -20,8 +20,11 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // 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/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/google/uuid v1.6.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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // 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/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.28.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
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
47
go.sum
47
go.sum
@@ -12,6 +12,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
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=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
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.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 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
414
internal/db/queries.go
Normal file
414
internal/db/queries.go
Normal 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
|
||||||
|
}
|
||||||
31
internal/db/schema/003_users.sql
Normal file
31
internal/db/schema/003_users.sql
Normal 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
400
internal/handlers/api.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||||
@@ -20,6 +21,7 @@ type Params struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/chat/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
@@ -45,4 +48,40 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
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
97
schema/README.md
Normal 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 (0xx–3xx)
|
||||||
|
|
||||||
|
| Code | Name | File | Description |
|
||||||
|
|-------|-------------------|-----------------------|--------------------------------|
|
||||||
|
| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation |
|
||||||
|
| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info |
|
||||||
|
| `003` | RPL_CREATED | `numerics/003.json` | Server creation date |
|
||||||
|
| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes |
|
||||||
|
| `322` | RPL_LIST | `numerics/322.json` | Channel list entry |
|
||||||
|
| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list |
|
||||||
|
| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic |
|
||||||
|
| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list |
|
||||||
|
| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list |
|
||||||
|
| `372` | RPL_MOTD | `numerics/372.json` | MOTD line |
|
||||||
|
| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD |
|
||||||
|
| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD |
|
||||||
|
|
||||||
|
### Errors (4xx)
|
||||||
|
|
||||||
|
| Code | Name | File | Description |
|
||||||
|
|-------|----------------------|-----------------------|--------------------------------|
|
||||||
|
| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel |
|
||||||
|
| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel |
|
||||||
|
| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use |
|
||||||
|
| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel |
|
||||||
|
| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator |
|
||||||
|
|
||||||
|
## Federation (S2S)
|
||||||
|
|
||||||
|
Server-to-server messages use the same command format. Federated servers relay
|
||||||
|
messages with an additional `origin` field in `meta` to track the source server.
|
||||||
|
The PING/PONG commands serve as inter-server keepalives. State sync after link
|
||||||
|
establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands.
|
||||||
16
schema/commands/JOIN.json
Normal file
16
schema/commands/JOIN.json
Normal 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
29
schema/commands/KICK.json
Normal 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
29
schema/commands/MODE.json
Normal 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
22
schema/commands/NICK.json
Normal 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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
schema/commands/NOTICE.json
Normal file
21
schema/commands/NOTICE.json
Normal 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
22
schema/commands/PART.json
Normal 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
20
schema/commands/PING.json
Normal 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
21
schema/commands/PONG.json
Normal 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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
schema/commands/PRIVMSG.json
Normal file
24
schema/commands/PRIVMSG.json
Normal 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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
37
schema/commands/PUBKEY.json
Normal file
37
schema/commands/PUBKEY.json
Normal 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
21
schema/commands/QUIT.json
Normal 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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
schema/commands/TOPIC.json
Normal file
22
schema/commands/TOPIC.json
Normal 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
72
schema/message.json
Normal 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
37
schema/numerics/001.json
Normal 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
36
schema/numerics/002.json
Normal 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
36
schema/numerics/003.json
Normal 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
39
schema/numerics/004.json
Normal 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
47
schema/numerics/322.json
Normal 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
27
schema/numerics/323.json
Normal 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
47
schema/numerics/332.json
Normal 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
48
schema/numerics/353.json
Normal 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
35
schema/numerics/366.json
Normal 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
36
schema/numerics/372.json
Normal 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
26
schema/numerics/375.json
Normal 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
27
schema/numerics/376.json
Normal 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
47
schema/numerics/401.json
Normal 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
47
schema/numerics/403.json
Normal 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
47
schema/numerics/433.json
Normal 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
35
schema/numerics/442.json
Normal 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
35
schema/numerics/482.json
Normal 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
41
web/build.sh
Executable 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
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
13
web/dist/index.html
vendored
Normal 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
274
web/dist/style.css
vendored
Normal 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
9
web/embed.go
Normal 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
513
web/package-lock.json
generated
Normal 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
18
web/package.json
Normal 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
371
web/src/app.jsx
Normal 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
13
web/src/index.html
Normal 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
274
web/src/style.css
Normal 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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user