1 Commits

Author SHA1 Message Date
clawbot
f9c145ad09 refactor: replace HTTP error codes with IRC numeric replies for all IRC commands
All checks were successful
check / check (push) Successful in 58s
IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, etc.) now respond with
proper IRC numeric replies delivered through the message queue instead of
HTTP status codes. HTTP error codes are now reserved exclusively for
transport-level concerns: auth failures (401), malformed requests (400),
and server errors (500).

Changes:
- Add params column to messages table for IRC-style parameters
- Add Params field to IRCMessage struct and update all queries
- Add respondIRCError helper for consistent IRC error delivery
- Add RPL_WELCOME (001) on session creation and login
- Add RPL_TOPIC/RPL_NOTOPIC (332/331), RPL_NAMREPLY (353),
  RPL_ENDOFNAMES (366) on JOIN
- Add RPL_TOPIC (332) on TOPIC set
- Replace HTTP 404 with ERR_NOSUCHCHANNEL (403) and ERR_NOSUCHNICK (401)
- Replace HTTP 409 with ERR_NICKNAMEINUSE (433)
- Replace HTTP 403 with ERR_NOTONCHANNEL (442)
- Replace HTTP 400 with ERR_NEEDMOREPARAMS (461), ERR_ERRONEUSNICKNAME (432),
  and ERR_UNKNOWNCOMMAND (421) where appropriate
- Change PRIVMSG/NOTICE success from HTTP 201 to HTTP 200
- Update all tests to verify IRC numerics in message queue
- Add new tests for RPL_WELCOME and JOIN numerics
- Update README to document new numeric reply behavior

closes #54
2026-03-08 01:32:02 -08:00
11 changed files with 1278 additions and 1744 deletions

View File

@@ -845,10 +845,11 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `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"]}` |
| `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 planned for full implementation. The current MVP **Note:** Numeric replies are now implemented. All IRC command responses
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead (success and error) are delivered as numeric replies through the message queue.
of numeric replies for error conditions. Numeric replies in the message queue HTTP error codes are reserved for transport-level issues (auth failures,
will be added post-MVP. malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
### Channel Modes ### Channel Modes
@@ -1054,8 +1055,8 @@ reference with all required and optional fields.
| Command | Required Fields | Optional | Response Status | | Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------| |-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 201 Created | | `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `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 |
@@ -1063,18 +1064,44 @@ reference with all required and optional fields.
| `QUIT` | | `body` | 200 OK | | `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK | | `PING` | | | 200 OK |
**Errors (all commands):** All IRC commands return HTTP 200 OK. IRC-level success and error responses
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 | | 400 | `invalid request` | Malformed JSON or empty command |
| 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 |
| 404 | `channel not found` | Target channel doesn't exist | | 500 | `internal error` | Server-side failure |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken | **IRC numeric error replies (delivered via message queue):**
| 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 |
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
| 353 | RPL_NAMREPLY | Channel member list (on JOIN) |
| 366 | RPL_ENDOFNAMES | End of NAMES list (on JOIN) |
| 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

View File

@@ -35,6 +35,7 @@ type IRCMessage struct {
Command string `json:"command"` Command string `json:"command"`
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"`
@@ -491,12 +492,17 @@ 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("[]")
} }
@@ -508,10 +514,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,
body, meta, created_at) params, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target, msgUUID, command, from, target,
string(body), string(meta), now) string(params), 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,
@@ -578,7 +584,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.body, m.meta, m.created_at m.params, 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
@@ -642,7 +648,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, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? AND id < ? WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -659,7 +665,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, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? WHERE msg_to = ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -684,16 +690,16 @@ func scanMessages(
for rows.Next() { for rows.Next() {
var ( var (
msg IRCMessage msg IRCMessage
qID int64 qID int64
body, meta string params, 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,
&body, &meta, &createdAt, &params, &body, &meta, &createdAt,
) )
if err != nil { if err != nil {
return nil, fallbackQID, fmt.Errorf( return nil, fallbackQID, fmt.Errorf(
@@ -701,6 +707,10 @@ 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)

View File

@@ -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", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, 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", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, 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",
json.RawMessage(`["msg"]`), nil, 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", body, nil, ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -50,6 +50,7 @@ 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

View File

@@ -28,18 +28,6 @@ const (
defaultHistLimit = 50 defaultHistLimit = 50
maxHistLimit = 500 maxHistLimit = 500
cmdPrivmsg = "PRIVMSG" cmdPrivmsg = "PRIVMSG"
// IRC numeric reply codes per RFC 1459/2812.
ircErrNoSuchNick = "401"
ircErrNoSuchChannel = "403"
ircErrNoRecipient = "411"
ircErrNoTextToSend = "412"
ircErrUnknownCommand = "421"
ircErrNoNicknameGiven = "431"
ircErrErroneusNickname = "432"
ircErrNicknameInUse = "433"
ircErrNotOnChannel = "442"
ircErrNeedMoreParams = "461"
) )
func (hdlr *Handlers) maxBodySize() int64 { func (hdlr *Handlers) maxBodySize() int64 {
@@ -75,43 +63,6 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil return sessionID, clientID, nick, nil
} }
// serverName returns the configured server name or the
// default "neoirc".
func (hdlr *Handlers) serverName() string {
if hdlr.params.Config.ServerName != "" {
return hdlr.params.Config.ServerName
}
return "neoirc"
}
// respondIRCError sends an IRC numeric error reply as a
// JSON response with HTTP 200 OK. This is used for errors
// in IRC command processing (as opposed to HTTP transport
// errors). The params slice contains IRC-style positional
// parameters (e.g. the target nick or channel).
func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
nick, numeric, msg string,
params ...string,
) {
resp := map[string]any{
"command": numeric,
"from": hdlr.serverName(),
"to": nick,
"body": []string{msg},
}
if len(params) > 0 {
resp["params"] = params
}
hdlr.respondJSON(
writer, request, resp, http.StatusOK,
)
}
func (hdlr *Handlers) requireAuth( func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -140,7 +91,7 @@ func (hdlr *Handlers) fanOut(
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, body, nil, request.Context(), command, from, target, nil, body, nil,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -234,7 +185,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
@@ -268,45 +219,76 @@ func (hdlr *Handlers) handleCreateSessionError(
) )
} }
// deliverWelcome sends the RPL_WELCOME (001) numeric to a
// new client.
func (hdlr *Handlers) deliverWelcome(
request *http.Request,
clientID int64,
nick string,
) {
ctx := request.Context()
hdlr.enqueueNumeric(
ctx, clientID, "001", nick, nil,
"Welcome to the network, "+nick,
)
}
// deliverMOTD sends the MOTD as IRC numeric messages to a // deliverMOTD sends the MOTD as IRC numeric messages to a
// new client. // new client.
func (hdlr *Handlers) deliverMOTD( func (hdlr *Handlers) deliverMOTD(
request *http.Request, request *http.Request,
clientID, sessionID int64, clientID, sessionID int64,
nick string,
) { ) {
motd := hdlr.params.Config.MOTD motd := hdlr.params.Config.MOTD
serverName := hdlr.serverName() srvName := hdlr.serverName()
if motd == "" {
return
}
ctx := request.Context() ctx := request.Context()
hdlr.deliverWelcome(request, clientID, nick)
if motd == "" {
hdlr.broker.Notify(sessionID)
return
}
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "375", serverName, ctx, clientID, "375", nick, nil,
"- "+serverName+" Message of the Day -", "- "+srvName+" Message of the Day -",
) )
for line := range strings.SplitSeq(motd, "\n") { for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "372", serverName, ctx, clientID, "372", nick, nil,
"- "+line, "- "+line,
) )
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, "376", serverName, ctx, clientID, "376", nick, nil,
"End of /MOTD command.", "End of /MOTD command.",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
} }
func (hdlr *Handlers) serverName() string {
name := hdlr.params.Config.ServerName
if name == "" {
return "neoirc"
}
return name
}
func (hdlr *Handlers) enqueueNumeric( func (hdlr *Handlers) enqueueNumeric(
ctx context.Context, ctx context.Context,
clientID int64, clientID int64,
command, serverName, text string, command, nick string,
params []string,
text string,
) { ) {
body, err := json.Marshal([]string{text}) body, err := json.Marshal([]string{text})
if err != nil { if err != nil {
@@ -317,9 +299,22 @@ func (hdlr *Handlers) enqueueNumeric(
return return
} }
var paramsJSON json.RawMessage
if len(params) > 0 {
paramsJSON, err = json.Marshal(params)
if err != nil {
hdlr.log.Error(
"marshal numeric params", "error", err,
)
return
}
}
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, serverName, "", ctx, command, hdlr.serverName(), nick,
json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -577,7 +572,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request.Body, hdlr.maxBodySize(), writer, request.Body, hdlr.maxBodySize(),
) )
sessionID, _, nick, ok := sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request) hdlr.requireAuth(writer, request)
if !ok { if !ok {
return return
@@ -602,10 +597,10 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
payload.To = strings.TrimSpace(payload.To) payload.To = strings.TrimSpace(payload.To)
if payload.Command == "" { if payload.Command == "" {
hdlr.respondIRCError( hdlr.respondError(
writer, request, writer, request,
nick, ircErrUnknownCommand, "command required",
"No command given", http.StatusBadRequest,
) )
return return
@@ -627,7 +622,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
} }
hdlr.dispatchCommand( hdlr.dispatchCommand(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
payload.Command, payload.To, payload.Command, payload.To,
payload.Body, bodyLines, payload.Body, bodyLines,
) )
@@ -637,7 +633,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
func (hdlr *Handlers) dispatchCommand( func (hdlr *Handlers) dispatchCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
@@ -645,24 +641,30 @@ func (hdlr *Handlers) dispatchCommand(
switch command { switch command {
case cmdPrivmsg, "NOTICE": case cmdPrivmsg, "NOTICE":
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines, command, target, body, bodyLines,
) )
case "JOIN": case "JOIN":
hdlr.handleJoin( hdlr.handleJoin(
writer, request, sessionID, nick, target, writer, request,
sessionID, clientID, nick, target,
) )
case "PART": case "PART":
hdlr.handlePart( hdlr.handlePart(
writer, request, sessionID, nick, target, body, writer, request,
sessionID, clientID, nick, target, body,
) )
case "NICK": case "NICK":
hdlr.handleNick( hdlr.handleNick(
writer, request, sessionID, nick, bodyLines, writer, request,
sessionID, clientID, nick, bodyLines,
) )
case "TOPIC": case "TOPIC":
hdlr.handleTopic( hdlr.handleTopic(
writer, request, nick, target, body, bodyLines, writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
) )
case "QUIT": case "QUIT":
hdlr.handleQuit( hdlr.handleQuit(
@@ -672,51 +674,63 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"command": "PONG", "command": "PONG",
"from": hdlr.params.Config.ServerName, "from": hdlr.serverName(),
}, },
http.StatusOK) http.StatusOK)
default: default:
hdlr.respondIRCError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
nick, ircErrUnknownCommand, "421", nick, []string{command},
command+" :Unknown command", "Unknown command",
command,
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
} }
} }
func (hdlr *Handlers) handlePrivmsg( func (hdlr *Handlers) handlePrivmsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
hdlr.respondIRCError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
nick, ircErrNoRecipient, "461", nick, []string{command},
"No recipient given ("+command+")", "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
nick, ircErrNoTextToSend, "461", nick, []string{command},
"No text to send", "Not enough parameters",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg( hdlr.handleChannelMsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, command, target, body,
) )
@@ -724,15 +738,36 @@ func (hdlr *Handlers) handlePrivmsg(
} }
hdlr.handleDirectMsg( hdlr.handleDirectMsg(
writer, request, sessionID, nick, writer, request,
sessionID, clientID, nick,
command, target, body, command, target, body,
) )
} }
// respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
numeric, nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
numeric, nick, params, text,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
func (hdlr *Handlers) handleChannelMsg( func (hdlr *Handlers) handleChannelMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
@@ -741,10 +776,9 @@ func (hdlr *Handlers) handleChannelMsg(
) )
if err != nil { if err != nil {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNoSuchChannel, "403", nick, []string{target},
"No such channel", "No such channel",
target,
) )
return return
@@ -768,15 +802,26 @@ func (hdlr *Handlers) handleChannelMsg(
if !isMember { if !isMember {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNotOnChannel, "442", nick, []string{target},
"You're not on that channel", "You're not on that channel",
target,
) )
return return
} }
hdlr.sendChannelMsg(
writer, request, command, nick, target, body, chID,
)
}
func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter,
request *http.Request,
command, nick, target string,
body json.RawMessage,
chID int64,
) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID, request.Context(), chID,
) )
@@ -815,7 +860,7 @@ func (hdlr *Handlers) handleChannelMsg(
func (hdlr *Handlers) handleDirectMsg( func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
@@ -823,12 +868,15 @@ func (hdlr *Handlers) handleDirectMsg(
request.Context(), target, request.Context(), target,
) )
if err != nil { if err != nil {
hdlr.respondIRCError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
nick, ircErrNoSuchNick, "401", nick, []string{target},
"No such nick/channel", "No such nick/channel",
target,
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -860,15 +908,14 @@ func (hdlr *Handlers) handleDirectMsg(
func (hdlr *Handlers) handleJoin( func (hdlr *Handlers) handleJoin(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, target string, nick, target string,
) { ) {
if target == "" { if target == "" {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNeedMoreParams, "461", nick, []string{"JOIN"},
"Not enough parameters", "Not enough parameters",
"JOIN",
) )
return return
@@ -881,15 +928,26 @@ func (hdlr *Handlers) handleJoin(
if !validChannelRe.MatchString(channel) { if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNoSuchChannel, "403", nick, []string{channel},
"No such channel", "No such channel",
channel,
) )
return return
} }
hdlr.executeJoin(
writer, request,
sessionID, clientID, nick, channel,
)
}
func (hdlr *Handlers) executeJoin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
) {
chID, err := hdlr.params.Database.GetOrCreateChannel( chID, err := hdlr.params.Database.GetOrCreateChannel(
request.Context(), channel, request.Context(), channel,
) )
@@ -930,6 +988,10 @@ func (hdlr *Handlers) handleJoin(
request, "JOIN", nick, channel, nil, memberIDs, request, "JOIN", nick, channel, nil, memberIDs,
) )
hdlr.deliverJoinNumerics(
request, clientID, sessionID, nick, channel, chID,
)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"status": "joined", "status": "joined",
@@ -938,20 +1000,96 @@ func (hdlr *Handlers) handleJoin(
http.StatusOK) http.StatusOK)
} }
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics(
request *http.Request,
clientID, sessionID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
chInfo, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err == nil {
_ = chInfo // chInfo is the ID; topic comes from DB.
}
// Get topic from channel info.
channels, listErr := hdlr.params.Database.ListChannels(
ctx, sessionID,
)
topic := ""
if listErr == nil {
for _, ch := range channels {
if ch.Name == channel {
topic = ch.Topic
break
}
}
}
if topic != "" {
hdlr.enqueueNumeric(
ctx, clientID, "332", nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, "331", nick,
[]string{channel}, "No topic is set",
)
}
// Get member list for NAMES reply.
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handlePart( func (hdlr *Handlers) handlePart(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick, target string, nick, target string,
body json.RawMessage, body json.RawMessage,
) { ) {
if target == "" { if target == "" {
hdlr.respondIRCError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
nick, ircErrNeedMoreParams, "461", nick, []string{"PART"},
"Not enough parameters", "Not enough parameters",
"PART",
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -965,12 +1103,15 @@ func (hdlr *Handlers) handlePart(
request.Context(), channel, request.Context(), channel,
) )
if err != nil { if err != nil {
hdlr.respondIRCError( hdlr.enqueueNumeric(
writer, request, request.Context(), clientID,
nick, ircErrNoSuchChannel, "403", nick, []string{channel},
"No such channel", "No such channel",
channel,
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return return
} }
@@ -1014,16 +1155,16 @@ func (hdlr *Handlers) handlePart(
func (hdlr *Handlers) handleNick( func (hdlr *Handlers) handleNick(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID, clientID int64,
nick string, nick string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNoNicknameGiven, "461", nick, []string{"NICK"},
"No nickname given", "Not enough parameters",
) )
return return
@@ -1033,10 +1174,9 @@ func (hdlr *Handlers) handleNick(
if !validNickRe.MatchString(newNick) { if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrErroneusNickname, "432", nick, []string{newNick},
"Erroneous nickname", "Erroneous nickname",
newNick,
) )
return return
@@ -1052,16 +1192,27 @@ func (hdlr *Handlers) handleNick(
return return
} }
hdlr.executeNickChange(
writer, request,
sessionID, clientID, nick, newNick,
)
}
func (hdlr *Handlers) executeNickChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, newNick string,
) {
err := hdlr.params.Database.ChangeNick( err := hdlr.params.Database.ChangeNick(
request.Context(), sessionID, newNick, request.Context(), sessionID, newNick,
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNicknameInUse, "433", nick, []string{newNick},
"Nickname is already in use", "Nickname is already in use",
newNick,
) )
return return
@@ -1111,7 +1262,7 @@ func (hdlr *Handlers) broadcastNick(
dbID, _, _ := hdlr.params.Database.InsertMessage( dbID, _, _ := hdlr.params.Database.InsertMessage(
request.Context(), "NICK", oldNick, "", request.Context(), "NICK", oldNick, "",
json.RawMessage(nickBody), nil, nil, json.RawMessage(nickBody), nil,
) )
_ = hdlr.params.Database.EnqueueToSession( _ = hdlr.params.Database.EnqueueToSession(
@@ -1143,16 +1294,16 @@ func (hdlr *Handlers) broadcastNick(
func (hdlr *Handlers) handleTopic( func (hdlr *Handlers) handleTopic(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID, clientID int64,
nick, target string, nick, target string,
body json.RawMessage, body json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNeedMoreParams, "461", nick, []string{"TOPIC"},
"Not enough parameters", "Not enough parameters",
"TOPIC",
) )
return return
@@ -1161,47 +1312,59 @@ func (hdlr *Handlers) handleTopic(
lines := bodyLines() lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNeedMoreParams, "461", nick, []string{"TOPIC"},
"Not enough parameters", "Not enough parameters",
"TOPIC",
) )
return return
} }
topic := strings.Join(lines, " ")
channel := target channel := target
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
channel = "#" + channel channel = "#" + channel
} }
err := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
)
if err != nil {
hdlr.log.Error(
"set topic failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), channel, request.Context(), channel,
) )
if err != nil { if err != nil {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, writer, request, clientID, sessionID,
nick, ircErrNoSuchChannel, "403", nick, []string{channel},
"No such channel", "No such channel",
channel, )
return
}
hdlr.executeTopic(
writer, request,
sessionID, clientID, nick,
channel, strings.Join(lines, " "),
body, chID,
)
}
func (hdlr *Handlers) executeTopic(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel, topic string,
body json.RawMessage,
chID int64,
) {
setErr := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
)
if setErr != nil {
hdlr.log.Error(
"set topic failed", "error", setErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
) )
return return
@@ -1215,6 +1378,12 @@ func (hdlr *Handlers) handleTopic(
request, "TOPIC", nick, channel, body, memberIDs, request, "TOPIC", nick, channel, body, memberIDs,
) )
hdlr.enqueueNumeric(
request.Context(), clientID,
"332", nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{ map[string]string{
"status": "ok", "topic": topic, "status": "ok", "topic": topic,
@@ -1240,7 +1409,8 @@ func (hdlr *Handlers) handleQuit(
if len(channels) > 0 { if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage( dbID, _, _ = hdlr.params.Database.InsertMessage(
request.Context(), "QUIT", nick, "", body, nil, request.Context(), "QUIT", nick, "",
nil, body, nil,
) )
} }
@@ -1489,7 +1659,8 @@ func (hdlr *Handlers) cleanupUser(
if len(channels) > 0 { if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage( quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, "QUIT", nick, "", nil, nil, ctx, "QUIT", nick, "",
nil, nil, nil,
) )
} }

View File

@@ -462,6 +462,19 @@ func findMessage(
return false return false
} }
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
for _, msg := range msgs {
if msg[commandKey] == numeric {
return true
}
}
return false
}
// --- Tests --- // --- Tests ---
func TestCreateSessionValid(t *testing.T) { func TestCreateSessionValid(t *testing.T) {
@@ -473,6 +486,47 @@ 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")
@@ -668,17 +722,22 @@ func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("joiner3") token := tserver.createSession("joiner3")
status, result := tserver.sendCommand( // Drain initial MOTD/welcome numerics.
_, 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)
} }
if result[commandKey] != "461" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected IRC 461, got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -735,17 +794,21 @@ func TestMessageMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#test", commandKey: joinCmd, toKey: "#test",
}) })
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
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)
} }
if result[commandKey] != "412" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected IRC 412, got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -754,7 +817,9 @@ func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("noto") token := tserver.createSession("noto")
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
bodyKey: []string{"hello"}, bodyKey: []string{"hello"},
}) })
@@ -762,10 +827,12 @@ func TestMessageMissingTo(t *testing.T) {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
if result[commandKey] != "411" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected IRC 411, got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -780,8 +847,10 @@ 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, result := tserver.sendCommand( status, _ := tserver.sendCommand(
aliceToken, aliceToken,
map[string]any{ map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
@@ -793,10 +862,12 @@ func TestNonMemberCannotSend(t *testing.T) {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
if result[commandKey] != "442" { msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf( t.Fatalf(
"expected IRC 442, got %v", "expected ERR_NOTONCHANNEL (442), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -846,7 +917,9 @@ func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("dmsender") token := tserver.createSession("dmsender")
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
toKey: "nobody", toKey: "nobody",
bodyKey: []string{"hello?"}, bodyKey: []string{"hello?"},
@@ -855,10 +928,12 @@ func TestDMToNonexistentUser(t *testing.T) {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
if result[commandKey] != "401" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf( t.Fatalf(
"expected IRC 401, got %v", "expected ERR_NOSUCHNICK (401), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -906,7 +981,9 @@ func TestNickCollision(t *testing.T) {
tserver.createSession("taken_nick") tserver.createSession("taken_nick")
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"taken_nick"}, bodyKey: []string{"taken_nick"},
}) })
@@ -914,10 +991,12 @@ func TestNickCollision(t *testing.T) {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
if result[commandKey] != "433" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf( t.Fatalf(
"expected IRC 433, got %v", "expected ERR_NICKNAMEINUSE (433), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -926,7 +1005,9 @@ func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nickval") token := tserver.createSession("nickval")
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"bad nick!"}, bodyKey: []string{"bad nick!"},
}) })
@@ -934,10 +1015,12 @@ func TestNickInvalid(t *testing.T) {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
if result[commandKey] != "432" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf( t.Fatalf(
"expected IRC 432, got %v", "expected ERR_ERRONEUSNICKNAME (432), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -946,17 +1029,21 @@ func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nicknobody") token := tserver.createSession("nicknobody")
status, result := tserver.sendCommand( _, lastID := tserver.pollMessages(token, 0)
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)
} }
if result[commandKey] != "431" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected IRC 431, got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -994,7 +1081,9 @@ func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("topicnoto") token := tserver.createSession("topicnoto")
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", commandKey: "TOPIC",
bodyKey: []string{"topic"}, bodyKey: []string{"topic"},
}) })
@@ -1002,10 +1091,12 @@ func TestTopicMissingTo(t *testing.T) {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 200, got %d", status)
} }
if result[commandKey] != "461" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected IRC 461, got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -1018,17 +1109,21 @@ func TestTopicMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#topictest", commandKey: joinCmd, toKey: "#topictest",
}) })
status, result := tserver.sendCommand(token, map[string]any{ _, lastID := tserver.pollMessages(token, 0)
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)
} }
if result[commandKey] != "461" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf( t.Fatalf(
"expected IRC 461, got %v", "expected ERR_NEEDMOREPARAMS (461), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -1097,17 +1192,21 @@ func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("cmdtest") token := tserver.createSession("cmdtest")
status, result := tserver.sendCommand( _, lastID := tserver.pollMessages(token, 0)
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)
} }
if result[commandKey] != "421" { msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf( t.Fatalf(
"expected IRC 421, got %v", "expected ERR_UNKNOWNCOMMAND (421), got %v",
result[commandKey], msgs,
) )
} }
} }
@@ -1116,18 +1215,11 @@ func TestEmptyCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("emptycmd") token := tserver.createSession("emptycmd")
status, result := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: ""}, token, map[string]any{commandKey: ""},
) )
if status != http.StatusOK { if status != http.StatusBadRequest {
t.Fatalf("expected 200, got %d", status) t.Fatalf("expected 400, got %d", status)
}
if result[commandKey] != "421" {
t.Fatalf(
"expected IRC 421, got %v",
result[commandKey],
)
} }
} }
@@ -1362,12 +1454,18 @@ 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(apiMessages+"?timeout=1"), tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
token, token,
nil, nil,
) )

View File

@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
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, _, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser( hdlr.params.Database.LoginUser(
request.Context(), request.Context(),
payload.Nick, payload.Nick,
@@ -178,6 +178,10 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.deliverMOTD(
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,

4
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

575
web/dist/style.css vendored
View File

@@ -1,466 +1,317 @@
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
--bg: #0a0e14; --bg: #1a1a2e;
--bg-panel: #0d1117; --bg-secondary: #16213e;
--bg-input: #0d1117; --bg-input: #0f3460;
--bg-tab: #161b22; --text: #e0e0e0;
--bg-tab-active: #0d1117; --text-muted: #888;
--bg-topic: #0d1117; --accent: #e94560;
--text: #c9d1d9; --accent2: #0f3460;
--text-dim: #6e7681; --border: #2a2a4a;
--text-bright: #e6edf3; --nick: #53a8b6;
--accent: #58a6ff; --timestamp: #666;
--accent-dim: #1f6feb; --tab-active: #e94560;
--border: #21262d; --tab-bg: #16213e;
--system: #7d8590; --tab-hover: #1a1a3e;
--action: #d2a8ff; --topic-bg: #121a30;
--warn: #d29922; --unread-bg: #e94560;
--error: #f85149; --warn: #f0ad4e;
--unread: #f0883e;
--nick-brackets: #6e7681;
--timestamp: #484f58;
--input-bg: #161b22;
--prompt: #3fb950;
--tab-indicator: #58a6ff;
--user-list-bg: #0d1117;
--user-list-header: #484f58;
} }
html, html, body, #root {
body,
#root {
height: 100%; height: 100%;
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", font-family: 'Courier New', Courier, monospace;
"Consolas", "Liberation Mono", "Courier New", monospace; font-size: 14px;
font-size: 13px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
overflow: hidden;
} }
/* ============================================ /* Login screen */
Login Screen
============================================ */
.login-screen { .login-screen {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
background: var(--bg); gap: 16px;
} }
.login-box { .login-screen h1 {
text-align: center;
max-width: 360px;
width: 100%;
padding: 32px;
}
.login-box h1 {
color: var(--accent); color: var(--accent);
font-size: 1.8em; font-size: 2em;
margin-bottom: 16px;
font-weight: 400;
} }
.login-box .motd { .login-screen input {
color: var(--text-dim); padding: 10px 16px;
font-size: 12px; font-size: 16px;
margin-bottom: 20px;
text-align: left;
white-space: pre-wrap;
font-family: inherit; font-family: inherit;
border-left: 2px solid var(--border); background: var(--bg-input);
padding-left: 12px;
}
.login-box form {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.login-box label {
color: var(--text-dim);
text-align: left;
font-size: 12px;
}
.login-box input {
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
background: var(--input-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-bright); color: var(--text);
border-radius: 3px; border-radius: 4px;
outline: none; width: 280px;
} }
.login-box input:focus { .login-screen button {
border-color: var(--accent-dim); padding: 10px 24px;
} font-size: 16px;
.login-box button {
padding: 8px 16px;
font-family: inherit; font-family: inherit;
font-size: 14px;
background: var(--accent-dim);
border: none;
color: var(--text-bright);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}
.login-box button:hover {
background: var(--accent); background: var(--accent);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
} }
.login-box .error { .login-screen .error {
color: var(--error); color: var(--accent);
font-size: 12px;
margin-top: 8px;
} }
/* ============================================ .login-screen .motd {
IRC App Layout color: var(--text-muted);
============================================ */ max-width: 400px;
text-align: center;
white-space: pre-wrap;
}
.irc-app { /* Main layout */
.app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: hidden;
} }
/* ============================================ /* Tab bar */
Tab Bar
============================================ */
.tab-bar { .tab-bar {
display: flex; display: flex;
background: var(--bg-tab); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 32px;
align-items: stretch;
}
.tabs {
display: flex;
overflow-x: auto; overflow-x: auto;
flex: 1; flex-shrink: 0;
scrollbar-width: none; align-items: center;
}
.tabs::-webkit-scrollbar {
display: none;
} }
.tab { .tab {
display: flex; padding: 8px 16px;
align-items: center;
padding: 0 12px;
cursor: pointer; cursor: pointer;
color: var(--text-dim); border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
color: var(--text-muted);
user-select: none; user-select: none;
border-right: 1px solid var(--border);
font-size: 12px;
gap: 4px;
position: relative; position: relative;
} }
.tab:hover { .tab:hover {
color: var(--text); background: var(--tab-hover);
background: rgba(255, 255, 255, 0.03);
} }
.tab.active { .tab.active {
color: var(--text-bright); color: var(--text);
background: var(--bg-tab-active); border-bottom-color: var(--tab-active);
border-bottom: 2px solid var(--tab-indicator);
margin-bottom: -1px;
} }
.tab.has-unread .tab-label { .tab .close-btn {
color: var(--unread); margin-left: 8px;
font-weight: bold; color: var(--text-muted);
}
.tab .unread-count {
color: var(--unread);
font-size: 11px;
font-weight: bold;
}
.tab-close {
color: var(--text-dim);
font-size: 14px;
line-height: 1;
margin-left: 2px;
}
.tab-close:hover {
color: var(--error);
}
.status-area {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
font-size: 12px; font-size: 12px;
} }
.status-nick { .tab .close-btn:hover {
color: var(--accent); color: var(--accent);
}
.tab .unread-badge {
display: inline-block;
background: var(--unread-bg);
color: white;
font-size: 10px;
font-weight: bold; font-weight: bold;
padding: 1px 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
} }
.status-warn { /* Connection status */
color: var(--warn); .connection-status {
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ============================================
Topic Bar
============================================ */
.topic-bar {
padding: 4px 12px; padding: 4px 12px;
background: var(--bg-topic); background: var(--warn);
color: #1a1a2e;
font-size: 12px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
}
/* Topic bar */
.topic-bar {
padding: 6px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-shrink: 0; flex-shrink: 0;
line-height: 1.5;
} }
.topic-label { /* Content area */
color: var(--text-dim); .content {
}
.topic-text {
color: var(--text);
}
/* ============================================
Main Content Area
============================================ */
.main-area {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
min-height: 0;
} }
/* ============================================ /* Messages */
Messages Panel .messages-pane {
============================================ */
.messages-panel {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
.messages-scroll { .messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 8px; padding: 8px 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
} }
.messages-scroll::-webkit-scrollbar {
width: 8px;
}
.messages-scroll::-webkit-scrollbar-track {
background: transparent;
}
.messages-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
/* ============================================
Message Lines
============================================ */
.message { .message {
padding: 1px 0; padding: 2px 0;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
font-size: 13px;
} }
.message .timestamp { .message .timestamp {
color: var(--timestamp); color: var(--timestamp);
font-size: 12px; font-size: 12px;
margin-right: 8px;
} }
.message .nick { .message .nick {
color: var(--nick);
font-weight: bold; font-weight: bold;
margin-right: 8px;
} }
.message .content { .message .nick::before { content: '<'; }
color: var(--text); .message .nick::after { content: '>'; }
}
/* System messages (joins, parts, quits, etc.) */ .message.system {
.system-message { color: var(--text-muted);
color: var(--system);
}
.system-message .system-text {
color: var(--system);
}
/* /me action messages */
.action-message .action-text {
color: var(--action);
}
/* ============================================
User List (Right Panel)
============================================ */
.user-list {
width: 160px;
background: var(--user-list-bg);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.user-list-header {
padding: 6px 10px;
color: var(--user-list-header);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
padding: 4px 0;
flex: 1;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.nick-entry {
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.nick-entry:hover {
background: rgba(255, 255, 255, 0.04);
}
.nick-prefix {
color: var(--text-dim);
display: inline-block;
width: 1ch;
text-align: right;
margin-right: 1px;
}
.nick-name {
font-weight: normal;
}
/* ============================================
Input Line (Bottom)
============================================ */
.input-line {
display: flex;
align-items: center;
background: var(--input-bg);
border-top: 1px solid var(--border);
flex-shrink: 0;
height: 36px;
padding: 0 8px;
gap: 6px;
}
.input-prompt {
color: var(--prompt);
font-size: 13px;
flex-shrink: 0;
white-space: nowrap;
}
.input-line input {
flex: 1;
padding: 4px 0;
font-family: inherit;
font-size: 13px;
background: transparent;
border: none;
color: var(--text-bright);
outline: none;
caret-color: var(--accent);
}
.input-line input::placeholder {
color: var(--text-dim);
font-style: italic; font-style: italic;
} }
/* ============================================ .message.system .nick {
Responsive color: var(--text-muted);
============================================ */ }
@media (max-width: 600px) { .message.system .nick::before,
.user-list { .message.system .nick::after { content: ''; }
display: none;
} /* Input */
.input-bar {
.tab { display: flex;
padding: 0 8px; border-top: 1px solid var(--border);
font-size: 11px; background: var(--bg-secondary);
} flex-shrink: 0;
}
.input-prompt {
font-size: 12px; .input-bar input {
} flex: 1;
padding: 10px 12px;
font-family: inherit;
font-size: 14px;
background: var(--bg-input);
border: none;
color: var(--text);
outline: none;
}
.input-bar button {
padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
}
/* User list */
.user-list {
width: 160px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
flex-shrink: 0;
}
.user-list h3 {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 1px;
}
.user-list .user {
padding: 3px 4px;
color: var(--nick);
font-size: 13px;
cursor: pointer;
}
.user-list .user:hover {
background: var(--tab-hover);
}
/* Server tab */
.server-messages {
color: var(--text-muted);
padding: 12px;
white-space: pre-wrap;
overflow-y: auto;
flex: 1;
}
/* Channel join dialog */
.join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
}
.join-dialog input {
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
width: 200px;
}
.join-dialog button {
padding: 6px 14px;
font-family: inherit;
font-size: 13px;
background: var(--accent2);
border: none;
color: var(--text);
border-radius: 3px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 600px) {
.user-list { display: none; }
.tab { padding: 6px 10px; font-size: 13px; }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,466 +1,317 @@
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
--bg: #0a0e14; --bg: #1a1a2e;
--bg-panel: #0d1117; --bg-secondary: #16213e;
--bg-input: #0d1117; --bg-input: #0f3460;
--bg-tab: #161b22; --text: #e0e0e0;
--bg-tab-active: #0d1117; --text-muted: #888;
--bg-topic: #0d1117; --accent: #e94560;
--text: #c9d1d9; --accent2: #0f3460;
--text-dim: #6e7681; --border: #2a2a4a;
--text-bright: #e6edf3; --nick: #53a8b6;
--accent: #58a6ff; --timestamp: #666;
--accent-dim: #1f6feb; --tab-active: #e94560;
--border: #21262d; --tab-bg: #16213e;
--system: #7d8590; --tab-hover: #1a1a3e;
--action: #d2a8ff; --topic-bg: #121a30;
--warn: #d29922; --unread-bg: #e94560;
--error: #f85149; --warn: #f0ad4e;
--unread: #f0883e;
--nick-brackets: #6e7681;
--timestamp: #484f58;
--input-bg: #161b22;
--prompt: #3fb950;
--tab-indicator: #58a6ff;
--user-list-bg: #0d1117;
--user-list-header: #484f58;
} }
html, html, body, #root {
body,
#root {
height: 100%; height: 100%;
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", font-family: 'Courier New', Courier, monospace;
"Consolas", "Liberation Mono", "Courier New", monospace; font-size: 14px;
font-size: 13px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
overflow: hidden;
} }
/* ============================================ /* Login screen */
Login Screen
============================================ */
.login-screen { .login-screen {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
background: var(--bg); gap: 16px;
} }
.login-box { .login-screen h1 {
text-align: center;
max-width: 360px;
width: 100%;
padding: 32px;
}
.login-box h1 {
color: var(--accent); color: var(--accent);
font-size: 1.8em; font-size: 2em;
margin-bottom: 16px;
font-weight: 400;
} }
.login-box .motd { .login-screen input {
color: var(--text-dim); padding: 10px 16px;
font-size: 12px; font-size: 16px;
margin-bottom: 20px;
text-align: left;
white-space: pre-wrap;
font-family: inherit; font-family: inherit;
border-left: 2px solid var(--border); background: var(--bg-input);
padding-left: 12px;
}
.login-box form {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.login-box label {
color: var(--text-dim);
text-align: left;
font-size: 12px;
}
.login-box input {
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
background: var(--input-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-bright); color: var(--text);
border-radius: 3px; border-radius: 4px;
outline: none; width: 280px;
} }
.login-box input:focus { .login-screen button {
border-color: var(--accent-dim); padding: 10px 24px;
} font-size: 16px;
.login-box button {
padding: 8px 16px;
font-family: inherit; font-family: inherit;
font-size: 14px;
background: var(--accent-dim);
border: none;
color: var(--text-bright);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}
.login-box button:hover {
background: var(--accent); background: var(--accent);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
} }
.login-box .error { .login-screen .error {
color: var(--error); color: var(--accent);
font-size: 12px;
margin-top: 8px;
} }
/* ============================================ .login-screen .motd {
IRC App Layout color: var(--text-muted);
============================================ */ max-width: 400px;
text-align: center;
white-space: pre-wrap;
}
.irc-app { /* Main layout */
.app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: hidden;
} }
/* ============================================ /* Tab bar */
Tab Bar
============================================ */
.tab-bar { .tab-bar {
display: flex; display: flex;
background: var(--bg-tab); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 32px;
align-items: stretch;
}
.tabs {
display: flex;
overflow-x: auto; overflow-x: auto;
flex: 1; flex-shrink: 0;
scrollbar-width: none; align-items: center;
}
.tabs::-webkit-scrollbar {
display: none;
} }
.tab { .tab {
display: flex; padding: 8px 16px;
align-items: center;
padding: 0 12px;
cursor: pointer; cursor: pointer;
color: var(--text-dim); border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
color: var(--text-muted);
user-select: none; user-select: none;
border-right: 1px solid var(--border);
font-size: 12px;
gap: 4px;
position: relative; position: relative;
} }
.tab:hover { .tab:hover {
color: var(--text); background: var(--tab-hover);
background: rgba(255, 255, 255, 0.03);
} }
.tab.active { .tab.active {
color: var(--text-bright); color: var(--text);
background: var(--bg-tab-active); border-bottom-color: var(--tab-active);
border-bottom: 2px solid var(--tab-indicator);
margin-bottom: -1px;
} }
.tab.has-unread .tab-label { .tab .close-btn {
color: var(--unread); margin-left: 8px;
font-weight: bold; color: var(--text-muted);
}
.tab .unread-count {
color: var(--unread);
font-size: 11px;
font-weight: bold;
}
.tab-close {
color: var(--text-dim);
font-size: 14px;
line-height: 1;
margin-left: 2px;
}
.tab-close:hover {
color: var(--error);
}
.status-area {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
font-size: 12px; font-size: 12px;
} }
.status-nick { .tab .close-btn:hover {
color: var(--accent); color: var(--accent);
}
.tab .unread-badge {
display: inline-block;
background: var(--unread-bg);
color: white;
font-size: 10px;
font-weight: bold; font-weight: bold;
padding: 1px 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
} }
.status-warn { /* Connection status */
color: var(--warn); .connection-status {
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ============================================
Topic Bar
============================================ */
.topic-bar {
padding: 4px 12px; padding: 4px 12px;
background: var(--bg-topic); background: var(--warn);
color: #1a1a2e;
font-size: 12px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
}
/* Topic bar */
.topic-bar {
padding: 6px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-shrink: 0; flex-shrink: 0;
line-height: 1.5;
} }
.topic-label { /* Content area */
color: var(--text-dim); .content {
}
.topic-text {
color: var(--text);
}
/* ============================================
Main Content Area
============================================ */
.main-area {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
min-height: 0;
} }
/* ============================================ /* Messages */
Messages Panel .messages-pane {
============================================ */
.messages-panel {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
.messages-scroll { .messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 8px; padding: 8px 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
} }
.messages-scroll::-webkit-scrollbar {
width: 8px;
}
.messages-scroll::-webkit-scrollbar-track {
background: transparent;
}
.messages-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
/* ============================================
Message Lines
============================================ */
.message { .message {
padding: 1px 0; padding: 2px 0;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
font-size: 13px;
} }
.message .timestamp { .message .timestamp {
color: var(--timestamp); color: var(--timestamp);
font-size: 12px; font-size: 12px;
margin-right: 8px;
} }
.message .nick { .message .nick {
color: var(--nick);
font-weight: bold; font-weight: bold;
margin-right: 8px;
} }
.message .content { .message .nick::before { content: '<'; }
color: var(--text); .message .nick::after { content: '>'; }
}
/* System messages (joins, parts, quits, etc.) */ .message.system {
.system-message { color: var(--text-muted);
color: var(--system);
}
.system-message .system-text {
color: var(--system);
}
/* /me action messages */
.action-message .action-text {
color: var(--action);
}
/* ============================================
User List (Right Panel)
============================================ */
.user-list {
width: 160px;
background: var(--user-list-bg);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.user-list-header {
padding: 6px 10px;
color: var(--user-list-header);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
padding: 4px 0;
flex: 1;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.nick-entry {
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.nick-entry:hover {
background: rgba(255, 255, 255, 0.04);
}
.nick-prefix {
color: var(--text-dim);
display: inline-block;
width: 1ch;
text-align: right;
margin-right: 1px;
}
.nick-name {
font-weight: normal;
}
/* ============================================
Input Line (Bottom)
============================================ */
.input-line {
display: flex;
align-items: center;
background: var(--input-bg);
border-top: 1px solid var(--border);
flex-shrink: 0;
height: 36px;
padding: 0 8px;
gap: 6px;
}
.input-prompt {
color: var(--prompt);
font-size: 13px;
flex-shrink: 0;
white-space: nowrap;
}
.input-line input {
flex: 1;
padding: 4px 0;
font-family: inherit;
font-size: 13px;
background: transparent;
border: none;
color: var(--text-bright);
outline: none;
caret-color: var(--accent);
}
.input-line input::placeholder {
color: var(--text-dim);
font-style: italic; font-style: italic;
} }
/* ============================================ .message.system .nick {
Responsive color: var(--text-muted);
============================================ */ }
@media (max-width: 600px) { .message.system .nick::before,
.user-list { .message.system .nick::after { content: ''; }
display: none;
} /* Input */
.input-bar {
.tab { display: flex;
padding: 0 8px; border-top: 1px solid var(--border);
font-size: 11px; background: var(--bg-secondary);
} flex-shrink: 0;
}
.input-prompt {
font-size: 12px; .input-bar input {
} flex: 1;
padding: 10px 12px;
font-family: inherit;
font-size: 14px;
background: var(--bg-input);
border: none;
color: var(--text);
outline: none;
}
.input-bar button {
padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
}
/* User list */
.user-list {
width: 160px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
flex-shrink: 0;
}
.user-list h3 {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 1px;
}
.user-list .user {
padding: 3px 4px;
color: var(--nick);
font-size: 13px;
cursor: pointer;
}
.user-list .user:hover {
background: var(--tab-hover);
}
/* Server tab */
.server-messages {
color: var(--text-muted);
padding: 12px;
white-space: pre-wrap;
overflow-y: auto;
flex: 1;
}
/* Channel join dialog */
.join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
}
.join-dialog input {
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
width: 200px;
}
.join-dialog button {
padding: 6px 14px;
font-family: inherit;
font-size: 13px;
background: var(--accent2);
border: none;
color: var(--text);
border-radius: 3px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 600px) {
.user-list { display: none; }
.tab { padding: 6px 10px; font-size: 13px; }
} }