Compare commits
9 Commits
docs/updat
...
b0107da216
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0107da216 | ||
|
|
25ab27652c | ||
|
|
49517e754a | ||
|
|
0d563adf31 | ||
|
|
46399de6dc | ||
|
|
82bff502fe | ||
|
|
a586e92dad | ||
|
|
50e7a93287 | ||
|
|
fe937b5ebb |
384
README.md
384
README.md
@@ -113,9 +113,8 @@ mechanisms or stuffing data into CTCP.
|
|||||||
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
||||||
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
|
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
|
||||||
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
||||||
are unique per server. Identity starts with a key — a nick is a display name.
|
are unique per server. There are no accounts — identity is a key, a nick is a
|
||||||
Accounts are optional: you can create an anonymous session instantly, or
|
display name.
|
||||||
register with a password for multi-client access to a single session.
|
|
||||||
|
|
||||||
### On the resemblance to JSON-RPC
|
### On the resemblance to JSON-RPC
|
||||||
|
|
||||||
@@ -149,45 +148,16 @@ not arbitrary choices — each one follows from the project's core thesis that
|
|||||||
IRC's command model is correct and only the transport and session management
|
IRC's command model is correct and only the transport and session management
|
||||||
need to change.
|
need to change.
|
||||||
|
|
||||||
### Identity & Sessions — Dual Authentication Model
|
### Identity & Sessions — No Accounts
|
||||||
|
|
||||||
The server supports two authentication paths: **anonymous sessions** for
|
There are no accounts, no registration, no passwords. Identity is a signing
|
||||||
instant access, and **optional account registration** for multi-client access.
|
key; a nick is just a display name. The two are decoupled.
|
||||||
|
|
||||||
#### Anonymous Sessions (No Account Required)
|
|
||||||
|
|
||||||
The simplest entry point. No registration, no passwords.
|
|
||||||
|
|
||||||
- **Session creation**: client sends `POST /api/v1/session` with a desired
|
- **Session creation**: client sends `POST /api/v1/session` with a desired
|
||||||
nick → server assigns an **auth token** (64 hex characters of
|
nick → server assigns an **auth token** (64 hex characters of
|
||||||
cryptographically random bytes) and returns the user ID, nick, and token.
|
cryptographically random bytes) and returns the user ID, nick, and token.
|
||||||
- The auth token implicitly identifies the client. Clients present it via
|
- The auth token implicitly identifies the client. Clients present it via
|
||||||
`Authorization: Bearer <token>`.
|
`Authorization: Bearer <token>`.
|
||||||
- Anonymous sessions are ephemeral — when the session expires or the user
|
|
||||||
QUITs, the nick is released and there is no way to reclaim it.
|
|
||||||
|
|
||||||
#### Registered Accounts (Optional)
|
|
||||||
|
|
||||||
For users who want multi-client access (multiple devices sharing one session):
|
|
||||||
|
|
||||||
- **Registration**: client sends `POST /api/v1/register` with a nick and
|
|
||||||
password (minimum 8 characters) → server creates a session with the
|
|
||||||
password hashed via bcrypt, and returns the user ID, nick, and auth token.
|
|
||||||
- **Login**: client sends `POST /api/v1/login` with nick and password →
|
|
||||||
server verifies the password against the stored bcrypt hash and creates a
|
|
||||||
new client token for the existing session. This enables multi-client
|
|
||||||
access: logging in from a new device adds a client to the existing session
|
|
||||||
rather than creating a new one, so channel memberships and message queues
|
|
||||||
are shared. Note: login only works while the session still exists — if all
|
|
||||||
clients have logged out or the user has sent QUIT, the session is deleted
|
|
||||||
and the registration is lost.
|
|
||||||
- Registered accounts cannot be logged into via `POST /api/v1/session` —
|
|
||||||
that endpoint is for anonymous sessions only.
|
|
||||||
- Anonymous sessions (created via `/session`) cannot be logged into via
|
|
||||||
`/login` because they have no password set.
|
|
||||||
|
|
||||||
#### Common Properties (Both Paths)
|
|
||||||
|
|
||||||
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
|
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
|
||||||
the stable identity.
|
the stable identity.
|
||||||
- Server-assigned IDs — clients do not choose their own IDs.
|
- Server-assigned IDs — clients do not choose their own IDs.
|
||||||
@@ -195,17 +165,11 @@ For users who want multi-client access (multiple devices sharing one session):
|
|||||||
in the token, no client-side decode. The server is the sole authority on
|
in the token, no client-side decode. The server is the sole authority on
|
||||||
token validity.
|
token validity.
|
||||||
|
|
||||||
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
|
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
|
||||||
Anonymous sessions preserve that simplicity — instant access, zero friction.
|
registration, email verification, or OAuth would solve a problem nobody asked
|
||||||
But some users want to access the same session from multiple devices without
|
about and add complexity that drives away casual users. Identity verification
|
||||||
a bouncer. Optional registration with password enables multi-client login
|
is handled at the message layer via cryptographic signatures (see
|
||||||
without adding friction for casual users: if you don't want an account,
|
[Security Model](#security-model)), not at the session layer.
|
||||||
don't create one. Note: in the current implementation, both anonymous and
|
|
||||||
registered sessions are deleted when the last client disconnects (QUIT or
|
|
||||||
logout); registration does not make a session survive all-client
|
|
||||||
removal. Identity verification at the message layer via cryptographic
|
|
||||||
signatures (see [Security Model](#security-model)) remains independent
|
|
||||||
of account registration.
|
|
||||||
|
|
||||||
### Nick Semantics
|
### Nick Semantics
|
||||||
|
|
||||||
@@ -243,12 +207,12 @@ User Session
|
|||||||
└── Client C (token_c, queue_c)
|
└── Client C (token_c, queue_c)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
|
**Current MVP note:** The current implementation creates a new user (with new
|
||||||
client to an existing registered session, enabling true multi-client support
|
nick) per `POST /api/v1/session` call. True multi-client (multiple tokens
|
||||||
(multiple tokens sharing one nick/session with independent message queues).
|
sharing one nick/session) is supported by the schema (`client_queues` is keyed
|
||||||
Anonymous sessions created via `POST /api/v1/session` always create a new
|
by user_id, and multiple tokens can point to the same user) but the session
|
||||||
user with a new nick. A future endpoint to "add a client to an existing
|
creation endpoint does not yet support "add a client to an existing session."
|
||||||
anonymous session" is planned but not yet implemented.
|
This will be added post-MVP.
|
||||||
|
|
||||||
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
**Rationale:** The fundamental IRC mobile problem is that you can't have your
|
||||||
phone and laptop connected simultaneously without a bouncer. Server-side
|
phone and laptop connected simultaneously without a bouncer. Server-side
|
||||||
@@ -301,8 +265,8 @@ The server implements HTTP long-polling for real-time message delivery:
|
|||||||
- The client disconnects (connection closed, no response needed)
|
- The client disconnects (connection closed, no response needed)
|
||||||
|
|
||||||
**Implementation detail:** The server maintains an in-memory broker with
|
**Implementation detail:** The server maintains an in-memory broker with
|
||||||
per-client notification channels. When a message is enqueued for a client, the
|
per-user notification channels. When a message is enqueued for a user, the
|
||||||
broker closes all waiting channels for that client, waking up any blocked
|
broker closes all waiting channels for that user, waking up any blocked
|
||||||
long-poll handlers. This is O(1) notification — no polling loops, no database
|
long-poll handlers. This is O(1) notification — no polling loops, no database
|
||||||
scanning.
|
scanning.
|
||||||
|
|
||||||
@@ -363,8 +327,8 @@ needs to revoke a token, change the expiry model, or add/remove claims, JWT
|
|||||||
clients may break or behave incorrectly.
|
clients may break or behave incorrectly.
|
||||||
|
|
||||||
Opaque tokens are simpler:
|
Opaque tokens are simpler:
|
||||||
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
|
- Server generates 32 random bytes → hex-encodes → stores hash
|
||||||
- Client presents the raw token; server hashes and looks it up
|
- Client presents the token; server looks it up
|
||||||
- Revocation is a database delete
|
- Revocation is a database delete
|
||||||
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
||||||
- Token format can change without breaking clients
|
- Token format can change without breaking clients
|
||||||
@@ -391,8 +355,6 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
|
|
||||||
### Session Lifecycle
|
### Session Lifecycle
|
||||||
|
|
||||||
#### Anonymous Session
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ Client ──────────────────────────────────────────────────┐
|
┌─ Client ──────────────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
@@ -423,30 +385,6 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
└────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Registered Account
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ Client ──────────────────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ 1. POST /api/v1/register │
|
|
||||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
|
||||||
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
|
|
||||||
│ (Session created with bcrypt-hashed password) │
|
|
||||||
│ │
|
|
||||||
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
|
|
||||||
│ │
|
|
||||||
│ (From another device, while session is still active) │
|
|
||||||
│ │
|
|
||||||
│ 2. POST /api/v1/login │
|
|
||||||
│ {"nick":"alice", "password":"s3cret!!"} │
|
|
||||||
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
|
|
||||||
│ (New client added to existing session — channels │
|
|
||||||
│ and message queues are preserved. If all clients │
|
|
||||||
│ have logged out, session no longer exists.) │
|
|
||||||
│ │
|
|
||||||
└────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Queue Architecture
|
### Queue Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -460,28 +398,28 @@ The entire read/write loop for a client is two endpoints. Everything else
|
|||||||
│ │ │
|
│ │ │
|
||||||
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
|
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
|
||||||
│client_queue│ │client_queue│ │client_queue│
|
│client_queue│ │client_queue│ │client_queue│
|
||||||
│ client_id=1│ │ client_id=2│ │ client_id=3│
|
│ user_id=1 │ │ user_id=2 │ │ user_id=3 │
|
||||||
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │
|
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │
|
||||||
└────────────┘ └────────────┘ └────────────┘
|
└────────────┘ └────────────┘ └────────────┘
|
||||||
alice bob carol
|
alice bob carol
|
||||||
|
|
||||||
Each message is stored ONCE. One queue entry per recipient client.
|
Each message is stored ONCE. One queue entry per recipient.
|
||||||
```
|
```
|
||||||
|
|
||||||
The `client_queues` table contains `(client_id, message_id)` pairs. When a
|
The `client_queues` table contains `(user_id, message_id)` pairs. When a
|
||||||
client polls with `GET /messages?after=<queue_id>`, the server queries for
|
client polls with `GET /messages?after=<queue_id>`, the server queries for
|
||||||
queue entries with `id > after` for that client, joins against the messages
|
queue entries with `id > after` for that user, joins against the messages
|
||||||
table, and returns the results. The `queue_id` (auto-incrementing primary
|
table, and returns the results. The `queue_id` (auto-incrementing primary
|
||||||
key of `client_queues`) serves as a monotonically increasing cursor.
|
key of `client_queues`) serves as a monotonically increasing cursor.
|
||||||
|
|
||||||
### In-Memory Broker
|
### In-Memory Broker
|
||||||
|
|
||||||
The server maintains an in-memory notification broker to avoid database
|
The server maintains an in-memory notification broker to avoid database
|
||||||
polling. The broker is a map of `client_id → []chan struct{}`. When a message
|
polling. The broker is a map of `user_id → []chan struct{}`. When a message
|
||||||
is enqueued for a client:
|
is enqueued for a user:
|
||||||
|
|
||||||
1. The handler calls `broker.Notify(clientID)`
|
1. The handler calls `broker.Notify(userID)`
|
||||||
2. The broker closes all waiting channels for that client
|
2. The broker closes all waiting channels for that user
|
||||||
3. Any goroutines blocked in `select` on those channels wake up
|
3. Any goroutines blocked in `select` on those channels wake up
|
||||||
4. The woken handler queries the database for new queue entries
|
4. The woken handler queries the database for new queue entries
|
||||||
5. Messages are returned to the client
|
5. Messages are returned to the client
|
||||||
@@ -1096,105 +1034,6 @@ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
|||||||
echo $TOKEN
|
echo $TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/v1/register — Register Account
|
|
||||||
|
|
||||||
Create a new user session with a password. The password is hashed
|
|
||||||
with bcrypt and stored server-side. The password enables login from
|
|
||||||
additional clients via `POST /api/v1/login` while the session
|
|
||||||
remains active.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{"nick": "alice", "password": "mypassword"}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
|
||||||
|------------|--------|----------|-------------|
|
|
||||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
|
||||||
| `password` | string | Yes | Minimum 8 characters |
|
|
||||||
|
|
||||||
**Response:** `201 Created`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"nick": "alice",
|
|
||||||
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---------|---------|-------------|
|
|
||||||
| `id` | integer | Server-assigned user ID |
|
|
||||||
| `nick` | string | Confirmed nick |
|
|
||||||
| `token` | string | 64-character hex auth token |
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
|
|
||||||
| Status | Error | When |
|
|
||||||
|--------|-------|------|
|
|
||||||
| 400 | `invalid nick format` | Nick doesn't match allowed format |
|
|
||||||
| 400 | `password must be at least 8 characters` | Password too short |
|
|
||||||
| 409 | `nick already taken` | Another active session holds this nick |
|
|
||||||
|
|
||||||
**curl example:**
|
|
||||||
```bash
|
|
||||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
|
||||||
echo $TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /api/v1/login — Login to Account
|
|
||||||
|
|
||||||
Authenticate with a previously registered nick and password. Creates a new
|
|
||||||
client token for the existing session, preserving channel memberships and
|
|
||||||
message queues. This is how multi-client access works for registered accounts:
|
|
||||||
each login adds a new client to the session.
|
|
||||||
|
|
||||||
On successful login, the server enqueues MOTD messages and synthetic channel
|
|
||||||
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
|
|
||||||
new client's queue, so the client can immediately restore its UI state.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{"nick": "alice", "password": "mypassword"}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Constraints |
|
|
||||||
|------------|--------|----------|-------------|
|
|
||||||
| `nick` | string | Yes | Must match a registered account |
|
|
||||||
| `password` | string | Yes | Must match the account's password |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"nick": "alice",
|
|
||||||
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---------|---------|-------------|
|
|
||||||
| `id` | integer | Session ID (same as when registered) |
|
|
||||||
| `nick` | string | Current nick |
|
|
||||||
| `token` | string | New 64-character hex auth token for this client |
|
|
||||||
|
|
||||||
**Errors:**
|
|
||||||
|
|
||||||
| Status | Error | When |
|
|
||||||
|--------|-------|------|
|
|
||||||
| 400 | `nick and password required` | Missing nick or password |
|
|
||||||
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
|
|
||||||
|
|
||||||
**curl example:**
|
|
||||||
```bash
|
|
||||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
|
|
||||||
echo $TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/v1/state — Get Session State
|
### GET /api/v1/state — Get Session State
|
||||||
|
|
||||||
Return the current user's session state.
|
Return the current user's session state.
|
||||||
@@ -1560,40 +1399,13 @@ Return server metadata. No authentication required.
|
|||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
Standard health check endpoint. No authentication required. Returns server
|
Standard health check endpoint. No authentication required.
|
||||||
health status and runtime statistics.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{"status": "ok"}
|
||||||
"status": "ok",
|
|
||||||
"now": "2024-01-15T12:00:00.000000000Z",
|
|
||||||
"uptimeSeconds": 3600,
|
|
||||||
"uptimeHuman": "1h0m0s",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"appname": "neoirc",
|
|
||||||
"maintenanceMode": false,
|
|
||||||
"sessions": 42,
|
|
||||||
"clients": 85,
|
|
||||||
"queuedLines": 128,
|
|
||||||
"channels": 7,
|
|
||||||
"connectionsSinceBoot": 200,
|
|
||||||
"sessionsSinceBoot": 150,
|
|
||||||
"messagesSinceBoot": 5000
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
| ---------------------- | ------------------------------------------------- |
|
|
||||||
| `sessions` | Current number of active sessions |
|
|
||||||
| `clients` | Current number of connected clients |
|
|
||||||
| `queuedLines` | Total entries in client output queues |
|
|
||||||
| `channels` | Current number of channels |
|
|
||||||
| `connectionsSinceBoot` | Total client connections since server start |
|
|
||||||
| `sessionsSinceBoot` | Total sessions created since server start |
|
|
||||||
| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Message Flow
|
## Message Flow
|
||||||
@@ -1778,16 +1590,9 @@ authenticity.
|
|||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
|
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
|
||||||
Tokens are hashed (SHA-256) before storage and validated on every request.
|
Tokens are stored in the database and validated on every request.
|
||||||
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
|
- **No passwords**: Session creation requires only a nick. The token is the
|
||||||
password, instant access. The token is the sole credential.
|
sole credential.
|
||||||
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
|
|
||||||
(minimum 8 characters). The password is hashed with bcrypt at the default
|
|
||||||
cost factor and stored alongside the session. `POST /api/v1/login`
|
|
||||||
authenticates against the stored hash and issues a new client token.
|
|
||||||
- **Password security**: Passwords are never stored in plain text. bcrypt
|
|
||||||
handles salting and key stretching automatically. Anonymous sessions have
|
|
||||||
an empty `password_hash` and cannot be logged into via `/login`.
|
|
||||||
- **Token security**: Tokens should be treated like session cookies. Transmit
|
- **Token security**: Tokens should be treated like session cookies. Transmit
|
||||||
only over HTTPS in production. If a token is compromised, the attacker has
|
only over HTTPS in production. If a token is compromised, the attacker has
|
||||||
full access to the session until QUIT or expiry.
|
full access to the session until QUIT or expiry.
|
||||||
@@ -1935,52 +1740,33 @@ The database schema is managed via embedded SQL migration files in
|
|||||||
|
|
||||||
**Current tables:**
|
**Current tables:**
|
||||||
|
|
||||||
#### `sessions`
|
#### `users`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|-----------------|----------|-------------|
|
|-------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
| `uuid` | TEXT | Unique session UUID |
|
| `nick` | TEXT | Unique nick |
|
||||||
| `nick` | TEXT | Unique nick |
|
| `token` | TEXT | Unique auth token (64 hex chars) |
|
||||||
| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
|
| `created_at`| DATETIME | Session creation time |
|
||||||
| `signing_key` | TEXT | Public signing key (empty string if unset) |
|
| `last_seen` | DATETIME | Last API request time |
|
||||||
| `away_message` | TEXT | Away message (empty string if not away) |
|
|
||||||
| `created_at` | DATETIME | Session creation time |
|
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
|
||||||
|
|
||||||
Index on `(uuid)`.
|
|
||||||
|
|
||||||
#### `clients`
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------------|----------|-------------|
|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
|
||||||
| `uuid` | TEXT | Unique client UUID |
|
|
||||||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
|
||||||
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
|
|
||||||
| `created_at` | DATETIME | Client creation time |
|
|
||||||
| `last_seen` | DATETIME | Last API request time |
|
|
||||||
|
|
||||||
Indexes on `(token)` and `(session_id)`.
|
|
||||||
|
|
||||||
#### `channels`
|
#### `channels`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|---------------|----------|-------------|
|
|-------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
| `name` | TEXT | Unique channel name (e.g., `#general`) |
|
| `name` | TEXT | Unique channel name (e.g., `#general`) |
|
||||||
| `topic` | TEXT | Channel topic (default empty) |
|
| `topic` | TEXT | Channel topic (default empty) |
|
||||||
| `topic_set_by`| TEXT | Nick of the user who set the topic (default empty) |
|
| `created_at`| DATETIME | Channel creation time |
|
||||||
| `topic_set_at`| DATETIME | When the topic was last set |
|
| `updated_at`| DATETIME | Last modification time |
|
||||||
| `created_at` | DATETIME | Channel creation time |
|
|
||||||
| `updated_at` | DATETIME | Last modification time |
|
|
||||||
|
|
||||||
#### `channel_members`
|
#### `channel_members`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|--------------|----------|-------------|
|
|-------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment) |
|
| `id` | INTEGER | Primary key (auto-increment) |
|
||||||
| `channel_id` | INTEGER | FK → channels.id (cascade delete) |
|
| `channel_id`| INTEGER | FK → channels.id |
|
||||||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
| `user_id` | INTEGER | FK → users.id |
|
||||||
| `joined_at` | DATETIME | When the user joined |
|
| `joined_at` | DATETIME | When the user joined |
|
||||||
|
|
||||||
Unique constraint on `(channel_id, session_id)`.
|
Unique constraint on `(channel_id, user_id)`.
|
||||||
|
|
||||||
#### `messages`
|
#### `messages`
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
@@ -1990,7 +1776,6 @@ Unique constraint on `(channel_id, session_id)`.
|
|||||||
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
|
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
|
||||||
| `msg_from` | TEXT | Sender nick |
|
| `msg_from` | TEXT | Sender nick |
|
||||||
| `msg_to` | TEXT | Target (`#channel` or nick) |
|
| `msg_to` | TEXT | Target (`#channel` or nick) |
|
||||||
| `params` | TEXT | JSON-encoded IRC-style positional parameters |
|
|
||||||
| `body` | TEXT | JSON-encoded body (array or object) |
|
| `body` | TEXT | JSON-encoded body (array or object) |
|
||||||
| `meta` | TEXT | JSON-encoded metadata |
|
| `meta` | TEXT | JSON-encoded metadata |
|
||||||
| `created_at`| DATETIME | Server timestamp |
|
| `created_at`| DATETIME | Server timestamp |
|
||||||
@@ -2001,11 +1786,11 @@ Indexes on `(msg_to, id)` and `(created_at)`.
|
|||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
|-------------|----------|-------------|
|
|-------------|----------|-------------|
|
||||||
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
|
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
|
||||||
| `client_id` | INTEGER | FK → clients.id (cascade delete) |
|
| `user_id` | INTEGER | FK → users.id |
|
||||||
| `message_id`| INTEGER | FK → messages.id (cascade delete) |
|
| `message_id`| INTEGER | FK → messages.id |
|
||||||
| `created_at`| DATETIME | When the entry was queued |
|
| `created_at`| DATETIME | When the entry was queued |
|
||||||
|
|
||||||
Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`.
|
Unique constraint on `(user_id, message_id)`. Index on `(user_id, id)`.
|
||||||
|
|
||||||
The `client_queues.id` is the monotonically increasing cursor used by
|
The `client_queues.id` is the monotonically increasing cursor used by
|
||||||
`GET /messages?after=<id>`. This is more reliable than timestamps (no clock
|
`GET /messages?after=<id>`. This is more reliable than timestamps (no clock
|
||||||
@@ -2018,19 +1803,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
|
|||||||
- **Client output queue entries**: Pruned automatically when older than
|
- **Client output queue entries**: Pruned automatically when older than
|
||||||
`QUEUE_MAX_AGE` (default 30 days).
|
`QUEUE_MAX_AGE` (default 30 days).
|
||||||
- **Channels**: Deleted when the last member leaves (ephemeral).
|
- **Channels**: Deleted when the last member leaves (ephemeral).
|
||||||
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
|
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
|
||||||
or when the last client logs out (`POST /api/v1/logout` with no remaining
|
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
|
||||||
clients triggers session cleanup). There is no distinction between session
|
30 days) — the server runs a background cleanup loop that parts idle users
|
||||||
types in the cleanup path — `handleQuit` and `cleanupUser` both call
|
from all channels, broadcasts QUIT, and releases their nicks.
|
||||||
`DeleteSession` unconditionally. Idle sessions are automatically expired
|
|
||||||
after `SESSION_IDLE_TIMEOUT`
|
|
||||||
(default 30 days) — the server runs a background cleanup loop that parts
|
|
||||||
idle users from all channels, broadcasts QUIT, and releases their nicks.
|
|
||||||
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
|
|
||||||
A session can have multiple clients; removing one doesn't affect others.
|
|
||||||
However, when the last client is removed (via logout), the entire session
|
|
||||||
is deleted — the user is parted from all channels, QUIT is broadcast, and
|
|
||||||
the nick is released.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2179,21 +1955,11 @@ A complete client needs only four HTTP calls:
|
|||||||
### Step-by-Step with curl
|
### Step-by-Step with curl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1a. Create an anonymous session (no account)
|
# 1. Create a session
|
||||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"nick":"testuser"}' | jq -r .token)
|
-d '{"nick":"testuser"}' | jq -r .token)
|
||||||
|
|
||||||
# 1b. Or register an account (multi-client support)
|
|
||||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
|
||||||
|
|
||||||
# 1c. Or login to an existing account
|
|
||||||
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
|
|
||||||
|
|
||||||
# 2. Join a channel
|
# 2. Join a channel
|
||||||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
@@ -2326,11 +2092,9 @@ Clients should handle these message commands from the queue:
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
|
- **HTTP 401**: Token expired or invalid. Re-create session.
|
||||||
re-login (registered account).
|
|
||||||
- **HTTP 404**: Channel or user not found.
|
- **HTTP 404**: Channel or user not found.
|
||||||
- **HTTP 409**: Nick already taken (on session creation, registration, or
|
- **HTTP 409**: Nick already taken (on session creation or NICK change).
|
||||||
NICK change).
|
|
||||||
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
- **HTTP 400**: Malformed request. Check the `error` field in the response.
|
||||||
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
|
||||||
|
|
||||||
@@ -2347,10 +2111,8 @@ Clients should handle these message commands from the queue:
|
|||||||
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
|
||||||
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
|
||||||
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
if `from` is you, use `to`; if `from` is someone else, use `from`.
|
||||||
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
|
5. **Reconnection**: If the poll loop fails with 401, the session is gone.
|
||||||
For anonymous sessions, create a new session. For registered accounts,
|
Create a new session. If it fails with a network error, retry with backoff.
|
||||||
log in again via `POST /api/v1/login` to get a fresh token on the same
|
|
||||||
session. If it fails with a network error, retry with backoff.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2570,8 +2332,6 @@ neoirc/
|
|||||||
│ │ └── healthcheck.go # Health check handler
|
│ │ └── healthcheck.go # Health check handler
|
||||||
│ ├── healthcheck/ # Health check logic
|
│ ├── healthcheck/ # Health check logic
|
||||||
│ │ └── healthcheck.go
|
│ │ └── healthcheck.go
|
||||||
│ ├── stats/ # Runtime statistics (atomic counters)
|
|
||||||
│ │ └── stats.go
|
|
||||||
│ ├── logger/ # slog-based logging
|
│ ├── logger/ # slog-based logging
|
||||||
│ │ └── logger.go
|
│ │ └── logger.go
|
||||||
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
|
│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth)
|
||||||
@@ -2623,13 +2383,9 @@ neoirc/
|
|||||||
build a working IRC-style TUI client against this API in an afternoon, the
|
build a working IRC-style TUI client against this API in an afternoon, the
|
||||||
API is too complex.
|
API is too complex.
|
||||||
|
|
||||||
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
|
2. **No accounts** — identity is a signing key, nick is a display name. No
|
||||||
talk. No registration, no email verification. The cost of entry is a
|
registration, no passwords, no email verification. Session creation is
|
||||||
hashcash proof, not bureaucracy. For users who want multi-client access
|
instant. The cost of entry is a hashcash proof, not bureaucracy.
|
||||||
(multiple devices sharing one session), optional account registration
|
|
||||||
with password is available — but never required. Identity
|
|
||||||
verification at the message layer uses cryptographic signing,
|
|
||||||
independent of account status.
|
|
||||||
|
|
||||||
3. **IRC semantics over HTTP** — command names and numeric codes from
|
3. **IRC semantics over HTTP** — command names and numeric codes from
|
||||||
RFC 1459/2812. If you've built an IRC client or bot, you already know the
|
RFC 1459/2812. If you've built an IRC client or bot, you already know the
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ func main() {
|
|||||||
server.New,
|
server.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
stats.New,
|
|
||||||
),
|
),
|
||||||
fx.Invoke(func(*server.Server) {}),
|
fx.Invoke(func(*server.Server) {}),
|
||||||
).Run()
|
).Run()
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
github.com/getsentry/sentry-go v0.42.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
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/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -746,8 +746,8 @@ func scanMessages(
|
|||||||
code, _ := strconv.Atoi(msg.Command)
|
code, _ := strconv.Atoi(msg.Command)
|
||||||
msg.Code = code
|
msg.Code = code
|
||||||
|
|
||||||
if mt, err := irc.FromInt(code); err == nil {
|
if name := irc.Name(code); name != "" {
|
||||||
msg.Command = mt.Name()
|
msg.Command = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,121 +1110,6 @@ func (database *Database) GetSessionCreatedAt(
|
|||||||
return createdAt, nil
|
return createdAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAway sets the away message for a session.
|
|
||||||
// An empty message clears the away status.
|
|
||||||
func (database *Database) SetAway(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
message string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
"UPDATE sessions SET away_message = ? WHERE id = ?",
|
|
||||||
message, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set away: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAway returns the away message for a session.
|
|
||||||
// Returns an empty string if the user is not away.
|
|
||||||
func (database *Database) GetAway(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (string, error) {
|
|
||||||
var msg string
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
"SELECT away_message FROM sessions WHERE id = ?",
|
|
||||||
sessionID,
|
|
||||||
).Scan(&msg)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get away: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTopicMeta sets the topic along with who set it and
|
|
||||||
// when.
|
|
||||||
func (database *Database) SetTopicMeta(
|
|
||||||
ctx context.Context,
|
|
||||||
channelName, topic, setBy string,
|
|
||||||
) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET topic = ?, topic_set_by = ?,
|
|
||||||
topic_set_at = ?, updated_at = ?
|
|
||||||
WHERE name = ?`,
|
|
||||||
topic, setBy, now, now, channelName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set topic meta: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TopicMeta holds topic metadata for a channel.
|
|
||||||
type TopicMeta struct {
|
|
||||||
SetBy string
|
|
||||||
SetAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTopicMeta returns who set the topic and when.
|
|
||||||
func (database *Database) GetTopicMeta(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (*TopicMeta, error) {
|
|
||||||
var (
|
|
||||||
setBy string
|
|
||||||
setAt sql.NullTime
|
|
||||||
)
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT topic_set_by, topic_set_at
|
|
||||||
FROM channels WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&setBy, &setAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"get topic meta: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if setBy == "" || !setAt.Valid {
|
|
||||||
return nil, nil //nolint:nilnil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TopicMeta{
|
|
||||||
SetBy: setBy,
|
|
||||||
SetAt: setAt.Time,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionLastSeen returns the last_seen time for a
|
|
||||||
// session.
|
|
||||||
func (database *Database) GetSessionLastSeen(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (time.Time, error) {
|
|
||||||
var lastSeen time.Time
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
"SELECT last_seen FROM sessions WHERE id = ?",
|
|
||||||
sessionID,
|
|
||||||
).Scan(&lastSeen)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"get session last_seen: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastSeen, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneOldQueueEntries deletes client output queue entries
|
// PruneOldQueueEntries deletes client output queue entries
|
||||||
// older than cutoff and returns the number of rows removed.
|
// older than cutoff and returns the number of rows removed.
|
||||||
func (database *Database) PruneOldQueueEntries(
|
func (database *Database) PruneOldQueueEntries(
|
||||||
@@ -1266,42 +1151,3 @@ func (database *Database) PruneOldMessages(
|
|||||||
|
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientCount returns the total number of clients.
|
|
||||||
func (database *Database) GetClientCount(
|
|
||||||
ctx context.Context,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT COUNT(*) FROM clients",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get client count: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetQueueEntryCount returns the total number of entries
|
|
||||||
// in the client output queues.
|
|
||||||
func (database *Database) GetQueueEntryCount(
|
|
||||||
ctx context.Context,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT COUNT(*) FROM client_queues",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get queue entry count: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
nick TEXT NOT NULL UNIQUE,
|
nick TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -31,8 +30,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
topic TEXT NOT NULL DEFAULT '',
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
|
||||||
topic_set_at DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -71,10 +71,11 @@ func (hdlr *Handlers) requireAuth(
|
|||||||
sessionID, clientID, nick, err :=
|
sessionID, clientID, nick, err :=
|
||||||
hdlr.authSession(request)
|
hdlr.authSession(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondError(
|
||||||
"error": "not registered",
|
writer, request,
|
||||||
"numeric": irc.ErrNotRegistered,
|
"unauthorized",
|
||||||
}, http.StatusUnauthorized)
|
http.StatusUnauthorized,
|
||||||
|
)
|
||||||
|
|
||||||
return 0, 0, "", false
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
@@ -212,9 +213,6 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.stats.IncrSessions()
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
@@ -427,12 +425,12 @@ func (hdlr *Handlers) serverName() string {
|
|||||||
func (hdlr *Handlers) enqueueNumeric(
|
func (hdlr *Handlers) enqueueNumeric(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
clientID int64,
|
clientID int64,
|
||||||
code irc.IRCMessageType,
|
code int,
|
||||||
nick string,
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
command := code.Code()
|
command := fmt.Sprintf("%03d", code)
|
||||||
|
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -839,11 +837,6 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdAway:
|
|
||||||
hdlr.handleAway(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -954,8 +947,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoRecipient, nick, []string{command},
|
irc.ErrNeedMoreParams, nick, []string{command},
|
||||||
"No recipient given",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -969,8 +962,8 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoTextToSend, nick, []string{command},
|
irc.ErrNeedMoreParams, nick, []string{command},
|
||||||
"No text to send",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -980,8 +973,6 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.stats.IncrMessages()
|
|
||||||
|
|
||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
hdlr.handleChannelMsg(
|
hdlr.handleChannelMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -1005,7 +996,7 @@ func (hdlr *Handlers) respondIRCError(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
code irc.IRCMessageType,
|
code int,
|
||||||
nick string,
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
@@ -1059,8 +1050,8 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrCannotSendToChan, nick, []string{target},
|
irc.ErrNotOnChannel, nick, []string{target},
|
||||||
"Cannot send to channel",
|
"You're not on that channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1156,19 +1147,6 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the target is away, send RPL_AWAY to the sender.
|
|
||||||
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
|
||||||
request.Context(), targetSID,
|
|
||||||
)
|
|
||||||
if awayErr == nil && awayMsg != "" {
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
request.Context(), clientID,
|
|
||||||
irc.RplAway, nick,
|
|
||||||
[]string{target}, awayMsg,
|
|
||||||
)
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": msgUUID, "status": "sent"},
|
map[string]string{"id": msgUUID, "status": "sent"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1279,25 +1257,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
hdlr.deliverTopicNumerics(
|
chInfo, err := hdlr.params.Database.GetChannelByName(
|
||||||
ctx, clientID, sessionID, nick, channel, chID,
|
ctx, channel,
|
||||||
)
|
)
|
||||||
|
if err == nil {
|
||||||
|
_ = chInfo // chInfo is the ID; topic comes from DB.
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.deliverNamesNumerics(
|
// Get topic from channel info.
|
||||||
ctx, clientID, nick, channel, chID,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC,
|
|
||||||
// plus RPL_TOPICWHOTIME when topic metadata is available.
|
|
||||||
func (hdlr *Handlers) deliverTopicNumerics(
|
|
||||||
ctx context.Context,
|
|
||||||
clientID, sessionID int64,
|
|
||||||
nick, channel string,
|
|
||||||
chID int64,
|
|
||||||
) {
|
|
||||||
channels, listErr := hdlr.params.Database.ListChannels(
|
channels, listErr := hdlr.params.Database.ListChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
@@ -1319,39 +1286,14 @@ func (hdlr *Handlers) deliverTopicNumerics(
|
|||||||
ctx, clientID, irc.RplTopic, nick,
|
ctx, clientID, irc.RplTopic, nick,
|
||||||
[]string{channel}, topic,
|
[]string{channel}, topic,
|
||||||
)
|
)
|
||||||
|
|
||||||
topicMeta, tmErr := hdlr.params.Database.
|
|
||||||
GetTopicMeta(ctx, chID)
|
|
||||||
if tmErr == nil && topicMeta != nil {
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID,
|
|
||||||
irc.RplTopicWhoTime, nick,
|
|
||||||
[]string{
|
|
||||||
channel,
|
|
||||||
topicMeta.SetBy,
|
|
||||||
strconv.FormatInt(
|
|
||||||
topicMeta.SetAt.Unix(), 10,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNoTopic, nick,
|
ctx, clientID, irc.RplNoTopic, nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]string{channel}, "No topic is set",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// deliverNamesNumerics sends RPL_NAMREPLY and
|
// Get member list for NAMES reply.
|
||||||
// RPL_ENDOFNAMES for a channel.
|
|
||||||
func (hdlr *Handlers) deliverNamesNumerics(
|
|
||||||
ctx context.Context,
|
|
||||||
clientID int64,
|
|
||||||
nick, channel string,
|
|
||||||
chID int64,
|
|
||||||
) {
|
|
||||||
members, memErr := hdlr.params.Database.ChannelMembers(
|
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
@@ -1374,6 +1316,8 @@ func (hdlr *Handlers) deliverNamesNumerics(
|
|||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, irc.RplEndOfNames, nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handlePart(
|
func (hdlr *Handlers) handlePart(
|
||||||
@@ -1641,32 +1585,6 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, err := hdlr.params.Database.IsChannelMember(
|
|
||||||
request.Context(), chID, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"check membership failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isMember {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNotOnChannel, nick, []string{channel},
|
|
||||||
"You're not on that channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.executeTopic(
|
hdlr.executeTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -1683,8 +1601,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
setErr := hdlr.params.Database.SetTopicMeta(
|
setErr := hdlr.params.Database.SetTopic(
|
||||||
request.Context(), channel, topic, nick,
|
request.Context(), channel, topic,
|
||||||
)
|
)
|
||||||
if setErr != nil {
|
if setErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -1711,25 +1629,6 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplTopic, nick, []string{channel}, topic,
|
irc.RplTopic, nick, []string{channel}, topic,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 333 RPL_TOPICWHOTIME
|
|
||||||
topicMeta, tmErr := hdlr.params.Database.
|
|
||||||
GetTopicMeta(request.Context(), chID)
|
|
||||||
if tmErr == nil && topicMeta != nil {
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
request.Context(), clientID,
|
|
||||||
irc.RplTopicWhoTime, nick,
|
|
||||||
[]string{
|
|
||||||
channel,
|
|
||||||
topicMeta.SetBy,
|
|
||||||
strconv.FormatInt(
|
|
||||||
topicMeta.SetAt.Unix(), 10,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -2119,11 +2018,6 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 317 RPL_WHOISIDLE
|
|
||||||
hdlr.deliverWhoisIdle(
|
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 319 RPL_WHOISCHANNELS
|
// 319 RPL_WHOISCHANNELS
|
||||||
hdlr.deliverWhoisChannels(
|
hdlr.deliverWhoisChannels(
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
@@ -2541,95 +2435,3 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAway handles the AWAY command. An empty body
|
|
||||||
// clears the away status; a non-empty body sets it.
|
|
||||||
func (hdlr *Handlers) handleAway(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
|
|
||||||
awayMsg := ""
|
|
||||||
if len(lines) > 0 {
|
|
||||||
awayMsg = strings.Join(lines, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hdlr.params.Database.SetAway(
|
|
||||||
ctx, sessionID, awayMsg,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error("set away failed", "error", err)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if awayMsg == "" {
|
|
||||||
// 305 RPL_UNAWAY
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUnaway, nick, nil,
|
|
||||||
"You are no longer marked as being away",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 306 RPL_NOWAWAY
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplNowAway, nick, nil,
|
|
||||||
"You have been marked as being away",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
|
|
||||||
// time and signon time.
|
|
||||||
func (hdlr *Handlers) deliverWhoisIdle(
|
|
||||||
ctx context.Context,
|
|
||||||
clientID int64,
|
|
||||||
nick, queryNick string,
|
|
||||||
targetSID int64,
|
|
||||||
) {
|
|
||||||
lastSeen, lsErr := hdlr.params.Database.
|
|
||||||
GetSessionLastSeen(ctx, targetSID)
|
|
||||||
if lsErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
createdAt, caErr := hdlr.params.Database.
|
|
||||||
GetSessionCreatedAt(ctx, targetSID)
|
|
||||||
if caErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idleSeconds := int64(time.Since(lastSeen).Seconds())
|
|
||||||
if idleSeconds < 0 {
|
|
||||||
idleSeconds = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
signonUnix := strconv.FormatInt(
|
|
||||||
createdAt.Unix(), 10,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplWhoisIdle, nick,
|
|
||||||
[]string{
|
|
||||||
queryNick,
|
|
||||||
strconv.FormatInt(idleSeconds, 10),
|
|
||||||
signonUnix,
|
|
||||||
},
|
|
||||||
"seconds idle, signon time",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
)
|
)
|
||||||
@@ -91,7 +90,6 @@ func newTestServer(
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
},
|
},
|
||||||
newTestDB,
|
newTestDB,
|
||||||
stats.New,
|
|
||||||
newTestHealthcheck,
|
newTestHealthcheck,
|
||||||
newTestMiddleware,
|
newTestMiddleware,
|
||||||
newTestHandlers,
|
newTestHandlers,
|
||||||
@@ -146,14 +144,12 @@ func newTestHealthcheck(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
log *logger.Logger,
|
log *logger.Logger,
|
||||||
database *db.Database,
|
database *db.Database,
|
||||||
tracker *stats.Tracker,
|
|
||||||
) (*healthcheck.Healthcheck, error) {
|
) (*healthcheck.Healthcheck, error) {
|
||||||
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
||||||
Globals: globs,
|
Globals: globs,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: log,
|
Logger: log,
|
||||||
Database: database,
|
Database: database,
|
||||||
Stats: tracker,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test healthcheck: %w", err)
|
return nil, fmt.Errorf("test healthcheck: %w", err)
|
||||||
@@ -187,7 +183,6 @@ func newTestHandlers(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
database *db.Database,
|
database *db.Database,
|
||||||
hcheck *healthcheck.Healthcheck,
|
hcheck *healthcheck.Healthcheck,
|
||||||
tracker *stats.Tracker,
|
|
||||||
) (*handlers.Handlers, error) {
|
) (*handlers.Handlers, error) {
|
||||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||||
Logger: log,
|
Logger: log,
|
||||||
@@ -195,7 +190,6 @@ func newTestHandlers(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Database: database,
|
Database: database,
|
||||||
Healthcheck: hcheck,
|
Healthcheck: hcheck,
|
||||||
Stats: tracker,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test handlers: %w", err)
|
return nil, fmt.Errorf("test handlers: %w", err)
|
||||||
@@ -817,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "412") {
|
if !findNumeric(msgs, "461") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOTEXTTOSEND (412), got %v",
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -841,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "411") {
|
if !findNumeric(msgs, "461") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NORECIPIENT (411), got %v",
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -876,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
|
|
||||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||||
|
|
||||||
if !findNumeric(msgs, "404") {
|
if !findNumeric(msgs, "442") {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||||
msgs,
|
msgs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1140,42 +1134,6 @@ func TestTopicMissingBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTopicNonMember(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
aliceToken := tserver.createSession("alice_topic")
|
|
||||||
bobToken := tserver.createSession("bob_topic")
|
|
||||||
|
|
||||||
// Only alice joins the channel.
|
|
||||||
tserver.sendCommand(aliceToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#topicpriv",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Drain bob's initial messages.
|
|
||||||
_, lastID := tserver.pollMessages(bobToken, 0)
|
|
||||||
|
|
||||||
// Bob tries to set topic without joining.
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
bobToken,
|
|
||||||
map[string]any{
|
|
||||||
commandKey: "TOPIC",
|
|
||||||
toKey: "#topicpriv",
|
|
||||||
bodyKey: []string{"Hijacked topic"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "442") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPing(t *testing.T) {
|
func TestPing(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("ping_user")
|
token := tserver.createSession("ping_user")
|
||||||
@@ -1699,133 +1657,6 @@ func TestHealthcheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
resp, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodGet,
|
|
||||||
tserver.url("/.well-known/healthcheck.json"),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 200, got %d", resp.StatusCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
|
|
||||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
if decErr != nil {
|
|
||||||
t.Fatalf("decode healthcheck: %v", decErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
requiredFields := []string{
|
|
||||||
"sessions", "clients", "queuedLines",
|
|
||||||
"channels", "connectionsSinceBoot",
|
|
||||||
"sessionsSinceBoot", "messagesSinceBoot",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, field := range requiredFields {
|
|
||||||
if _, ok := result[field]; !ok {
|
|
||||||
t.Errorf(
|
|
||||||
"missing field %q in healthcheck", field,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("statsuser")
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#statschan",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: privmsgCmd,
|
|
||||||
toKey: "#statschan",
|
|
||||||
bodyKey: []string{"hello stats"},
|
|
||||||
})
|
|
||||||
|
|
||||||
result := tserver.fetchHealthcheck(t)
|
|
||||||
|
|
||||||
assertFieldGTE(t, result, "sessions", 1)
|
|
||||||
assertFieldGTE(t, result, "clients", 1)
|
|
||||||
assertFieldGTE(t, result, "channels", 1)
|
|
||||||
assertFieldGTE(t, result, "queuedLines", 0)
|
|
||||||
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
|
|
||||||
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
|
|
||||||
assertFieldGTE(t, result, "messagesSinceBoot", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tserver *testServer) fetchHealthcheck(
|
|
||||||
t *testing.T,
|
|
||||||
) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
resp, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodGet,
|
|
||||||
tserver.url("/.well-known/healthcheck.json"),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 200, got %d", resp.StatusCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
|
|
||||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
if decErr != nil {
|
|
||||||
t.Fatalf("decode healthcheck: %v", decErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertFieldGTE(
|
|
||||||
t *testing.T,
|
|
||||||
result map[string]any,
|
|
||||||
field string,
|
|
||||||
minimum float64,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
val, ok := result[field].(float64)
|
|
||||||
if !ok {
|
|
||||||
t.Errorf(
|
|
||||||
"field %q: not a number (got %T)",
|
|
||||||
field, result[field],
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if val < minimum {
|
|
||||||
t.Errorf(
|
|
||||||
"expected %s >= %v, got %v",
|
|
||||||
field, minimum, val,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterValid(t *testing.T) {
|
func TestRegisterValid(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,6 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.stats.IncrSessions()
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
@@ -183,8 +180,6 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Stats *stats.Tracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
@@ -43,7 +41,6 @@ type Handlers struct {
|
|||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
stats *stats.Tracker
|
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +60,6 @@ func New(
|
|||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
stats: params.Stats,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
resp := hdlr.hc.Healthcheck(request.Context())
|
resp := hdlr.hc.Healthcheck()
|
||||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Stats *stats.Tracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck tracks server uptime and provides health status.
|
// Healthcheck tracks server uptime and provides health status.
|
||||||
@@ -66,22 +64,11 @@ type Response struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Appname string `json:"appname"`
|
Appname string `json:"appname"`
|
||||||
Maintenance bool `json:"maintenanceMode"`
|
Maintenance bool `json:"maintenanceMode"`
|
||||||
|
|
||||||
// Runtime statistics.
|
|
||||||
Sessions int64 `json:"sessions"`
|
|
||||||
Clients int64 `json:"clients"`
|
|
||||||
QueuedLines int64 `json:"queuedLines"`
|
|
||||||
Channels int64 `json:"channels"`
|
|
||||||
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
|
|
||||||
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
|
|
||||||
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck returns the current health status of the server.
|
// Healthcheck returns the current health status of the server.
|
||||||
func (hcheck *Healthcheck) Healthcheck(
|
func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||||
ctx context.Context,
|
return &Response{
|
||||||
) *Response {
|
|
||||||
resp := &Response{
|
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||||
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
|
|||||||
Appname: hcheck.params.Globals.Appname,
|
Appname: hcheck.params.Globals.Appname,
|
||||||
Version: hcheck.params.Globals.Version,
|
Version: hcheck.params.Globals.Version,
|
||||||
Maintenance: hcheck.params.Config.MaintenanceMode,
|
Maintenance: hcheck.params.Config.MaintenanceMode,
|
||||||
|
|
||||||
Sessions: 0,
|
|
||||||
Clients: 0,
|
|
||||||
QueuedLines: 0,
|
|
||||||
Channels: 0,
|
|
||||||
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
|
|
||||||
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
|
|
||||||
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
|
|
||||||
}
|
|
||||||
|
|
||||||
hcheck.populateDBStats(ctx, resp)
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// populateDBStats fills in database-derived counters.
|
|
||||||
func (hcheck *Healthcheck) populateDBStats(
|
|
||||||
ctx context.Context,
|
|
||||||
resp *Response,
|
|
||||||
) {
|
|
||||||
sessions, err := hcheck.params.Database.GetUserCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: session count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Sessions = sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
clients, err := hcheck.params.Database.GetClientCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: client count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Clients = clients
|
|
||||||
}
|
|
||||||
|
|
||||||
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: queue entry count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.QueuedLines = queued
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, err := hcheck.params.Database.GetChannelCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: channel count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Channels = channels
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAway = "AWAY"
|
|
||||||
CmdJoin = "JOIN"
|
CmdJoin = "JOIN"
|
||||||
CmdList = "LIST"
|
CmdList = "LIST"
|
||||||
CmdLusers = "LUSERS"
|
CmdLusers = "LUSERS"
|
||||||
150
internal/irc/numerics.go
Normal file
150
internal/irc/numerics.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Package irc provides constants and utilities for the
|
||||||
|
// IRC protocol, including numeric reply codes from
|
||||||
|
// RFC 1459 and RFC 2812, and standard command names.
|
||||||
|
package irc
|
||||||
|
|
||||||
|
// Connection registration replies (001-005).
|
||||||
|
const (
|
||||||
|
RplWelcome = 1
|
||||||
|
RplYourHost = 2
|
||||||
|
RplCreated = 3
|
||||||
|
RplMyInfo = 4
|
||||||
|
RplIsupport = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command responses (200-399).
|
||||||
|
const (
|
||||||
|
RplUmodeIs = 221
|
||||||
|
RplLuserClient = 251
|
||||||
|
RplLuserOp = 252
|
||||||
|
RplLuserUnknown = 253
|
||||||
|
RplLuserChannels = 254
|
||||||
|
RplLuserMe = 255
|
||||||
|
RplAway = 301
|
||||||
|
RplUserHost = 302
|
||||||
|
RplIson = 303
|
||||||
|
RplUnaway = 305
|
||||||
|
RplNowAway = 306
|
||||||
|
RplWhoisUser = 311
|
||||||
|
RplWhoisServer = 312
|
||||||
|
RplWhoisOperator = 313
|
||||||
|
RplEndOfWho = 315
|
||||||
|
RplWhoisIdle = 317
|
||||||
|
RplEndOfWhois = 318
|
||||||
|
RplWhoisChannels = 319
|
||||||
|
RplList = 322
|
||||||
|
RplListEnd = 323
|
||||||
|
RplChannelModeIs = 324
|
||||||
|
RplCreationTime = 329
|
||||||
|
RplNoTopic = 331
|
||||||
|
RplTopic = 332
|
||||||
|
RplTopicWhoTime = 333
|
||||||
|
RplInviting = 341
|
||||||
|
RplWhoReply = 352
|
||||||
|
RplNamReply = 353
|
||||||
|
RplEndOfNames = 366
|
||||||
|
RplBanList = 367
|
||||||
|
RplEndOfBanList = 368
|
||||||
|
RplMotd = 372
|
||||||
|
RplMotdStart = 375
|
||||||
|
RplEndOfMotd = 376
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error replies (400-599).
|
||||||
|
const (
|
||||||
|
ErrNoSuchNick = 401
|
||||||
|
ErrNoSuchServer = 402
|
||||||
|
ErrNoSuchChannel = 403
|
||||||
|
ErrCannotSendToChan = 404
|
||||||
|
ErrTooManyChannels = 405
|
||||||
|
ErrNoRecipient = 411
|
||||||
|
ErrNoTextToSend = 412
|
||||||
|
ErrUnknownCommand = 421
|
||||||
|
ErrNoNicknameGiven = 431
|
||||||
|
ErrErroneusNickname = 432
|
||||||
|
ErrNicknameInUse = 433
|
||||||
|
ErrUserNotInChannel = 441
|
||||||
|
ErrNotOnChannel = 442
|
||||||
|
ErrNotRegistered = 451
|
||||||
|
ErrNeedMoreParams = 461
|
||||||
|
ErrAlreadyRegistered = 462
|
||||||
|
ErrChannelIsFull = 471
|
||||||
|
ErrInviteOnlyChan = 473
|
||||||
|
ErrBannedFromChan = 474
|
||||||
|
ErrBadChannelKey = 475
|
||||||
|
ErrChanOpPrivsNeeded = 482
|
||||||
|
)
|
||||||
|
|
||||||
|
// names maps numeric codes to their standard IRC names.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var names = map[int]string{
|
||||||
|
RplWelcome: "RPL_WELCOME",
|
||||||
|
RplYourHost: "RPL_YOURHOST",
|
||||||
|
RplCreated: "RPL_CREATED",
|
||||||
|
RplMyInfo: "RPL_MYINFO",
|
||||||
|
RplIsupport: "RPL_ISUPPORT",
|
||||||
|
RplUmodeIs: "RPL_UMODEIS",
|
||||||
|
RplLuserClient: "RPL_LUSERCLIENT",
|
||||||
|
RplLuserOp: "RPL_LUSEROP",
|
||||||
|
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||||
|
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||||
|
RplLuserMe: "RPL_LUSERME",
|
||||||
|
RplAway: "RPL_AWAY",
|
||||||
|
RplUserHost: "RPL_USERHOST",
|
||||||
|
RplIson: "RPL_ISON",
|
||||||
|
RplUnaway: "RPL_UNAWAY",
|
||||||
|
RplNowAway: "RPL_NOWAWAY",
|
||||||
|
RplWhoisUser: "RPL_WHOISUSER",
|
||||||
|
RplWhoisServer: "RPL_WHOISSERVER",
|
||||||
|
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||||
|
RplEndOfWho: "RPL_ENDOFWHO",
|
||||||
|
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||||
|
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||||
|
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||||
|
RplList: "RPL_LIST",
|
||||||
|
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||||
|
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||||
|
RplCreationTime: "RPL_CREATIONTIME",
|
||||||
|
RplNoTopic: "RPL_NOTOPIC",
|
||||||
|
RplTopic: "RPL_TOPIC",
|
||||||
|
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||||
|
RplInviting: "RPL_INVITING",
|
||||||
|
RplWhoReply: "RPL_WHOREPLY",
|
||||||
|
RplNamReply: "RPL_NAMREPLY",
|
||||||
|
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||||
|
RplBanList: "RPL_BANLIST",
|
||||||
|
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||||
|
RplMotd: "RPL_MOTD",
|
||||||
|
RplMotdStart: "RPL_MOTDSTART",
|
||||||
|
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||||
|
|
||||||
|
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||||
|
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||||
|
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||||
|
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||||
|
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||||
|
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||||
|
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||||
|
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||||
|
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||||
|
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||||
|
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||||
|
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||||
|
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||||
|
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||||
|
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||||
|
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||||
|
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||||
|
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||||
|
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||||
|
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||||
|
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the standard IRC name for a numeric code
|
||||||
|
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
||||||
|
// empty string if the code is unknown.
|
||||||
|
func Name(code int) string {
|
||||||
|
return names[code]
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// Package stats tracks runtime statistics since server boot.
|
|
||||||
package stats
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tracker holds atomic counters for runtime statistics
|
|
||||||
// that accumulate since the server started.
|
|
||||||
type Tracker struct {
|
|
||||||
connectionsSinceBoot atomic.Int64
|
|
||||||
sessionsSinceBoot atomic.Int64
|
|
||||||
messagesSinceBoot atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Tracker with all counters at zero.
|
|
||||||
func New() *Tracker {
|
|
||||||
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrConnections increments the total connection count.
|
|
||||||
func (t *Tracker) IncrConnections() {
|
|
||||||
t.connectionsSinceBoot.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrSessions increments the total session count.
|
|
||||||
func (t *Tracker) IncrSessions() {
|
|
||||||
t.sessionsSinceBoot.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrMessages increments the total PRIVMSG/NOTICE count.
|
|
||||||
func (t *Tracker) IncrMessages() {
|
|
||||||
t.messagesSinceBoot.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectionsSinceBoot returns the total number of
|
|
||||||
// client connections since boot.
|
|
||||||
func (t *Tracker) ConnectionsSinceBoot() int64 {
|
|
||||||
return t.connectionsSinceBoot.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionsSinceBoot returns the total number of sessions
|
|
||||||
// created since boot.
|
|
||||||
func (t *Tracker) SessionsSinceBoot() int64 {
|
|
||||||
return t.sessionsSinceBoot.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessagesSinceBoot returns the total number of
|
|
||||||
// PRIVMSG/NOTICE messages sent since boot.
|
|
||||||
func (t *Tracker) MessagesSinceBoot() int64 {
|
|
||||||
return t.messagesSinceBoot.Load()
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package stats_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
if tracker == nil {
|
|
||||||
t.Fatal("expected non-nil tracker")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.ConnectionsSinceBoot() != 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 0 connections, got %d",
|
|
||||||
tracker.ConnectionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.SessionsSinceBoot() != 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 0 sessions, got %d",
|
|
||||||
tracker.SessionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.MessagesSinceBoot() != 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 0 messages, got %d",
|
|
||||||
tracker.MessagesSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrConnections(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrConnections()
|
|
||||||
tracker.IncrConnections()
|
|
||||||
tracker.IncrConnections()
|
|
||||||
|
|
||||||
got := tracker.ConnectionsSinceBoot()
|
|
||||||
if got != 3 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 3 connections, got %d", got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrSessions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrSessions()
|
|
||||||
tracker.IncrSessions()
|
|
||||||
|
|
||||||
got := tracker.SessionsSinceBoot()
|
|
||||||
if got != 2 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 2 sessions, got %d", got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrMessages(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrMessages()
|
|
||||||
|
|
||||||
got := tracker.MessagesSinceBoot()
|
|
||||||
if got != 1 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 1 message, got %d", got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountersAreIndependent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrConnections()
|
|
||||||
tracker.IncrSessions()
|
|
||||||
tracker.IncrMessages()
|
|
||||||
tracker.IncrMessages()
|
|
||||||
|
|
||||||
if tracker.ConnectionsSinceBoot() != 1 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 1 connection, got %d",
|
|
||||||
tracker.ConnectionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.SessionsSinceBoot() != 1 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 1 session, got %d",
|
|
||||||
tracker.SessionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.MessagesSinceBoot() != 2 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 2 messages, got %d",
|
|
||||||
tracker.MessagesSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IRCMessageType represents an IRC numeric reply or error code.
|
|
||||||
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for this numeric code
|
|
||||||
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
|
||||||
// Returns an empty string if the code is unknown.
|
|
||||||
func (t IRCMessageType) Name() string {
|
|
||||||
return names[t]
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the name and numeric code in angle brackets
|
|
||||||
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
|
||||||
// If the code is unknown, returns "UNKNOWN <NNN>".
|
|
||||||
func (t IRCMessageType) String() string {
|
|
||||||
n := names[t]
|
|
||||||
if n == "" {
|
|
||||||
n = "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s <%03d>", n, int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code returns the three-digit zero-padded string representation
|
|
||||||
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
|
||||||
func (t IRCMessageType) Code() string {
|
|
||||||
return fmt.Sprintf("%03d", int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Int returns the bare integer value of the numeric code.
|
|
||||||
func (t IRCMessageType) Int() int {
|
|
||||||
return int(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
|
||||||
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
|
||||||
|
|
||||||
// FromInt converts an integer to an IRCMessageType, returning an error
|
|
||||||
// if the numeric code is not a known IRC reply or error code.
|
|
||||||
func FromInt(n int) (IRCMessageType, error) {
|
|
||||||
t := IRCMessageType(n)
|
|
||||||
if _, ok := names[t]; !ok {
|
|
||||||
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome IRCMessageType = 1
|
|
||||||
RplYourHost IRCMessageType = 2
|
|
||||||
RplCreated IRCMessageType = 3
|
|
||||||
RplMyInfo IRCMessageType = 4
|
|
||||||
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
|
||||||
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
// RFC 2812 trace/stats/links replies (200-219).
|
|
||||||
RplTraceLink IRCMessageType = 200
|
|
||||||
RplTraceConnecting IRCMessageType = 201
|
|
||||||
RplTraceHandshake IRCMessageType = 202
|
|
||||||
RplTraceUnknown IRCMessageType = 203
|
|
||||||
RplTraceOperator IRCMessageType = 204
|
|
||||||
RplTraceUser IRCMessageType = 205
|
|
||||||
RplTraceServer IRCMessageType = 206
|
|
||||||
RplTraceService IRCMessageType = 207
|
|
||||||
RplTraceNewType IRCMessageType = 208
|
|
||||||
RplTraceClass IRCMessageType = 209
|
|
||||||
RplStatsLinkInfo IRCMessageType = 211
|
|
||||||
RplStatsCommands IRCMessageType = 212
|
|
||||||
RplStatsCLine IRCMessageType = 213
|
|
||||||
RplStatsNLine IRCMessageType = 214
|
|
||||||
RplStatsILine IRCMessageType = 215
|
|
||||||
RplStatsKLine IRCMessageType = 216
|
|
||||||
RplStatsQLine IRCMessageType = 217
|
|
||||||
RplStatsYLine IRCMessageType = 218
|
|
||||||
RplEndOfStats IRCMessageType = 219
|
|
||||||
|
|
||||||
RplUmodeIs IRCMessageType = 221
|
|
||||||
RplServList IRCMessageType = 234
|
|
||||||
RplServListEnd IRCMessageType = 235
|
|
||||||
RplStatsLLine IRCMessageType = 241
|
|
||||||
RplStatsUptime IRCMessageType = 242
|
|
||||||
RplStatsOLine IRCMessageType = 243
|
|
||||||
RplStatsHLine IRCMessageType = 244
|
|
||||||
RplLuserClient IRCMessageType = 251
|
|
||||||
RplLuserOp IRCMessageType = 252
|
|
||||||
RplLuserUnknown IRCMessageType = 253
|
|
||||||
|
|
||||||
RplLuserChannels IRCMessageType = 254
|
|
||||||
RplLuserMe IRCMessageType = 255
|
|
||||||
RplAdminMe IRCMessageType = 256
|
|
||||||
RplAdminLoc1 IRCMessageType = 257
|
|
||||||
RplAdminLoc2 IRCMessageType = 258
|
|
||||||
RplAdminEmail IRCMessageType = 259
|
|
||||||
RplTraceLog IRCMessageType = 261
|
|
||||||
RplTraceEnd IRCMessageType = 262
|
|
||||||
RplTryAgain IRCMessageType = 263
|
|
||||||
|
|
||||||
RplAway IRCMessageType = 301
|
|
||||||
RplUserHost IRCMessageType = 302
|
|
||||||
RplIson IRCMessageType = 303
|
|
||||||
RplUnaway IRCMessageType = 305
|
|
||||||
RplNowAway IRCMessageType = 306
|
|
||||||
RplWhoisUser IRCMessageType = 311
|
|
||||||
RplWhoisServer IRCMessageType = 312
|
|
||||||
RplWhoisOperator IRCMessageType = 313
|
|
||||||
RplWhoWasUser IRCMessageType = 314
|
|
||||||
RplEndOfWho IRCMessageType = 315
|
|
||||||
RplWhoisIdle IRCMessageType = 317
|
|
||||||
RplEndOfWhois IRCMessageType = 318
|
|
||||||
RplWhoisChannels IRCMessageType = 319
|
|
||||||
RplListStart IRCMessageType = 321
|
|
||||||
RplList IRCMessageType = 322
|
|
||||||
RplListEnd IRCMessageType = 323
|
|
||||||
RplChannelModeIs IRCMessageType = 324
|
|
||||||
|
|
||||||
RplUniqOpIs IRCMessageType = 325
|
|
||||||
RplCreationTime IRCMessageType = 329
|
|
||||||
RplNoTopic IRCMessageType = 331
|
|
||||||
RplTopic IRCMessageType = 332
|
|
||||||
RplTopicWhoTime IRCMessageType = 333
|
|
||||||
RplInviting IRCMessageType = 341
|
|
||||||
RplSummoning IRCMessageType = 342
|
|
||||||
RplInviteList IRCMessageType = 346
|
|
||||||
RplEndOfInviteList IRCMessageType = 347
|
|
||||||
RplExceptList IRCMessageType = 348
|
|
||||||
RplEndOfExceptList IRCMessageType = 349
|
|
||||||
RplVersion IRCMessageType = 351
|
|
||||||
RplWhoReply IRCMessageType = 352
|
|
||||||
RplNamReply IRCMessageType = 353
|
|
||||||
RplLinks IRCMessageType = 364
|
|
||||||
RplEndOfLinks IRCMessageType = 365
|
|
||||||
RplEndOfNames IRCMessageType = 366
|
|
||||||
RplBanList IRCMessageType = 367
|
|
||||||
RplEndOfBanList IRCMessageType = 368
|
|
||||||
RplEndOfWhowas IRCMessageType = 369
|
|
||||||
RplInfo IRCMessageType = 371
|
|
||||||
RplMotd IRCMessageType = 372
|
|
||||||
RplEndOfInfo IRCMessageType = 374
|
|
||||||
RplMotdStart IRCMessageType = 375
|
|
||||||
RplEndOfMotd IRCMessageType = 376
|
|
||||||
RplYoureOper IRCMessageType = 381
|
|
||||||
RplRehashing IRCMessageType = 382
|
|
||||||
RplYoureService IRCMessageType = 383
|
|
||||||
RplTime IRCMessageType = 391
|
|
||||||
RplUsersStart IRCMessageType = 392
|
|
||||||
RplUsers IRCMessageType = 393
|
|
||||||
RplEndOfUsers IRCMessageType = 394
|
|
||||||
RplNoUsers IRCMessageType = 395
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick IRCMessageType = 401
|
|
||||||
ErrNoSuchServer IRCMessageType = 402
|
|
||||||
ErrNoSuchChannel IRCMessageType = 403
|
|
||||||
ErrCannotSendToChan IRCMessageType = 404
|
|
||||||
ErrTooManyChannels IRCMessageType = 405
|
|
||||||
ErrWasNoSuchNick IRCMessageType = 406
|
|
||||||
ErrTooManyTargets IRCMessageType = 407
|
|
||||||
ErrNoSuchService IRCMessageType = 408
|
|
||||||
ErrNoOrigin IRCMessageType = 409
|
|
||||||
ErrNoRecipient IRCMessageType = 411
|
|
||||||
ErrNoTextToSend IRCMessageType = 412
|
|
||||||
ErrNoTopLevel IRCMessageType = 413
|
|
||||||
ErrWildTopLevel IRCMessageType = 414
|
|
||||||
ErrBadMask IRCMessageType = 415
|
|
||||||
ErrUnknownCommand IRCMessageType = 421
|
|
||||||
ErrNoMotd IRCMessageType = 422
|
|
||||||
ErrNoAdminInfo IRCMessageType = 423
|
|
||||||
ErrFileError IRCMessageType = 424
|
|
||||||
ErrNoNicknameGiven IRCMessageType = 431
|
|
||||||
ErrErroneusNickname IRCMessageType = 432
|
|
||||||
ErrNicknameInUse IRCMessageType = 433
|
|
||||||
ErrNickCollision IRCMessageType = 436
|
|
||||||
ErrUnavailResource IRCMessageType = 437
|
|
||||||
|
|
||||||
ErrUserNotInChannel IRCMessageType = 441
|
|
||||||
ErrNotOnChannel IRCMessageType = 442
|
|
||||||
ErrUserOnChannel IRCMessageType = 443
|
|
||||||
ErrNoLogin IRCMessageType = 444
|
|
||||||
ErrSummonDisabled IRCMessageType = 445
|
|
||||||
ErrUsersDisabled IRCMessageType = 446
|
|
||||||
ErrNotRegistered IRCMessageType = 451
|
|
||||||
ErrNeedMoreParams IRCMessageType = 461
|
|
||||||
ErrAlreadyRegistered IRCMessageType = 462
|
|
||||||
ErrNoPermForHost IRCMessageType = 463
|
|
||||||
ErrPasswdMismatch IRCMessageType = 464
|
|
||||||
ErrYoureBannedCreep IRCMessageType = 465
|
|
||||||
ErrYouWillBeBanned IRCMessageType = 466
|
|
||||||
ErrKeySet IRCMessageType = 467
|
|
||||||
ErrChannelIsFull IRCMessageType = 471
|
|
||||||
ErrUnknownMode IRCMessageType = 472
|
|
||||||
ErrInviteOnlyChan IRCMessageType = 473
|
|
||||||
ErrBannedFromChan IRCMessageType = 474
|
|
||||||
ErrBadChannelKey IRCMessageType = 475
|
|
||||||
ErrBadChanMask IRCMessageType = 476
|
|
||||||
ErrNoChanModes IRCMessageType = 477
|
|
||||||
ErrBanListFull IRCMessageType = 478
|
|
||||||
ErrNoPrivileges IRCMessageType = 481
|
|
||||||
ErrChanOpPrivsNeeded IRCMessageType = 482
|
|
||||||
ErrCantKillServer IRCMessageType = 483
|
|
||||||
ErrRestricted IRCMessageType = 484
|
|
||||||
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
|
||||||
ErrNoOperHost IRCMessageType = 491
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag IRCMessageType = 501
|
|
||||||
ErrUsersDoNotMatch IRCMessageType = 502
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[IRCMessageType]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplBounce: "RPL_BOUNCE",
|
|
||||||
|
|
||||||
RplTraceLink: "RPL_TRACELINK",
|
|
||||||
RplTraceConnecting: "RPL_TRACECONNECTING",
|
|
||||||
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
|
||||||
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
|
||||||
RplTraceOperator: "RPL_TRACEOPERATOR",
|
|
||||||
RplTraceUser: "RPL_TRACEUSER",
|
|
||||||
RplTraceServer: "RPL_TRACESERVER",
|
|
||||||
RplTraceService: "RPL_TRACESERVICE",
|
|
||||||
RplTraceNewType: "RPL_TRACENEWTYPE",
|
|
||||||
RplTraceClass: "RPL_TRACECLASS",
|
|
||||||
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
|
||||||
RplStatsCommands: "RPL_STATSCOMMANDS",
|
|
||||||
RplStatsCLine: "RPL_STATSCLINE",
|
|
||||||
RplStatsNLine: "RPL_STATSNLINE",
|
|
||||||
RplStatsILine: "RPL_STATSILINE",
|
|
||||||
RplStatsKLine: "RPL_STATSKLINE",
|
|
||||||
RplStatsQLine: "RPL_STATSQLINE",
|
|
||||||
RplStatsYLine: "RPL_STATSYLINE",
|
|
||||||
RplEndOfStats: "RPL_ENDOFSTATS",
|
|
||||||
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplServList: "RPL_SERVLIST",
|
|
||||||
RplServListEnd: "RPL_SERVLISTEND",
|
|
||||||
RplStatsLLine: "RPL_STATSLLINE",
|
|
||||||
RplStatsUptime: "RPL_STATSUPTIME",
|
|
||||||
RplStatsOLine: "RPL_STATSOLINE",
|
|
||||||
RplStatsHLine: "RPL_STATSHLINE",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAdminMe: "RPL_ADMINME",
|
|
||||||
RplAdminLoc1: "RPL_ADMINLOC1",
|
|
||||||
RplAdminLoc2: "RPL_ADMINLOC2",
|
|
||||||
RplAdminEmail: "RPL_ADMINEMAIL",
|
|
||||||
RplTraceLog: "RPL_TRACELOG",
|
|
||||||
RplTraceEnd: "RPL_TRACEEND",
|
|
||||||
RplTryAgain: "RPL_TRYAGAIN",
|
|
||||||
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplWhoWasUser: "RPL_WHOWASUSER",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplListStart: "RPL_LISTSTART",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
|
|
||||||
RplUniqOpIs: "RPL_UNIQOPIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplSummoning: "RPL_SUMMONING",
|
|
||||||
RplInviteList: "RPL_INVITELIST",
|
|
||||||
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
|
||||||
RplExceptList: "RPL_EXCEPTLIST",
|
|
||||||
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
|
||||||
RplVersion: "RPL_VERSION",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplLinks: "RPL_LINKS",
|
|
||||||
RplEndOfLinks: "RPL_ENDOFLINKS",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
|
||||||
RplInfo: "RPL_INFO",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplEndOfInfo: "RPL_ENDOFINFO",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
RplYoureOper: "RPL_YOUREOPER",
|
|
||||||
RplRehashing: "RPL_REHASHING",
|
|
||||||
RplYoureService: "RPL_YOURESERVICE",
|
|
||||||
RplTime: "RPL_TIME",
|
|
||||||
RplUsersStart: "RPL_USERSSTART",
|
|
||||||
RplUsers: "RPL_USERS",
|
|
||||||
RplEndOfUsers: "RPL_ENDOFUSERS",
|
|
||||||
RplNoUsers: "RPL_NOUSERS",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
|
||||||
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
|
||||||
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
|
||||||
ErrNoOrigin: "ERR_NOORIGIN",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
|
||||||
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
|
||||||
ErrBadMask: "ERR_BADMASK",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoMotd: "ERR_NOMOTD",
|
|
||||||
ErrNoAdminInfo: "ERR_NOADMININFO",
|
|
||||||
ErrFileError: "ERR_FILEERROR",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrNickCollision: "ERR_NICKCOLLISION",
|
|
||||||
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
|
||||||
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
|
||||||
ErrNoLogin: "ERR_NOLOGIN",
|
|
||||||
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
|
||||||
ErrUsersDisabled: "ERR_USERSDISABLED",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
|
||||||
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
|
||||||
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
|
||||||
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
|
||||||
ErrKeySet: "ERR_KEYSET",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrBadChanMask: "ERR_BADCHANMASK",
|
|
||||||
ErrNoChanModes: "ERR_NOCHANMODES",
|
|
||||||
ErrBanListFull: "ERR_BANLISTFULL",
|
|
||||||
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
|
||||||
ErrRestricted: "ERR_RESTRICTED",
|
|
||||||
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
|
||||||
ErrNoOperHost: "ERR_NOOPERHOST",
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
|
||||||
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
//
|
|
||||||
// Deprecated: Use IRCMessageType.Name() instead.
|
|
||||||
func Name(code IRCMessageType) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package irc_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
|
||||||
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Name(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestString(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.String(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "001"},
|
|
||||||
{irc.RplBounce, "005"},
|
|
||||||
{irc.RplLuserOp, "252"},
|
|
||||||
{irc.ErrCannotSendToChan, "404"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Code(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, 1},
|
|
||||||
{irc.RplBounce, 5},
|
|
||||||
{irc.RplLuserOp, 252},
|
|
||||||
{irc.ErrCannotSendToChan, 404},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Int(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Known(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
code int
|
|
||||||
want irc.IRCMessageType
|
|
||||||
}{
|
|
||||||
{1, irc.RplWelcome},
|
|
||||||
{5, irc.RplBounce},
|
|
||||||
{252, irc.RplLuserOp},
|
|
||||||
{404, irc.ErrCannotSendToChan},
|
|
||||||
{433, irc.ErrNicknameInUse},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
got, err := irc.FromInt(test.code)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != test.want {
|
|
||||||
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Unknown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknowns := []int{0, 999, 600, -1}
|
|
||||||
for _, code := range unknowns {
|
|
||||||
_, err := irc.FromInt(code)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("FromInt(%d) expected error, got nil", code)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
|
||||||
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_Name(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
if got := unknown.Name(); got != "" {
|
|
||||||
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_String(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
want := "UNKNOWN <999>"
|
|
||||||
|
|
||||||
if got := unknown.String(); got != want {
|
|
||||||
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeprecatedNameFunc(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
|
||||||
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user