Compare commits
2 Commits
78d657111b
...
fix/irc-nu
| Author | SHA1 | Date | |
|---|---|---|---|
| 38d222f6a7 | |||
|
|
8d91ad852c |
219
README.md
219
README.md
@@ -764,98 +764,21 @@ not pollute the message queue.
|
|||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
|
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
|
||||||
|
|
||||||
#### MODE — Query Modes
|
#### MODE — Set/Query Modes (Planned)
|
||||||
|
|
||||||
Query channel or user modes. Returns the current mode string and, for
|
Set channel or user modes.
|
||||||
channels, the creation timestamp.
|
|
||||||
|
|
||||||
**C2S:**
|
**C2S:**
|
||||||
```json
|
```json
|
||||||
{"command": "MODE", "to": "#general"}
|
{"command": "MODE", "to": "#general", "params": ["+m"]}
|
||||||
{"command": "MODE", "to": "alice"}
|
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
**S2C (via message queue):**
|
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the
|
||||||
|
planned mode set.
|
||||||
For channels, the server sends RPL_CHANNELMODEIS (324) and
|
|
||||||
RPL_CREATIONTIME (329):
|
|
||||||
```json
|
|
||||||
{"command": "324", "to": "alice", "params": ["#general", "+n"]}
|
|
||||||
{"command": "329", "to": "alice", "params": ["#general", "1709251200"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
For users, the server sends RPL_UMODEIS (221):
|
|
||||||
```json
|
|
||||||
{"command": "221", "to": "alice", "body": ["+"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Mode changes (setting/unsetting modes) are not yet implemented.
|
|
||||||
Currently only query is supported.
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.2.3
|
**IRC reference:** RFC 1459 §4.2.3
|
||||||
|
|
||||||
#### NAMES — Channel Member List
|
|
||||||
|
|
||||||
Request the member list for a channel. Returns RPL_NAMREPLY (353) and
|
|
||||||
RPL_ENDOFNAMES (366).
|
|
||||||
|
|
||||||
**C2S:**
|
|
||||||
```json
|
|
||||||
{"command": "NAMES", "to": "#general"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.2.5
|
|
||||||
|
|
||||||
#### LIST — List Channels
|
|
||||||
|
|
||||||
Request a list of all channels with member counts. Returns RPL_LIST (322)
|
|
||||||
for each channel followed by RPL_LISTEND (323).
|
|
||||||
|
|
||||||
**C2S:**
|
|
||||||
```json
|
|
||||||
{"command": "LIST"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.2.6
|
|
||||||
|
|
||||||
#### WHOIS — User Information
|
|
||||||
|
|
||||||
Query information about a user. Returns RPL_WHOISUSER (311),
|
|
||||||
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
|
||||||
|
|
||||||
**C2S:**
|
|
||||||
```json
|
|
||||||
{"command": "WHOIS", "to": "alice"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.5.2
|
|
||||||
|
|
||||||
#### WHO — Channel User List
|
|
||||||
|
|
||||||
Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed
|
|
||||||
by RPL_ENDOFWHO (315).
|
|
||||||
|
|
||||||
**C2S:**
|
|
||||||
```json
|
|
||||||
{"command": "WHO", "to": "#general"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.5.1
|
|
||||||
|
|
||||||
#### LUSERS — Server Statistics
|
|
||||||
|
|
||||||
Request server user/channel statistics. Returns RPL_LUSERCLIENT (251),
|
|
||||||
RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255).
|
|
||||||
|
|
||||||
**C2S:**
|
|
||||||
```json
|
|
||||||
{"command": "LUSERS"}
|
|
||||||
```
|
|
||||||
|
|
||||||
LUSERS replies are also sent automatically during connection registration.
|
|
||||||
|
|
||||||
**IRC reference:** RFC 1459 §4.3.2
|
|
||||||
|
|
||||||
#### KICK — Kick User (Planned)
|
#### KICK — Kick User (Planned)
|
||||||
|
|
||||||
Remove a user from a channel.
|
Remove a user from a channel.
|
||||||
@@ -905,27 +828,12 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| Code | Name | When Sent | Example |
|
| Code | Name | When Sent | Example |
|
||||||
|------|----------------------|-----------|---------|
|
|------|----------------------|-----------|---------|
|
||||||
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
||||||
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
|
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc-server, running version 0.1"]}` |
|
||||||
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
||||||
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` |
|
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc-server","0.1","","imnst"]}` |
|
||||||
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` |
|
|
||||||
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
|
|
||||||
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
|
|
||||||
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
|
|
||||||
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
|
|
||||||
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
|
|
||||||
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
|
|
||||||
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
|
|
||||||
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
|
|
||||||
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
|
|
||||||
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
|
|
||||||
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
||||||
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
||||||
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
|
|
||||||
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
|
|
||||||
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
|
|
||||||
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
||||||
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` |
|
|
||||||
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
||||||
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
||||||
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
||||||
@@ -933,18 +841,14 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
||||||
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
||||||
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
||||||
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
|
|
||||||
| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` |
|
|
||||||
| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` |
|
| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` |
|
||||||
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
||||||
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
|
|
||||||
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
||||||
|
|
||||||
**Note:** Numeric replies are now implemented. All IRC command responses
|
**Note:** Numeric replies are planned for full implementation. The current MVP
|
||||||
(success and error) are delivered as numeric replies through the message queue.
|
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
|
||||||
HTTP error codes are reserved for transport-level issues (auth failures,
|
of numeric replies for error conditions. Numeric replies in the message queue
|
||||||
malformed requests, server errors). The `params` field in the message envelope
|
will be added post-MVP.
|
||||||
carries IRC-style parameters (e.g., channel name, target nick).
|
|
||||||
|
|
||||||
### Channel Modes
|
### Channel Modes
|
||||||
|
|
||||||
@@ -1032,12 +936,6 @@ Return the current user's session state.
|
|||||||
|
|
||||||
**Request:** No body. Requires auth.
|
**Request:** No body. Requires auth.
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|-----------|--------|---------|-------------|
|
|
||||||
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1070,12 +968,6 @@ curl -s http://localhost:8080/api/v1/state \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reconnect with channel state initialization:**
|
|
||||||
```bash
|
|
||||||
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
|
||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
|
|
||||||
Retrieve messages from the client's delivery queue. This is the primary
|
Retrieve messages from the client's delivery queue. This is the primary
|
||||||
@@ -1162,78 +1054,27 @@ reference with all required and optional fields.
|
|||||||
|
|
||||||
| Command | Required Fields | Optional | Response Status |
|
| Command | Required Fields | Optional | Response Status |
|
||||||
|-----------|---------------------|---------------|-----------------|
|
|-----------|---------------------|---------------|-----------------|
|
||||||
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
|
||||||
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
|
||||||
| `JOIN` | `to` | | 200 OK |
|
| `JOIN` | `to` | | 200 OK |
|
||||||
| `PART` | `to` | `body` | 200 OK |
|
| `PART` | `to` | `body` | 200 OK |
|
||||||
| `NICK` | `body` | | 200 OK |
|
| `NICK` | `body` | | 200 OK |
|
||||||
| `TOPIC` | `to`, `body` | | 200 OK |
|
| `TOPIC` | `to`, `body` | | 200 OK |
|
||||||
| `MODE` | `to` | | 200 OK |
|
|
||||||
| `NAMES` | `to` | | 200 OK |
|
|
||||||
| `LIST` | | | 200 OK |
|
|
||||||
| `WHOIS` | `to` or `body` | | 200 OK |
|
|
||||||
| `WHO` | `to` | | 200 OK |
|
|
||||||
| `LUSERS` | | | 200 OK |
|
|
||||||
| `QUIT` | | `body` | 200 OK |
|
| `QUIT` | | `body` | 200 OK |
|
||||||
| `PING` | | | 200 OK |
|
| `PING` | | | 200 OK |
|
||||||
|
|
||||||
All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
**Errors (all commands):**
|
||||||
are delivered as **numeric replies** through the message queue (see
|
|
||||||
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
|
||||||
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
|
||||||
auth tokens (401), and server errors (500).
|
|
||||||
|
|
||||||
**HTTP errors (transport-level only):**
|
|
||||||
|
|
||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `invalid request` | Malformed JSON or empty command |
|
| 400 | `invalid request` | Malformed JSON |
|
||||||
|
| 400 | `to field required` | Missing `to` for commands that need it |
|
||||||
|
| 400 | `body required` | Missing `body` for commands that need it |
|
||||||
|
| 400 | `unknown command: X` | Unrecognized command |
|
||||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||||
| 500 | `internal error` | Server-side failure |
|
| 404 | `channel not found` | Target channel doesn't exist |
|
||||||
|
| 404 | `user not found` | DM target nick doesn't exist |
|
||||||
**IRC numeric error replies (delivered via message queue):**
|
| 409 | `nick already in use` | NICK target is taken |
|
||||||
|
|
||||||
| Numeric | Name | When |
|
|
||||||
|---------|------|------|
|
|
||||||
| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist |
|
|
||||||
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
|
|
||||||
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
|
|
||||||
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
|
|
||||||
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
|
||||||
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
|
||||||
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
|
||||||
|
|
||||||
**IRC numeric success replies (delivered via message queue):**
|
|
||||||
|
|
||||||
| Numeric | Name | When |
|
|
||||||
|---------|------|------|
|
|
||||||
| 001 | RPL_WELCOME | Sent on session creation/login |
|
|
||||||
| 002 | RPL_YOURHOST | Sent on session creation/login |
|
|
||||||
| 003 | RPL_CREATED | Sent on session creation/login |
|
|
||||||
| 004 | RPL_MYINFO | Sent on session creation/login |
|
|
||||||
| 005 | RPL_ISUPPORT | Sent on session creation/login |
|
|
||||||
| 221 | RPL_UMODEIS | In response to user MODE query |
|
|
||||||
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
|
|
||||||
| 252 | RPL_LUSEROP | On connect or LUSERS command |
|
|
||||||
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
|
|
||||||
| 255 | RPL_LUSERME | On connect or LUSERS command |
|
|
||||||
| 311 | RPL_WHOISUSER | WHOIS user info |
|
|
||||||
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
|
||||||
| 315 | RPL_ENDOFWHO | End of WHO list |
|
|
||||||
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
|
||||||
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
|
||||||
| 322 | RPL_LIST | Channel in LIST response |
|
|
||||||
| 323 | RPL_LISTEND | End of LIST |
|
|
||||||
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
|
|
||||||
| 329 | RPL_CREATIONTIME | Channel creation timestamp |
|
|
||||||
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
|
|
||||||
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
|
|
||||||
| 352 | RPL_WHOREPLY | User in WHO response |
|
|
||||||
| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) |
|
|
||||||
| 366 | RPL_ENDOFNAMES | End of NAMES list |
|
|
||||||
| 375 | RPL_MOTDSTART | Start of MOTD |
|
|
||||||
| 372 | RPL_MOTD | MOTD line |
|
|
||||||
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
|
||||||
|
|
||||||
### GET /api/v1/history — Message History
|
### GET /api/v1/history — Message History
|
||||||
|
|
||||||
@@ -2236,18 +2077,10 @@ GET /api/v1/challenge
|
|||||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||||
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
|
||||||
- [x] **MODE command** — query channel and user modes (set not yet implemented)
|
- [ ] **MODE command** — set/query channel and user modes
|
||||||
- [x] **NAMES command** — query channel member list
|
|
||||||
- [x] **LIST command** — list all channels with member counts
|
|
||||||
- [x] **WHOIS command** — query user information and channel membership
|
|
||||||
- [x] **WHO command** — query channel user list
|
|
||||||
- [x] **LUSERS command** — query server statistics
|
|
||||||
- [x] **Connection registration numerics** — 001-005 sent on session creation
|
|
||||||
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS
|
|
||||||
- [ ] **KICK command** — remove users from channels
|
- [ ] **KICK command** — remove users from channels
|
||||||
- [x] **Numeric replies** — send IRC numeric codes via the message queue
|
- [ ] **Numeric replies** — send IRC numeric codes via the message queue
|
||||||
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE,
|
(001 welcome, 353 NAMES, 332 TOPIC, etc.)
|
||||||
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
|
|
||||||
- [ ] **Max message size enforcement** — reject oversized messages
|
- [ ] **Max message size enforcement** — reject oversized messages
|
||||||
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
||||||
- [ ] **Multi-client sessions** — add client to existing session
|
- [ ] **Multi-client sessions** — add client to existing session
|
||||||
@@ -2267,7 +2100,7 @@ GET /api/v1/challenge
|
|||||||
- [ ] **Push notifications** — optional webhook/push for mobile clients
|
- [ ] **Push notifications** — optional webhook/push for mobile clients
|
||||||
when messages arrive during disconnect
|
when messages arrive during disconnect
|
||||||
- [ ] **Message search** — full-text search over channel history
|
- [ ] **Message search** — full-text search over channel history
|
||||||
- [x] **User info command** — WHOIS for querying user info and channels
|
- [ ] **User info command** — WHOIS-equivalent for querying user metadata
|
||||||
- [ ] **Connection flood protection** — per-IP connection limits as a
|
- [ ] **Connection flood protection** — per-IP connection limits as a
|
||||||
complement to hashcash
|
complement to hashcash
|
||||||
- [ ] **Invite system** — `INVITE` command for `+i` channels
|
- [ ] **Invite system** — `INVITE` command for `+i` channels
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -170,7 +168,7 @@ func (client *Client) PollMessages(
|
|||||||
func (client *Client) JoinChannel(channel string) error {
|
func (client *Client) JoinChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdJoin, To: channel,
|
Command: "JOIN", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
|
|||||||
func (client *Client) PartChannel(channel string) error {
|
func (client *Client) PartChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdPart, To: channel,
|
Command: "PART", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -87,7 +86,7 @@ func (a *App) handleInput(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -139,29 +138,16 @@ func (a *App) dispatchCommand(cmd, args string) {
|
|||||||
a.cmdQuery(args)
|
a.cmdQuery(args)
|
||||||
case "/topic":
|
case "/topic":
|
||||||
a.cmdTopic(args)
|
a.cmdTopic(args)
|
||||||
|
case "/names":
|
||||||
|
a.cmdNames()
|
||||||
|
case "/list":
|
||||||
|
a.cmdList()
|
||||||
case "/window", "/w":
|
case "/window", "/w":
|
||||||
a.cmdWindow(args)
|
a.cmdWindow(args)
|
||||||
case "/quit":
|
case "/quit":
|
||||||
a.cmdQuit()
|
a.cmdQuit()
|
||||||
case "/help":
|
case "/help":
|
||||||
a.cmdHelp()
|
a.cmdHelp()
|
||||||
default:
|
|
||||||
a.dispatchInfoCommand(cmd, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
|
||||||
switch cmd {
|
|
||||||
case "/names":
|
|
||||||
a.cmdNames()
|
|
||||||
case "/list":
|
|
||||||
a.cmdList()
|
|
||||||
case "/motd":
|
|
||||||
a.cmdMotd()
|
|
||||||
case "/who":
|
|
||||||
a.cmdWho(args)
|
|
||||||
case "/whois":
|
|
||||||
a.cmdWhois(args)
|
|
||||||
default:
|
default:
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Unknown command: " + cmd,
|
"[red]Unknown command: " + cmd,
|
||||||
@@ -242,7 +228,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdNick,
|
Command: "NICK",
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,7 +363,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -435,7 +421,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +434,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
@@ -524,96 +510,6 @@ func (a *App) cmdList() {
|
|||||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) cmdMotd() {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]MOTD failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWho(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
target := a.target
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channel := args
|
|
||||||
if channel == "" {
|
|
||||||
channel = target
|
|
||||||
}
|
|
||||||
|
|
||||||
if channel == "" ||
|
|
||||||
!strings.HasPrefix(channel, "#") {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /who #channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdWho, To: channel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]WHO failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWhois(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if args == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /whois <nick>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdWhois, To: args,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]WHOIS failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWindow(args string) {
|
func (a *App) cmdWindow(args string) {
|
||||||
if args == "" {
|
if args == "" {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
@@ -654,7 +550,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(
|
_ = a.client.SendMessage(
|
||||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,9 +574,6 @@ func (a *App) cmdHelp() {
|
|||||||
" /topic [text] — View/set topic",
|
" /topic [text] — View/set topic",
|
||||||
" /names — List channel members",
|
" /names — List channel members",
|
||||||
" /list — List channels",
|
" /list — List channels",
|
||||||
" /who [#channel] — List users in channel",
|
|
||||||
" /whois <nick> — Show user info",
|
|
||||||
" /motd — Show message of the day",
|
|
||||||
" /window <n> — Switch buffer",
|
" /window <n> — Switch buffer",
|
||||||
" /quit — Disconnect and exit",
|
" /quit — Disconnect and exit",
|
||||||
" /help — This help",
|
" /help — This help",
|
||||||
@@ -739,19 +632,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case irc.CmdPrivmsg:
|
case "PRIVMSG":
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdJoin:
|
case "JOIN":
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case irc.CmdPart:
|
case "PART":
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case irc.CmdQuit:
|
case "QUIT":
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case irc.CmdNick:
|
case "NICK":
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdNotice:
|
case "NOTICE":
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case irc.CmdTopic:
|
case "TOPIC":
|
||||||
a.handleTopicEvent(msg, timestamp)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
|
|||||||
@@ -13,14 +13,6 @@ import (
|
|||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
|
||||||
| '_ \ / _ \/ _ \ | | '__/ __|
|
|
||||||
| | | | __/ (_) || | | | (__
|
|
||||||
|_| |_|\___|\___/ |_|_| \___|
|
|
||||||
|
|
||||||
Welcome to NeoIRC — IRC semantics over HTTP.
|
|
||||||
Type /help for available commands.`
|
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Config.
|
// Params defines the dependencies for creating a Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -70,7 +62,7 @@ func New(
|
|||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MAX_HISTORY", "10000")
|
viper.SetDefault("MAX_HISTORY", "10000")
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
viper.SetDefault("MOTD", defaultMOTD)
|
viper.SetDefault("MOTD", "")
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,25 +33,14 @@ func generateToken() (string, error) {
|
|||||||
type IRCMessage struct {
|
type IRCMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Code int `json:"code,omitempty"`
|
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
Body json.RawMessage `json:"body,omitempty"`
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
TS string `json:"ts"`
|
TS string `json:"ts"`
|
||||||
Meta json.RawMessage `json:"meta,omitempty"`
|
Meta json.RawMessage `json:"meta,omitempty"`
|
||||||
DBID int64 `json:"-"`
|
DBID int64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNumericCode returns true if s is exactly a 3-digit
|
|
||||||
// IRC numeric reply code.
|
|
||||||
func isNumericCode(s string) bool {
|
|
||||||
return len(s) == 3 &&
|
|
||||||
s[0] >= '0' && s[0] <= '9' &&
|
|
||||||
s[1] >= '0' && s[1] <= '9' &&
|
|
||||||
s[2] >= '0' && s[2] <= '9'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelInfo is a lightweight channel representation.
|
// ChannelInfo is a lightweight channel representation.
|
||||||
type ChannelInfo struct {
|
type ChannelInfo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -504,17 +491,12 @@ func (database *Database) GetSessionChannelIDs(
|
|||||||
func (database *Database) InsertMessage(
|
func (database *Database) InsertMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
params json.RawMessage,
|
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
msgUUID := uuid.New().String()
|
msgUUID := uuid.New().String()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
if params == nil {
|
|
||||||
params = json.RawMessage("[]")
|
|
||||||
}
|
|
||||||
|
|
||||||
if body == nil {
|
if body == nil {
|
||||||
body = json.RawMessage("[]")
|
body = json.RawMessage("[]")
|
||||||
}
|
}
|
||||||
@@ -526,10 +508,10 @@ func (database *Database) InsertMessage(
|
|||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(uuid, command, msg_from, msg_to,
|
(uuid, command, msg_from, msg_to,
|
||||||
params, body, meta, created_at)
|
body, meta, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
msgUUID, command, from, target,
|
msgUUID, command, from, target,
|
||||||
string(params), string(body), string(meta), now)
|
string(body), string(meta), now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", fmt.Errorf(
|
return 0, "", fmt.Errorf(
|
||||||
"insert message: %w", err,
|
"insert message: %w", err,
|
||||||
@@ -596,7 +578,7 @@ func (database *Database) PollMessages(
|
|||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT cq.id, m.uuid, m.command,
|
`SELECT cq.id, m.uuid, m.command,
|
||||||
m.msg_from, m.msg_to,
|
m.msg_from, m.msg_to,
|
||||||
m.params, m.body, m.meta, m.created_at
|
m.body, m.meta, m.created_at
|
||||||
FROM client_queues cq
|
FROM client_queues cq
|
||||||
INNER JOIN messages m
|
INNER JOIN messages m
|
||||||
ON m.id = cq.message_id
|
ON m.id = cq.message_id
|
||||||
@@ -660,7 +642,7 @@ func (database *Database) queryHistory(
|
|||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, params, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ? AND id < ?
|
WHERE msg_to = ? AND id < ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -677,7 +659,7 @@ func (database *Database) queryHistory(
|
|||||||
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, params, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ?
|
WHERE msg_to = ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -702,16 +684,16 @@ func scanMessages(
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
msg IRCMessage
|
msg IRCMessage
|
||||||
qID int64
|
qID int64
|
||||||
params, body, meta string
|
body, meta string
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&qID, &msg.ID, &msg.Command,
|
&qID, &msg.ID, &msg.Command,
|
||||||
&msg.From, &msg.To,
|
&msg.From, &msg.To,
|
||||||
¶ms, &body, &meta, &createdAt,
|
&body, &meta, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fallbackQID, fmt.Errorf(
|
return nil, fallbackQID, fmt.Errorf(
|
||||||
@@ -719,25 +701,12 @@ func scanMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params != "" && params != "[]" {
|
|
||||||
msg.Params = json.RawMessage(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.Body = json.RawMessage(body)
|
msg.Body = json.RawMessage(body)
|
||||||
msg.Meta = json.RawMessage(meta)
|
msg.Meta = json.RawMessage(meta)
|
||||||
msg.TS = createdAt.Format(time.RFC3339Nano)
|
msg.TS = createdAt.Format(time.RFC3339Nano)
|
||||||
msg.DBID = qID
|
msg.DBID = qID
|
||||||
lastQID = qID
|
lastQID = qID
|
||||||
|
|
||||||
if isNumericCode(msg.Command) {
|
|
||||||
code, _ := strconv.Atoi(msg.Command)
|
|
||||||
msg.Code = code
|
|
||||||
|
|
||||||
if name := irc.Name(code); name != "" {
|
|
||||||
msg.Command = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs = append(msgs, msg)
|
msgs = append(msgs, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,125 +943,3 @@ func (database *Database) GetSessionChannels(
|
|||||||
|
|
||||||
return scanChannels(rows)
|
return scanChannels(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelCount returns the total number of channels.
|
|
||||||
func (database *Database) GetChannelCount(
|
|
||||||
ctx context.Context,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT COUNT(*) FROM channels",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get channel count: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelInfoFull contains extended channel information.
|
|
||||||
type ChannelInfoFull struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
MemberCount int64 `json:"memberCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllChannelsWithCounts returns every channel
|
|
||||||
// with its member count.
|
|
||||||
func (database *Database) ListAllChannelsWithCounts(
|
|
||||||
ctx context.Context,
|
|
||||||
) ([]ChannelInfoFull, error) {
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT c.id, c.name, c.topic,
|
|
||||||
COUNT(cm.session_id) AS member_count
|
|
||||||
FROM channels c
|
|
||||||
LEFT JOIN channel_members cm
|
|
||||||
ON cm.channel_id = c.id
|
|
||||||
GROUP BY c.id
|
|
||||||
ORDER BY c.name`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"list channels with counts: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var out []ChannelInfoFull
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var chanInfo ChannelInfoFull
|
|
||||||
|
|
||||||
err = rows.Scan(
|
|
||||||
&chanInfo.ID, &chanInfo.Name,
|
|
||||||
&chanInfo.Topic, &chanInfo.MemberCount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel full: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, chanInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("rows error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if out == nil {
|
|
||||||
out = []ChannelInfoFull{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetChannelCreatedAt returns the creation time of a
|
|
||||||
// channel.
|
|
||||||
func (database *Database) GetChannelCreatedAt(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (time.Time, error) {
|
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT created_at FROM channels WHERE id = ?",
|
|
||||||
channelID,
|
|
||||||
).Scan(&createdAt)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"get channel created_at: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionCreatedAt returns the creation time of a
|
|
||||||
// session.
|
|
||||||
func (database *Database) GetSessionCreatedAt(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (time.Time, error) {
|
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT created_at FROM sessions WHERE id = ?",
|
|
||||||
sessionID,
|
|
||||||
).Scan(&createdAt)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"get session created_at: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdAt, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
|
|||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, msgUUID, err := database.InsertMessage(
|
dbID, msgUUID, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
|
|||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
|
|||||||
for range msgCount {
|
for range msgCount {
|
||||||
_, _, err := database.InsertMessage(
|
_, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "user", "#hist",
|
ctx, "PRIVMSG", "user", "#hist",
|
||||||
nil, json.RawMessage(`["msg"]`), nil,
|
json.RawMessage(`["msg"]`), nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
body := json.RawMessage(`["test"]`)
|
body := json.RawMessage(`["test"]`)
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
||||||
msg_from TEXT NOT NULL DEFAULT '',
|
msg_from TEXT NOT NULL DEFAULT '',
|
||||||
msg_to TEXT NOT NULL DEFAULT '',
|
msg_to TEXT NOT NULL DEFAULT '',
|
||||||
params TEXT NOT NULL DEFAULT '[]',
|
|
||||||
body TEXT NOT NULL DEFAULT '[]',
|
body TEXT NOT NULL DEFAULT '[]',
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,18 +15,16 @@ var (
|
|||||||
|
|
||||||
// Globals holds application-wide metadata.
|
// Globals holds application-wide metadata.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
StartTime time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Globals instance from the global state.
|
// New creates a new Globals instance from the global state.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
result := &Globals{
|
n := &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
StartTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -116,9 +115,8 @@ func newTestServer(
|
|||||||
|
|
||||||
func newTestGlobals() *globals.Globals {
|
func newTestGlobals() *globals.Globals {
|
||||||
return &globals.Globals{
|
return &globals.Globals{
|
||||||
Appname: "neoirc-test",
|
Appname: "neoirc-test",
|
||||||
Version: "test",
|
Version: "test",
|
||||||
StartTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,22 +462,6 @@ func findMessage(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func findNumeric(
|
|
||||||
msgs []map[string]any,
|
|
||||||
numeric string,
|
|
||||||
) bool {
|
|
||||||
want, _ := strconv.Atoi(numeric)
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
code, ok := msg["code"].(float64)
|
|
||||||
if ok && int(code) == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
func TestCreateSessionValid(t *testing.T) {
|
func TestCreateSessionValid(t *testing.T) {
|
||||||
@@ -491,47 +473,6 @@ func TestCreateSessionValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWelcomeNumeric(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
token := tserver.createSession("welcomer")
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "001") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_WELCOME (001), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJoinNumerics(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
token := tserver.createSession("jnumtest")
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#numtest",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "353") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_NAMREPLY (353), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "366") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ENDOFNAMES (366), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSessionDuplicate(t *testing.T) {
|
func TestCreateSessionDuplicate(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
tserver.createSession("alice")
|
tserver.createSession("alice")
|
||||||
@@ -727,22 +668,17 @@ func TestJoinMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("joiner3")
|
token := tserver.createSession("joiner3")
|
||||||
|
|
||||||
// Drain initial MOTD/welcome numerics.
|
status, result := tserver.sendCommand(
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token, map[string]any{commandKey: joinCmd},
|
token, map[string]any{commandKey: joinCmd},
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "461" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 461, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,21 +735,17 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#test",
|
commandKey: joinCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: privmsgCmd, toKey: "#test",
|
commandKey: privmsgCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "412" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 412, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -822,9 +754,7 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("noto")
|
token := tserver.createSession("noto")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
bodyKey: []string{"hello"},
|
bodyKey: []string{"hello"},
|
||||||
})
|
})
|
||||||
@@ -832,12 +762,10 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "411" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 411, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,10 +780,8 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#private",
|
commandKey: joinCmd, toKey: "#private",
|
||||||
})
|
})
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(aliceToken, 0)
|
|
||||||
|
|
||||||
// Alice tries to send without joining.
|
// Alice tries to send without joining.
|
||||||
status, _ := tserver.sendCommand(
|
status, result := tserver.sendCommand(
|
||||||
aliceToken,
|
aliceToken,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
@@ -867,12 +793,10 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
if result[commandKey] != "442" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "442") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
"expected IRC 442, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -922,9 +846,7 @@ func TestDMToNonexistentUser(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("dmsender")
|
token := tserver.createSession("dmsender")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
toKey: "nobody",
|
toKey: "nobody",
|
||||||
bodyKey: []string{"hello?"},
|
bodyKey: []string{"hello?"},
|
||||||
@@ -933,12 +855,10 @@ func TestDMToNonexistentUser(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "401" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "401") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NOSUCHNICK (401), got %v",
|
"expected IRC 401, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -986,9 +906,7 @@ func TestNickCollision(t *testing.T) {
|
|||||||
|
|
||||||
tserver.createSession("taken_nick")
|
tserver.createSession("taken_nick")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "NICK",
|
commandKey: "NICK",
|
||||||
bodyKey: []string{"taken_nick"},
|
bodyKey: []string{"taken_nick"},
|
||||||
})
|
})
|
||||||
@@ -996,12 +914,10 @@ func TestNickCollision(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "433" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "433") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NICKNAMEINUSE (433), got %v",
|
"expected IRC 433, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1010,9 +926,7 @@ func TestNickInvalid(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("nickval")
|
token := tserver.createSession("nickval")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "NICK",
|
commandKey: "NICK",
|
||||||
bodyKey: []string{"bad nick!"},
|
bodyKey: []string{"bad nick!"},
|
||||||
})
|
})
|
||||||
@@ -1020,12 +934,10 @@ func TestNickInvalid(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "432" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "432") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
"expected IRC 432, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1034,21 +946,17 @@ func TestNickEmptyBody(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("nicknobody")
|
token := tserver.createSession("nicknobody")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token, map[string]any{commandKey: "NICK"},
|
token, map[string]any{commandKey: "NICK"},
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "431" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 431, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1086,9 +994,7 @@ func TestTopicMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("topicnoto")
|
token := tserver.createSession("topicnoto")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "TOPIC",
|
commandKey: "TOPIC",
|
||||||
bodyKey: []string{"topic"},
|
bodyKey: []string{"topic"},
|
||||||
})
|
})
|
||||||
@@ -1096,12 +1002,10 @@ func TestTopicMissingTo(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "461" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 461, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1114,21 +1018,17 @@ func TestTopicMissingBody(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#topictest",
|
commandKey: joinCmd, toKey: "#topictest",
|
||||||
})
|
})
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(token, map[string]any{
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "TOPIC", toKey: "#topictest",
|
commandKey: "TOPIC", toKey: "#topictest",
|
||||||
})
|
})
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "461" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
"expected IRC 461, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1197,21 +1097,17 @@ func TestUnknownCommand(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("cmdtest")
|
token := tserver.createSession("cmdtest")
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
status, result := tserver.sendCommand(
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
|
||||||
token, map[string]any{commandKey: "BOGUS"},
|
token, map[string]any{commandKey: "BOGUS"},
|
||||||
)
|
)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
if result[commandKey] != "421" {
|
||||||
|
|
||||||
if !findNumeric(msgs, "421") {
|
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
"expected IRC 421, got %v",
|
||||||
msgs,
|
result[commandKey],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1220,11 +1116,18 @@ func TestEmptyCommand(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("emptycmd")
|
token := tserver.createSession("emptycmd")
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
status, result := tserver.sendCommand(
|
||||||
token, map[string]any{commandKey: ""},
|
token, map[string]any{commandKey: ""},
|
||||||
)
|
)
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[commandKey] != "421" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected IRC 421, got %v",
|
||||||
|
result[commandKey],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1459,18 +1362,12 @@ func TestLongPollTimeout(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("lp_timeout")
|
token := tserver.createSession("lp_timeout")
|
||||||
|
|
||||||
// Drain initial welcome/MOTD numerics.
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
resp, err := doRequestAuth(
|
resp, err := doRequestAuth(
|
||||||
t,
|
t,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
tserver.url(fmt.Sprintf(
|
tserver.url(apiMessages+"?timeout=1"),
|
||||||
"%s?timeout=1&after=%d",
|
|
||||||
apiMessages, lastID,
|
|
||||||
)),
|
|
||||||
token,
|
token,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(request, clientID, sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, _, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
payload.Nick,
|
payload.Nick,
|
||||||
@@ -178,16 +178,6 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize channel state so the new client knows
|
|
||||||
// which channels the session already belongs to.
|
|
||||||
hdlr.initChannelState(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package irc
|
|
||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
|
||||||
const (
|
|
||||||
CmdJoin = "JOIN"
|
|
||||||
CmdList = "LIST"
|
|
||||||
CmdLusers = "LUSERS"
|
|
||||||
CmdMode = "MODE"
|
|
||||||
CmdMotd = "MOTD"
|
|
||||||
CmdNames = "NAMES"
|
|
||||||
CmdNick = "NICK"
|
|
||||||
CmdNotice = "NOTICE"
|
|
||||||
CmdPart = "PART"
|
|
||||||
CmdPing = "PING"
|
|
||||||
CmdPong = "PONG"
|
|
||||||
CmdPrivmsg = "PRIVMSG"
|
|
||||||
CmdQuit = "QUIT"
|
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
|
||||||
@@ -1,150 +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
|
|
||||||
|
|
||||||
// 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]
|
|
||||||
}
|
|
||||||
4
web/dist/app.js
vendored
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
10
web/dist/style.css
vendored
10
web/dist/style.css
vendored
@@ -70,14 +70,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box .motd {
|
.login-box .motd {
|
||||||
color: var(--accent);
|
color: var(--text-dim);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre;
|
white-space: pre-wrap;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.2;
|
border-left: 2px solid var(--border);
|
||||||
overflow-x: auto;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box form {
|
.login-box form {
|
||||||
|
|||||||
292
web/src/app.jsx
292
web/src/app.jsx
@@ -70,8 +70,8 @@ function LoginScreen({ onLogin }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state?initChannelState=1")
|
api("/state")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -333,24 +333,7 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
if (msg.to && msg.to.startsWith("#")) {
|
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
||||||
// Create a tab when the current user joins a channel
|
|
||||||
// (including JOINs from initChannelState on reconnect).
|
|
||||||
if (msg.from === nickRef.current) {
|
|
||||||
setTabs((prev) => {
|
|
||||||
if (
|
|
||||||
prev.find(
|
|
||||||
(t) => t.type === "channel" && t.name === msg.to,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return prev;
|
|
||||||
|
|
||||||
return [...prev, { type: "channel", name: msg.to }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshMembers(msg.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -436,100 +419,6 @@ function App() {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "322": {
|
|
||||||
// RPL_LIST — channel, member count, topic.
|
|
||||||
if (Array.isArray(msg.params) && msg.params.length >= 2) {
|
|
||||||
const chName = msg.params[0];
|
|
||||||
const count = msg.params[1];
|
|
||||||
const chTopic = body || "";
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: `${chName} (${count} users): ${chTopic.trim()}`,
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "323":
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: body || "End of channel list",
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "352": {
|
|
||||||
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
|
|
||||||
if (Array.isArray(msg.params) && msg.params.length >= 5) {
|
|
||||||
const whoCh = msg.params[0];
|
|
||||||
const whoNick = msg.params[4];
|
|
||||||
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: `${whoCh} ${whoNick} ${whoFlags}`,
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "315":
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: body || "End of /WHO list",
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "311": {
|
|
||||||
// RPL_WHOISUSER — nick, user, host, *, realname.
|
|
||||||
if (Array.isArray(msg.params) && msg.params.length >= 1) {
|
|
||||||
const wiNick = msg.params[0];
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: `${wiNick} (${body})`,
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "312": {
|
|
||||||
// RPL_WHOISSERVER — nick, server, server info.
|
|
||||||
if (Array.isArray(msg.params) && msg.params.length >= 2) {
|
|
||||||
const wiNick = msg.params[0];
|
|
||||||
const wiServer = msg.params[1];
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: `${wiNick} on ${wiServer}`,
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "319": {
|
|
||||||
// RPL_WHOISCHANNELS — nick, channels.
|
|
||||||
if (Array.isArray(msg.params) && msg.params.length >= 1) {
|
|
||||||
const wiNick = msg.params[0];
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: `${wiNick} is on: ${body}`,
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "318":
|
|
||||||
addMessage("Server", {
|
|
||||||
...base,
|
|
||||||
text: body || "End of /WHOIS list",
|
|
||||||
system: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "375":
|
case "375":
|
||||||
case "372":
|
case "372":
|
||||||
case "376":
|
case "376":
|
||||||
@@ -608,31 +497,6 @@ function App() {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Global keyboard handler — capture '/' to prevent
|
|
||||||
// Firefox quick search and redirect focus to the input.
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalKeyDown = (e) => {
|
|
||||||
if (
|
|
||||||
e.key === "/" &&
|
|
||||||
document.activeElement !== inputRef.current &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.altKey &&
|
|
||||||
!e.metaKey
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleGlobalKeyDown);
|
|
||||||
|
|
||||||
// Also focus input on initial mount.
|
|
||||||
inputRef.current?.focus();
|
|
||||||
|
|
||||||
return () =>
|
|
||||||
document.removeEventListener("keydown", handleGlobalKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch topic for active channel.
|
// Fetch topic for active channel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loggedIn) return;
|
if (!loggedIn) return;
|
||||||
@@ -648,31 +512,10 @@ function App() {
|
|||||||
}, [loggedIn, activeTab, tabs]);
|
}, [loggedIn, activeTab, tabs]);
|
||||||
|
|
||||||
const onLogin = useCallback(
|
const onLogin = useCallback(
|
||||||
async (userNick, isResumed) => {
|
async (userNick) => {
|
||||||
setNick(userNick);
|
setNick(userNick);
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
if (isResumed) {
|
|
||||||
// Request MOTD on resumed sessions (new sessions
|
|
||||||
// get it automatically from the server during
|
|
||||||
// creation). Channel state is initialized by the
|
|
||||||
// server via the message queue
|
|
||||||
// (?initChannelState=1), so we do not need to
|
|
||||||
// re-JOIN channels here.
|
|
||||||
try {
|
|
||||||
await api("/messages", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ command: "MOTD" }),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// MOTD is non-critical.
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fresh session — join any previously saved channels.
|
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
@@ -948,127 +791,18 @@ function App() {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "/motd": {
|
|
||||||
try {
|
|
||||||
await api("/messages", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ command: "MOTD" }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
addSystemMessage(
|
|
||||||
"Server",
|
|
||||||
`Failed to request MOTD: ${err.data?.error || "error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/query": {
|
|
||||||
if (parts[1]) {
|
|
||||||
const target = parts[1];
|
|
||||||
openDM(target);
|
|
||||||
const msgText = parts.slice(2).join(" ");
|
|
||||||
if (msgText) {
|
|
||||||
try {
|
|
||||||
await api("/messages", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
command: "PRIVMSG",
|
|
||||||
to: target,
|
|
||||||
body: [msgText],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
addSystemMessage(
|
|
||||||
"Server",
|
|
||||||
`Message failed: ${err.data?.error || "error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addSystemMessage("Server", "Usage: /query <nick> [message]");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/list": {
|
|
||||||
try {
|
|
||||||
await api("/messages", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ command: "LIST" }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
addSystemMessage(
|
|
||||||
"Server",
|
|
||||||
`Failed to list channels: ${err.data?.error || "error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/who": {
|
|
||||||
const whoTarget = parts[1] || (tab.type === "channel" ? tab.name : "");
|
|
||||||
if (whoTarget) {
|
|
||||||
try {
|
|
||||||
await api("/messages", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ command: "WHO", to: whoTarget }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
addSystemMessage(
|
|
||||||
"Server",
|
|
||||||
`WHO failed: ${err.data?.error || "error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addSystemMessage("Server", "Usage: /who #channel");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/whois": {
|
|
||||||
if (parts[1]) {
|
|
||||||
try {
|
|
||||||
await api("/messages", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ command: "WHOIS", to: parts[1] }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
addSystemMessage(
|
|
||||||
"Server",
|
|
||||||
`WHOIS failed: ${err.data?.error || "error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addSystemMessage("Server", "Usage: /whois <nick>");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/clear": {
|
|
||||||
const clearTarget = tab.name;
|
|
||||||
setMessages((prev) => ({ ...prev, [clearTarget]: [] }));
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "/help": {
|
case "/help": {
|
||||||
const helpLines = [
|
const helpLines = [
|
||||||
"Available commands:",
|
"Available commands:",
|
||||||
" /join #channel — Join a channel",
|
" /join #channel — Join a channel",
|
||||||
" /part [reason] — Part the current channel",
|
" /part [reason] — Part the current channel",
|
||||||
" /msg nick message — Send a private message",
|
" /msg nick message — Send a private message",
|
||||||
" /query nick [message] — Open a DM tab (optionally send a message)",
|
" /me action — Send an action",
|
||||||
" /me action — Send an action",
|
" /nick newnick — Change your nickname",
|
||||||
" /nick newnick — Change your nickname",
|
" /topic [text] — View or set channel topic",
|
||||||
" /topic [text] — View or set channel topic",
|
" /mode +/-flags — Set channel modes",
|
||||||
" /mode +/-flags — Set channel modes",
|
" /quit [reason] — Disconnect from server",
|
||||||
" /motd — Display the message of the day",
|
" /help — Show this help",
|
||||||
" /list — List all channels",
|
|
||||||
" /who [#channel] — List users in a channel",
|
|
||||||
" /whois nick — Show info about a user",
|
|
||||||
" /clear — Clear messages in the current tab",
|
|
||||||
" /quit [reason] — Disconnect from server",
|
|
||||||
" /help — Show this help",
|
|
||||||
];
|
];
|
||||||
for (const line of helpLines) {
|
for (const line of helpLines) {
|
||||||
addSystemMessage("Server", line);
|
addSystemMessage("Server", line);
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box .motd {
|
.login-box .motd {
|
||||||
color: var(--accent);
|
color: var(--text-dim);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre;
|
white-space: pre-wrap;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.2;
|
border-left: 2px solid var(--border);
|
||||||
overflow-x: auto;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box form {
|
.login-box form {
|
||||||
|
|||||||
Reference in New Issue
Block a user