2 Commits

Author SHA1 Message Date
a193831ed4 fix: extract repeated IRC command string literals to constants (goconst)
All checks were successful
check / check (push) Successful in 2m18s
2026-03-09 15:07:02 -07:00
clawbot
8d26df60db feat: implement IRC numerics batch 2 — connection registration, channel ops, user queries
Add comprehensive IRC numeric reply support:

Connection registration (001-005):
- 002 RPL_YOURHOST, 003 RPL_CREATED, 004 RPL_MYINFO, 005 RPL_ISUPPORT
- All sent automatically during session creation after RPL_WELCOME

Server statistics (251-255):
- RPL_LUSERCLIENT, RPL_LUSEROP, RPL_LUSERCHANNELS, RPL_LUSERME
- Sent during connection registration and via LUSERS command

Channel operations:
- MODE command: query channel modes (324 RPL_CHANNELMODEIS, 329 RPL_CREATIONTIME)
- MODE command: query user modes (221 RPL_UMODEIS)
- NAMES command: query channel member list (353/366)
- LIST command: list all channels (322 RPL_LIST, 323 end of list)

User queries:
- WHOIS command: 311/312/318/319 numerics
- WHO command: 352 RPL_WHOREPLY, 315 RPL_ENDOFWHO

Database additions:
- GetChannelCount, ListAllChannelsWithCounts
- GetChannelCreatedAt, GetSessionCreatedAt

Also adds StartTime to Globals for RPL_CREATED and updates README
with comprehensive documentation of all new commands and numerics.

closes #52
2026-03-09 15:04:10 -07:00
17 changed files with 861 additions and 480 deletions

1
.gitignore vendored
View File

@@ -21,7 +21,6 @@ node_modules/
*.key
# Build artifacts
web/dist/
/neoircd
/bin/
*.exe

View File

@@ -1,13 +1,3 @@
# Web build stage — compile SPA from source
# node:22-alpine, 2026-03-09
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/src/ src/
COPY web/build.sh build.sh
RUN sh build.sh
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
@@ -15,9 +5,6 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
# without depending on the web-builder stage (lint should fail fast)
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
RUN make fmt-check
RUN make lint
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web-builder /web/dist/ web/dist/
RUN make test

View File

@@ -1032,12 +1032,6 @@ Return the current user's session state.
**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`
```json
{
@@ -1070,12 +1064,6 @@ curl -s http://localhost:8080/api/v1/state \
-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)
Retrieve messages from the client's delivery queue. This is the primary
@@ -1374,18 +1362,16 @@ Return server metadata. No authentication required.
```json
{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}
```
| Field | Type | Description |
|-----------|---------|-------------|
| `name` | string | Server display name |
| `version` | string | Server version |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
| Field | Type | Description |
|---------|---------|-------------|
| `name` | string | Server display name |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
### GET /.well-known/healthcheck.json — Health Check
@@ -1852,16 +1838,26 @@ docker run -p 8080:8080 \
neoirc
```
The Dockerfile is a four-stage build:
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
bundled JS via esbuild) into `web/dist/`
2. **lint**: Runs formatting checks and golangci-lint against the Go source
(uses empty placeholder files for `web/dist/` so it runs independently of
web-builder for fast feedback)
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
binaries with the real SPA assets from web-builder (CLI built to verify
The Dockerfile is a multi-stage build:
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
compilation, not included in final image)
4. **final**: Minimal Alpine image with only the `neoircd` binary
2. **Final stage**: Alpine Linux + `neoircd` binary only
```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /neoircd ./cmd/neoircd/
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
FROM alpine:latest
COPY --from=builder /neoircd /usr/local/bin/neoircd
EXPOSE 8080
CMD ["neoircd"]
```
### Binary
@@ -2310,14 +2306,10 @@ neoirc/
│ └── http.go # HTTP timeouts
├── web/
│ ├── embed.go # go:embed directive for SPA
── build.sh # SPA build script (esbuild, runs in Docker)
├── package.json # Node dependencies (preact, esbuild)
├── package-lock.json
├── src/ # SPA source files (JSX + HTML + CSS)
│ │ ├── app.jsx
│ │ ├── index.html
│ │ └── style.css
│ └── dist/ # Generated at Docker build time (not committed)
── dist/ # Built SPA (vanilla JS, no build step)
├── index.html
├── style.css
└── app.js
├── schema/ # JSON Schema definitions (planned)
├── go.mod
├── go.sum

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-03-09
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -98,13 +98,6 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
@@ -151,14 +144,8 @@ style conventions are in separate documents:
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
Post-1.0.0: add new migration files.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.

View File

@@ -13,8 +13,6 @@ import (
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
)
const (
@@ -170,7 +168,7 @@ func (client *Client) PollMessages(
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
&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 {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdPart, To: channel,
Command: "PART", To: channel,
},
)
}

View File

@@ -9,7 +9,6 @@ import (
"time"
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
"git.eeqj.de/sneak/neoirc/internal/irc"
)
const (
@@ -87,7 +86,7 @@ func (a *App) handleInput(text string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdPrivmsg,
Command: "PRIVMSG",
To: target,
Body: []string{text},
})
@@ -242,7 +241,7 @@ func (a *App) cmdNick(nick string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdNick,
Command: "NICK",
Body: []string{nick},
})
if err != nil {
@@ -377,7 +376,7 @@ func (a *App) cmdMsg(args string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdPrivmsg,
Command: "PRIVMSG",
To: target,
Body: []string{text},
})
@@ -435,7 +434,7 @@ func (a *App) cmdTopic(args string) {
if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
Command: "TOPIC",
To: target,
})
if err != nil {
@@ -448,7 +447,7 @@ func (a *App) cmdTopic(args string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
Command: "TOPIC",
To: target,
Body: []string{args},
})
@@ -536,7 +535,7 @@ func (a *App) cmdMotd() {
}
err := a.client.SendMessage(
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
@@ -573,7 +572,7 @@ func (a *App) cmdWho(args string) {
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: irc.CmdWho, To: channel,
Command: "WHO", To: channel,
},
)
if err != nil {
@@ -604,7 +603,7 @@ func (a *App) cmdWhois(args string) {
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: irc.CmdWhois, To: args,
Command: "WHOIS", To: args,
},
)
if err != nil {
@@ -654,7 +653,7 @@ func (a *App) cmdQuit() {
if a.connected && a.client != nil {
_ = a.client.SendMessage(
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
)
}
@@ -739,19 +738,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
a.mu.Unlock()
switch msg.Command {
case irc.CmdPrivmsg:
case "PRIVMSG":
a.handlePrivmsgEvent(msg, timestamp, myNick)
case irc.CmdJoin:
case "JOIN":
a.handleJoinEvent(msg, timestamp)
case irc.CmdPart:
case "PART":
a.handlePartEvent(msg, timestamp)
case irc.CmdQuit:
case "QUIT":
a.handleQuitEvent(msg, timestamp)
case irc.CmdNick:
case "NICK":
a.handleNickEvent(msg, timestamp, myNick)
case irc.CmdNotice:
case "NOTICE":
a.handleNoticeEvent(msg, timestamp)
case irc.CmdTopic:
case "TOPIC":
a.handleTopicEvent(msg, timestamp)
default:
a.handleDefaultEvent(msg, timestamp)

View File

@@ -7,10 +7,8 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/google/uuid"
)
@@ -35,7 +33,6 @@ func generateToken() (string, error) {
type IRCMessage struct {
ID string `json:"id"`
Command string `json:"command"`
Code int `json:"code,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
@@ -45,15 +42,6 @@ type IRCMessage struct {
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.
type ChannelInfo struct {
ID int64 `json:"id"`
@@ -729,15 +717,6 @@ func scanMessages(
msg.DBID = 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)
}

View File

@@ -10,7 +10,6 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/go-chi/chi"
)
@@ -28,6 +27,15 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
cmdJoin = "JOIN"
cmdList = "LIST"
cmdNick = "NICK"
cmdPart = "PART"
cmdPrivmsg = "PRIVMSG"
cmdQuit = "QUIT"
cmdTopic = "TOPIC"
cmdWho = "WHO"
cmdWhois = "WHOIS"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -232,20 +240,20 @@ func (hdlr *Handlers) deliverWelcome(
// 001 RPL_WELCOME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWelcome, nick, nil,
ctx, clientID, "001", nick, nil,
"Welcome to the network, "+nick,
)
// 002 RPL_YOURHOST
hdlr.enqueueNumeric(
ctx, clientID, irc.RplYourHost, nick, nil,
ctx, clientID, "002", nick, nil,
"Your host is "+srvName+
", running version "+version,
)
// 003 RPL_CREATED
hdlr.enqueueNumeric(
ctx, clientID, irc.RplCreated, nick, nil,
ctx, clientID, "003", nick, nil,
"This server was created "+
hdlr.params.Globals.StartTime.
Format("2006-01-02"),
@@ -253,14 +261,14 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick,
ctx, clientID, "004", nick,
[]string{srvName, version, "", "imnst"},
"",
)
// 005 RPL_ISUPPORT
hdlr.enqueueNumeric(
ctx, clientID, irc.RplIsupport, nick,
ctx, clientID, "005", nick,
[]string{
"CHANTYPES=#",
"NICKLEN=32",
@@ -305,7 +313,7 @@ func (hdlr *Handlers) deliverLusers(
// 251 RPL_LUSERCLIENT
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserClient, nick, nil,
ctx, clientID, "251", nick, nil,
fmt.Sprintf(
"There are %d users and 0 invisible on 1 servers",
userCount,
@@ -314,21 +322,21 @@ func (hdlr *Handlers) deliverLusers(
// 252 RPL_LUSEROP
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserOp, nick,
ctx, clientID, "252", nick,
[]string{"0"},
"operator(s) online",
)
// 254 RPL_LUSERCHANNELS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserChannels, nick,
ctx, clientID, "254", nick,
[]string{strconv.FormatInt(chanCount, 10)},
"channels formed",
)
// 255 RPL_LUSERME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserMe, nick, nil,
ctx, clientID, "255", nick, nil,
fmt.Sprintf(
"I have %d clients and 1 servers",
userCount,
@@ -366,19 +374,19 @@ func (hdlr *Handlers) deliverMOTD(
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMotdStart, nick, nil,
ctx, clientID, "375", nick, nil,
"- "+srvName+" Message of the Day -",
)
for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMotd, nick, nil,
ctx, clientID, "372", nick, nil,
"- "+line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfMotd, nick, nil,
ctx, clientID, "376", nick, nil,
"End of /MOTD command.",
)
@@ -397,13 +405,10 @@ func (hdlr *Handlers) serverName() string {
func (hdlr *Handlers) enqueueNumeric(
ctx context.Context,
clientID int64,
code int,
nick string,
command, nick string,
params []string,
text string,
) {
command := fmt.Sprintf("%03d", code)
body, err := json.Marshal([]string{text})
if err != nil {
hdlr.log.Error(
@@ -444,17 +449,13 @@ func (hdlr *Handlers) enqueueNumeric(
}
// HandleState returns the current session's info and
// channels. When called with ?initChannelState=1, it also
// enqueues synthetic JOIN + TOPIC + NAMES messages for
// every channel the session belongs to so that a
// reconnecting client can rebuild its channel tabs from
// the message stream.
// channels.
func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, clientID, nick, ok :=
sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
@@ -476,12 +477,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
return
}
if request.URL.Query().Get("initChannelState") == "1" {
hdlr.initChannelState(
request, clientID, sessionID, nick,
)
}
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
@@ -490,52 +485,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
}
}
// initChannelState enqueues synthetic JOIN messages and
// join-numerics (TOPIC, NAMES) for every channel the
// session belongs to. Messages are enqueued only to the
// specified client so other clients/sessions are not
// affected.
func (hdlr *Handlers) initChannelState(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
if err != nil || len(channels) == 0 {
return
}
for _, chanInfo := range channels {
// Enqueue a synthetic JOIN (only to this client).
dbID, _, insErr := hdlr.params.Database.
InsertMessage(
ctx, "JOIN", nick, chanInfo.Name,
nil, nil, nil,
)
if insErr != nil {
hdlr.log.Error(
"initChannelState: insert JOIN",
"error", insErr,
)
continue
}
_ = hdlr.params.Database.EnqueueToClient(
ctx, clientID, dbID,
)
// Enqueue TOPIC + NAMES numerics.
hdlr.deliverJoinNumerics(
request, clientID, sessionID,
nick, chanInfo.Name, chanInfo.ID,
)
}
}
// HandleListAllChannels returns all channels on the server.
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
return func(
@@ -809,38 +758,38 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string,
) {
switch command {
case irc.CmdPrivmsg, irc.CmdNotice:
case cmdPrivmsg, "NOTICE":
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines,
)
case irc.CmdJoin:
case cmdJoin:
hdlr.handleJoin(
writer, request,
sessionID, clientID, nick, target,
)
case irc.CmdPart:
case cmdPart:
hdlr.handlePart(
writer, request,
sessionID, clientID, nick, target, body,
)
case irc.CmdNick:
case cmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
case cmdTopic:
hdlr.handleTopic(
writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
)
case irc.CmdQuit:
case cmdQuit:
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case irc.CmdMotd, irc.CmdPing:
case "MOTD", cmdList, cmdWho, cmdWhois, "PING":
hdlr.dispatchInfoCommand(
writer, request,
sessionID, clientID, nick,
@@ -863,34 +812,34 @@ func (hdlr *Handlers) dispatchQueryCommand(
bodyLines func() []string,
) {
switch command {
case irc.CmdMode:
case "MODE":
hdlr.handleMode(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case irc.CmdNames:
case "NAMES":
hdlr.handleNames(
writer, request,
sessionID, clientID, nick, target,
)
case irc.CmdList:
case cmdList:
hdlr.handleList(
writer, request,
sessionID, clientID, nick,
)
case irc.CmdWhois:
case cmdWhois:
hdlr.handleWhois(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case irc.CmdWho:
case cmdWho:
hdlr.handleWho(
writer, request,
sessionID, clientID, nick, target,
)
case irc.CmdLusers:
case "LUSERS":
hdlr.handleLusers(
writer, request,
sessionID, clientID, nick,
@@ -898,7 +847,7 @@ func (hdlr *Handlers) dispatchQueryCommand(
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrUnknownCommand, nick, []string{command},
"421", nick, []string{command},
"Unknown command",
)
hdlr.broker.Notify(sessionID)
@@ -919,7 +868,7 @@ func (hdlr *Handlers) handlePrivmsg(
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNeedMoreParams, nick, []string{command},
"461", nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
@@ -934,7 +883,7 @@ func (hdlr *Handlers) handlePrivmsg(
if len(lines) == 0 {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNeedMoreParams, nick, []string{command},
"461", nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
@@ -968,14 +917,13 @@ func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
code int,
nick string,
numeric, nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
code, nick, params, text,
numeric, nick, params, text,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -996,7 +944,7 @@ func (hdlr *Handlers) handleChannelMsg(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{target},
"403", nick, []string{target},
"No such channel",
)
@@ -1022,7 +970,7 @@ func (hdlr *Handlers) handleChannelMsg(
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick, []string{target},
"442", nick, []string{target},
"You're not on that channel",
)
@@ -1089,7 +1037,7 @@ func (hdlr *Handlers) handleDirectMsg(
if err != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNoSuchNick, nick, []string{target},
"401", nick, []string{target},
"No such nick/channel",
)
hdlr.broker.Notify(sessionID)
@@ -1133,7 +1081,7 @@ func (hdlr *Handlers) handleJoin(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
"461", nick, []string{cmdJoin},
"Not enough parameters",
)
@@ -1148,7 +1096,7 @@ func (hdlr *Handlers) handleJoin(
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"403", nick, []string{channel},
"No such channel",
)
@@ -1204,7 +1152,7 @@ func (hdlr *Handlers) executeJoin(
)
_ = hdlr.fanOutSilent(
request, irc.CmdJoin, nick, channel, nil, memberIDs,
request, cmdJoin, nick, channel, nil, memberIDs,
)
hdlr.deliverJoinNumerics(
@@ -1255,12 +1203,12 @@ func (hdlr *Handlers) deliverJoinNumerics(
if topic != "" {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTopic, nick,
ctx, clientID, "332", nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNoTopic, nick,
ctx, clientID, "331", nick,
[]string{channel}, "No topic is set",
)
}
@@ -1278,14 +1226,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick,
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfNames, nick,
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
@@ -1302,7 +1250,7 @@ func (hdlr *Handlers) handlePart(
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
"461", nick, []string{cmdPart},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
@@ -1324,7 +1272,7 @@ func (hdlr *Handlers) handlePart(
if err != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrNoSuchChannel, nick, []string{channel},
"403", nick, []string{channel},
"No such channel",
)
hdlr.broker.Notify(sessionID)
@@ -1340,7 +1288,7 @@ func (hdlr *Handlers) handlePart(
)
_ = hdlr.fanOutSilent(
request, irc.CmdPart, nick, channel, body, memberIDs,
request, cmdPart, nick, channel, body, memberIDs,
)
err = hdlr.params.Database.PartChannel(
@@ -1382,7 +1330,7 @@ func (hdlr *Handlers) handleNick(
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
"461", nick, []string{cmdNick},
"Not enough parameters",
)
@@ -1394,7 +1342,7 @@ func (hdlr *Handlers) handleNick(
if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrErroneusNickname, nick, []string{newNick},
"432", nick, []string{newNick},
"Erroneous nickname",
)
@@ -1430,7 +1378,7 @@ func (hdlr *Handlers) executeNickChange(
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNicknameInUse, nick, []string{newNick},
"433", nick, []string{newNick},
"Nickname is already in use",
)
@@ -1480,7 +1428,7 @@ func (hdlr *Handlers) broadcastNick(
}
dbID, _, _ := hdlr.params.Database.InsertMessage(
request.Context(), irc.CmdNick, oldNick, "",
request.Context(), cmdNick, oldNick, "",
nil, json.RawMessage(nickBody), nil,
)
@@ -1521,7 +1469,7 @@ func (hdlr *Handlers) handleTopic(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
"461", nick, []string{cmdTopic},
"Not enough parameters",
)
@@ -1532,7 +1480,7 @@ func (hdlr *Handlers) handleTopic(
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
"461", nick, []string{cmdTopic},
"Not enough parameters",
)
@@ -1550,7 +1498,7 @@ func (hdlr *Handlers) handleTopic(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"403", nick, []string{channel},
"No such channel",
)
@@ -1594,12 +1542,12 @@ func (hdlr *Handlers) executeTopic(
)
_ = hdlr.fanOutSilent(
request, irc.CmdTopic, nick, channel, body, memberIDs,
request, cmdTopic, nick, channel, body, memberIDs,
)
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplTopic, nick, []string{channel}, topic,
"332", nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
@@ -1611,7 +1559,8 @@ func (hdlr *Handlers) executeTopic(
}
// dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING).
// that produce server-side numerics (MOTD, LIST, WHO,
// WHOIS, PING).
func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter,
request *http.Request,
@@ -1619,20 +1568,31 @@ func (hdlr *Handlers) dispatchInfoCommand(
nick, command, target string,
bodyLines func() []string,
) {
_ = target
_ = bodyLines
okResp := map[string]string{"status": "ok"}
switch command {
case irc.CmdMotd:
case "MOTD":
hdlr.deliverMOTD(
request, clientID, sessionID, nick,
)
case irc.CmdPing:
case cmdList:
hdlr.handleListCmd(
request, clientID, sessionID, nick,
)
case cmdWho:
hdlr.handleWhoCmd(
request, clientID, sessionID, nick,
target,
)
case cmdWhois:
hdlr.handleWhoisCmd(
request, clientID, sessionID, nick,
target, bodyLines,
)
case "PING":
hdlr.respondJSON(writer, request,
map[string]string{
"command": irc.CmdPong,
"command": "PONG",
"from": hdlr.serverName(),
},
http.StatusOK)
@@ -1645,6 +1605,222 @@ func (hdlr *Handlers) dispatchInfoCommand(
)
}
// handleListCmd sends RPL_LIST (322) for each channel,
// then sends 323 to signal the end of the list.
func (hdlr *Handlers) handleListCmd(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.ListAllChannels(
ctx,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
return
}
for _, channel := range channels {
memberIDs, _ :=
hdlr.params.Database.GetChannelMemberIDs(
ctx, channel.ID,
)
count := strconv.Itoa(len(memberIDs))
topic := channel.Topic
if topic == "" {
topic = " "
}
hdlr.enqueueNumeric(
ctx, clientID, "322", nick,
[]string{channel.Name, count}, topic,
)
}
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
}
// handleWhoCmd sends RPL_WHOREPLY (352) for each member
// of the target channel, followed by RPL_ENDOFWHO (315).
func (hdlr *Handlers) handleWhoCmd(
request *http.Request,
clientID, sessionID int64,
nick, target string,
) {
ctx := request.Context()
if target == "" {
hdlr.enqueueNumeric(
ctx, clientID, "461", nick,
[]string{cmdWho}, "Not enough parameters",
)
hdlr.broker.Notify(sessionID)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel}, "End of /WHO list",
)
hdlr.broker.Notify(sessionID)
return
}
members, err := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if err == nil {
srvName := hdlr.serverName()
for _, mem := range members {
hdlr.enqueueNumeric(
ctx, clientID, "352", nick,
[]string{
channel, mem.Nick, "neoirc",
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
)
}
}
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel}, "End of /WHO list",
)
hdlr.broker.Notify(sessionID)
}
// handleWhoisCmd sends WHOIS reply numerics (311, 312,
// 319, 318) for the target nick.
func (hdlr *Handlers) handleWhoisCmd(
request *http.Request,
clientID, sessionID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
whoisNick := target
if whoisNick == "" {
lines := bodyLines()
if len(lines) > 0 {
whoisNick = strings.TrimSpace(lines[0])
}
}
if whoisNick == "" {
hdlr.enqueueNumeric(
ctx, clientID, "461", nick,
[]string{cmdWhois}, "Not enough parameters",
)
hdlr.broker.Notify(sessionID)
return
}
targetSID, err :=
hdlr.params.Database.GetSessionByNick(
ctx, whoisNick,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "401", nick,
[]string{whoisNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{whoisNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
return
}
hdlr.sendWhoisNumerics(
ctx, clientID, sessionID, nick,
whoisNick, targetSID,
)
}
// sendWhoisNumerics emits 311/312/319/318 for a
// resolved WHOIS target.
func (hdlr *Handlers) sendWhoisNumerics(
ctx context.Context,
clientID, sessionID int64,
nick, whoisNick string,
targetSID int64,
) {
srvName := hdlr.serverName()
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, "311", nick,
[]string{whoisNick, whoisNick, "neoirc", "*"},
whoisNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, "312", nick,
[]string{whoisNick, srvName},
srvName,
)
// 319 RPL_WHOISCHANNELS
channels, _ := hdlr.params.Database.GetSessionChannels(
ctx, targetSID,
)
if len(channels) > 0 {
names := make([]string, 0, len(channels))
for _, chanInfo := range channels {
names = append(names, chanInfo.Name)
}
hdlr.enqueueNumeric(
ctx, clientID, "319", nick,
[]string{whoisNick},
strings.Join(names, " "),
)
}
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{whoisNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handleQuit(
writer http.ResponseWriter,
request *http.Request,
@@ -1663,7 +1839,7 @@ func (hdlr *Handlers) handleQuit(
if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage(
request.Context(), irc.CmdQuit, nick, "",
request.Context(), cmdQuit, nick, "",
nil, body, nil,
)
}
@@ -1716,7 +1892,7 @@ func (hdlr *Handlers) handleMode(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
"461", nick, []string{"MODE"},
"Not enough parameters",
)
@@ -1728,7 +1904,7 @@ func (hdlr *Handlers) handleMode(
// User mode query — return empty modes.
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplUmodeIs, nick, nil, "+",
"221", nick, nil, "+",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -1760,7 +1936,7 @@ func (hdlr *Handlers) handleChannelMode(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"403", nick, []string{channel},
"No such channel",
)
@@ -1769,7 +1945,7 @@ func (hdlr *Handlers) handleChannelMode(
// 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
ctx, clientID, "324", nick,
[]string{channel, "+n"}, "",
)
@@ -1778,7 +1954,7 @@ func (hdlr *Handlers) handleChannelMode(
GetChannelCreatedAt(ctx, chID)
if timeErr == nil {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplCreationTime, nick,
ctx, clientID, "329", nick,
[]string{
channel,
strconv.FormatInt(
@@ -1805,7 +1981,7 @@ func (hdlr *Handlers) handleNames(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
"461", nick, []string{"NAMES"},
"Not enough parameters",
)
@@ -1825,7 +2001,7 @@ func (hdlr *Handlers) handleNames(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"403", nick, []string{channel},
"No such channel",
)
@@ -1843,14 +2019,14 @@ func (hdlr *Handlers) handleNames(
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNamReply, nick,
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfNames, nick,
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
@@ -1888,7 +2064,7 @@ func (hdlr *Handlers) handleList(
for _, chanInfo := range channels {
// 322 RPL_LIST
hdlr.enqueueNumeric(
ctx, clientID, irc.RplList, nick,
ctx, clientID, "322", nick,
[]string{
chanInfo.Name,
strconv.FormatInt(
@@ -1901,7 +2077,7 @@ func (hdlr *Handlers) handleList(
// 323 — end of channel list.
hdlr.enqueueNumeric(
ctx, clientID, irc.RplListEnd, nick, nil,
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
@@ -1932,7 +2108,7 @@ func (hdlr *Handlers) handleWhois(
if queryNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
"461", nick, []string{cmdWhois},
"Not enough parameters",
)
@@ -1959,12 +2135,12 @@ func (hdlr *Handlers) executeWhois(
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoSuchNick, nick,
ctx, clientID, "401", nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
ctx, clientID, "318", nick,
[]string{queryNick},
"End of /WHOIS list",
)
@@ -1978,14 +2154,14 @@ func (hdlr *Handlers) executeWhois(
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisUser, nick,
ctx, clientID, "311", nick,
[]string{queryNick, queryNick, srvName, "*"},
queryNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisServer, nick,
ctx, clientID, "312", nick,
[]string{queryNick, srvName},
"neoirc server",
)
@@ -1997,7 +2173,7 @@ func (hdlr *Handlers) executeWhois(
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
ctx, clientID, "318", nick,
[]string{queryNick},
"End of /WHOIS list",
)
@@ -2027,7 +2203,7 @@ func (hdlr *Handlers) deliverWhoisChannels(
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisChannels, nick,
ctx, clientID, "319", nick,
[]string{queryNick},
strings.Join(chanNames, " "),
)
@@ -2043,7 +2219,7 @@ func (hdlr *Handlers) handleWho(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
"461", nick, []string{cmdWho},
"Not enough parameters",
)
@@ -2064,7 +2240,7 @@ func (hdlr *Handlers) handleWho(
if err != nil {
// 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick,
ctx, clientID, "315", nick,
[]string{target},
"End of /WHO list",
)
@@ -2083,7 +2259,7 @@ func (hdlr *Handlers) handleWho(
for _, mem := range members {
// 352 RPL_WHOREPLY
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoReply, nick,
ctx, clientID, "352", nick,
[]string{
channel, mem.Nick, srvName,
srvName, mem.Nick, "H",
@@ -2095,7 +2271,7 @@ func (hdlr *Handlers) handleWho(
// 315 RPL_ENDOFWHO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick,
ctx, clientID, "315", nick,
[]string{channel},
"End of /WHO list",
)
@@ -2331,7 +2507,7 @@ func (hdlr *Handlers) cleanupUser(
if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, irc.CmdQuit, nick, "",
ctx, cmdQuit, nick, "",
nil, nil, nil,
)
}
@@ -2392,10 +2568,9 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
}
hdlr.respondJSON(writer, request, map[string]any{
"name": hdlr.params.Config.ServerName,
"version": hdlr.params.Globals.Version,
"motd": hdlr.params.Config.MOTD,
"users": users,
"name": hdlr.params.Config.ServerName,
"motd": hdlr.params.Config.MOTD,
"users": users,
}, http.StatusOK)
}
}

View File

@@ -12,7 +12,6 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -468,11 +467,8 @@ 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 {
if msg[commandKey] == numeric {
return true
}
}

View File

@@ -182,12 +182,6 @@ func (hdlr *Handlers) handleLogin(
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{
"id": sessionID,
"nick": payload.Nick,

View File

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

View File

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

View File

@@ -16,11 +16,6 @@ import (
const routeTimeout = 60 * time.Second
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
// The SPA loads external scripts and stylesheets from the same origin only;
// all API communication uses same-origin fetch (no WebSockets).
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
// SetupRoutes configures the HTTP routes and middleware.
func (srv *Server) SetupRoutes() {
srv.router = chi.NewRouter()
@@ -138,11 +133,6 @@ func (srv *Server) setupSPA() {
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspHeader,
)
readFS, ok := distFS.(fs.ReadFileFS)
if !ok {
fileServer.ServeHTTP(writer, request)

2
web/dist/app.js vendored Normal file

File diff suppressed because one or more lines are too long

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

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

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

@@ -0,0 +1,466 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #0a0e14;
--bg-panel: #0d1117;
--bg-input: #0d1117;
--bg-tab: #161b22;
--bg-tab-active: #0d1117;
--bg-topic: #0d1117;
--text: #c9d1d9;
--text-dim: #6e7681;
--text-bright: #e6edf3;
--accent: #58a6ff;
--accent-dim: #1f6feb;
--border: #21262d;
--system: #7d8590;
--action: #d2a8ff;
--warn: #d29922;
--error: #f85149;
--unread: #f0883e;
--nick-brackets: #6e7681;
--timestamp: #484f58;
--input-bg: #161b22;
--prompt: #3fb950;
--tab-indicator: #58a6ff;
--user-list-bg: #0d1117;
--user-list-header: #484f58;
}
html,
body,
#root {
height: 100%;
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
"Consolas", "Liberation Mono", "Courier New", monospace;
font-size: 13px;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* ============================================
Login Screen
============================================ */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: var(--bg);
}
.login-box {
text-align: center;
max-width: 360px;
width: 100%;
padding: 32px;
}
.login-box h1 {
color: var(--accent);
font-size: 1.8em;
margin-bottom: 16px;
font-weight: 400;
}
.login-box .motd {
color: var(--accent);
font-size: 11px;
margin-bottom: 20px;
text-align: left;
white-space: pre;
font-family: inherit;
line-height: 1.2;
overflow-x: auto;
}
.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);
color: var(--text-bright);
border-radius: 3px;
outline: none;
}
.login-box input:focus {
border-color: var(--accent-dim);
}
.login-box button {
padding: 8px 16px;
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);
}
.login-box .error {
color: var(--error);
font-size: 12px;
margin-top: 8px;
}
/* ============================================
IRC App Layout
============================================ */
.irc-app {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ============================================
Tab Bar
============================================ */
.tab-bar {
display: flex;
background: var(--bg-tab);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 32px;
align-items: stretch;
}
.tabs {
display: flex;
overflow-x: auto;
flex: 1;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
display: flex;
align-items: center;
padding: 0 12px;
cursor: pointer;
color: var(--text-dim);
white-space: nowrap;
user-select: none;
border-right: 1px solid var(--border);
font-size: 12px;
gap: 4px;
position: relative;
}
.tab:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.03);
}
.tab.active {
color: var(--text-bright);
background: var(--bg-tab-active);
border-bottom: 2px solid var(--tab-indicator);
margin-bottom: -1px;
}
.tab.has-unread .tab-label {
color: var(--unread);
font-weight: bold;
}
.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;
}
.status-nick {
color: var(--accent);
font-weight: bold;
}
.status-warn {
color: var(--warn);
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;
background: var(--bg-topic);
border-bottom: 1px solid var(--border);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
line-height: 1.5;
}
.topic-label {
color: var(--text-dim);
}
.topic-text {
color: var(--text);
}
/* ============================================
Main Content Area
============================================ */
.main-area {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ============================================
Messages Panel
============================================ */
.messages-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.messages-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
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 {
padding: 1px 0;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
}
.message .timestamp {
color: var(--timestamp);
font-size: 12px;
}
.message .nick {
font-weight: bold;
}
.message .content {
color: var(--text);
}
/* System messages (joins, parts, quits, etc.) */
.system-message {
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;
}
/* ============================================
Responsive
============================================ */
@media (max-width: 600px) {
.user-list {
display: none;
}
.tab {
padding: 0 8px;
font-size: 11px;
}
.input-prompt {
font-size: 12px;
}
}

View File

@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
.catch(() => {});
const saved = localStorage.getItem("neoirc_token");
if (saved) {
api("/state?initChannelState=1")
api("/state")
.then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token"));
}
@@ -333,24 +333,7 @@ function App() {
case "JOIN": {
const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) {
// 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);
}
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
break;
}
@@ -653,13 +636,9 @@ function App() {
setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`);
// Request MOTD on resumed sessions (new sessions get
// it automatically from the server during creation).
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",
@@ -668,11 +647,8 @@ function App() {
} catch (e) {
// MOTD is non-critical.
}
return;
}
// Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);