Compare commits
2 Commits
feat/add-c
...
bc8df117ab
| Author | SHA1 | Date | |
|---|---|---|---|
| bc8df117ab | |||
|
|
df82c6b302 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,7 +21,6 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
web/dist/
|
|
||||||
/neoircd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -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
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
@@ -15,9 +5,6 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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 fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
|
|
||||||
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web-builder /web/dist/ web/dist/
|
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -1032,12 +1032,6 @@ Return the current user's session state.
|
|||||||
|
|
||||||
**Request:** No body. Requires auth.
|
**Request:** No body. Requires auth.
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|-----------|--------|---------|-------------|
|
|
||||||
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1070,12 +1064,6 @@ curl -s http://localhost:8080/api/v1/state \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reconnect with channel state initialization:**
|
|
||||||
```bash
|
|
||||||
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
|
||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
|
|
||||||
Retrieve messages from the client's delivery queue. This is the primary
|
Retrieve messages from the client's delivery queue. This is the primary
|
||||||
@@ -1374,18 +1362,16 @@ Return server metadata. No authentication required.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My NeoIRC Server",
|
"name": "My NeoIRC Server",
|
||||||
"version": "0.1.0",
|
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-----------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `name` | string | Server display name |
|
| `name` | string | Server display name |
|
||||||
| `version` | string | Server version |
|
| `motd` | string | Message of the day |
|
||||||
| `motd` | string | Message of the day |
|
| `users` | integer | Number of currently active user sessions |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
|
||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
@@ -1852,16 +1838,26 @@ docker run -p 8080:8080 \
|
|||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a four-stage build:
|
The Dockerfile is a multi-stage build:
|
||||||
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
|
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
||||||
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
|
|
||||||
compilation, not included in final image)
|
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
|
### Binary
|
||||||
|
|
||||||
@@ -2310,14 +2306,10 @@ neoirc/
|
|||||||
│ └── http.go # HTTP timeouts
|
│ └── http.go # HTTP timeouts
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # go:embed directive for SPA
|
│ ├── embed.go # go:embed directive for SPA
|
||||||
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
||||||
│ ├── package.json # Node dependencies (preact, esbuild)
|
│ ├── index.html
|
||||||
│ ├── package-lock.json
|
│ ├── style.css
|
||||||
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
│ └── app.js
|
||||||
│ │ ├── app.jsx
|
|
||||||
│ │ ├── index.html
|
|
||||||
│ │ └── style.css
|
|
||||||
│ └── dist/ # Generated at Docker build time (not committed)
|
|
||||||
├── schema/ # JSON Schema definitions (planned)
|
├── schema/ # JSON Schema definitions (planned)
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-03-09
|
last_modified: 2026-02-22
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
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 use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -151,14 +144,8 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary.
|
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
Post-1.0.0: add new migration files.
|
||||||
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.
|
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -170,7 +168,7 @@ func (client *Client) PollMessages(
|
|||||||
func (client *Client) JoinChannel(channel string) error {
|
func (client *Client) JoinChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdJoin, To: channel,
|
Command: "JOIN", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
|
|||||||
func (client *Client) PartChannel(channel string) error {
|
func (client *Client) PartChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdPart, To: channel,
|
Command: "PART", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -87,7 +86,7 @@ func (a *App) handleInput(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -242,7 +241,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdNick,
|
Command: "NICK",
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,7 +376,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -435,7 +434,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +447,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
@@ -536,7 +535,7 @@ func (a *App) cmdMotd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
@@ -573,7 +572,7 @@ func (a *App) cmdWho(args string) {
|
|||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{ //nolint:exhaustruct
|
&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdWho, To: channel,
|
Command: "WHO", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -604,7 +603,7 @@ func (a *App) cmdWhois(args string) {
|
|||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{ //nolint:exhaustruct
|
&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdWhois, To: args,
|
Command: "WHOIS", To: args,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -654,7 +653,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(
|
_ = a.client.SendMessage(
|
||||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,19 +738,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case irc.CmdPrivmsg:
|
case "PRIVMSG":
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdJoin:
|
case "JOIN":
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case irc.CmdPart:
|
case "PART":
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case irc.CmdQuit:
|
case "QUIT":
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case irc.CmdNick:
|
case "NICK":
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdNotice:
|
case "NOTICE":
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case irc.CmdTopic:
|
case "TOPIC":
|
||||||
a.handleTopicEvent(msg, timestamp)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +33,6 @@ func generateToken() (string, error) {
|
|||||||
type IRCMessage struct {
|
type IRCMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Code int `json:"code,omitempty"`
|
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
@@ -45,15 +42,6 @@ type IRCMessage struct {
|
|||||||
DBID int64 `json:"-"`
|
DBID int64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNumericCode returns true if s is exactly a 3-digit
|
|
||||||
// IRC numeric reply code.
|
|
||||||
func isNumericCode(s string) bool {
|
|
||||||
return len(s) == 3 &&
|
|
||||||
s[0] >= '0' && s[0] <= '9' &&
|
|
||||||
s[1] >= '0' && s[1] <= '9' &&
|
|
||||||
s[2] >= '0' && s[2] <= '9'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelInfo is a lightweight channel representation.
|
// ChannelInfo is a lightweight channel representation.
|
||||||
type ChannelInfo struct {
|
type ChannelInfo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -729,15 +717,6 @@ func scanMessages(
|
|||||||
msg.DBID = qID
|
msg.DBID = qID
|
||||||
lastQID = qID
|
lastQID = qID
|
||||||
|
|
||||||
if isNumericCode(msg.Command) {
|
|
||||||
code, _ := strconv.Atoi(msg.Command)
|
|
||||||
msg.Code = code
|
|
||||||
|
|
||||||
if name := irc.Name(code); name != "" {
|
|
||||||
msg.Command = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs = append(msgs, msg)
|
msgs = append(msgs, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +27,7 @@ const (
|
|||||||
defaultMaxBodySize = 4096
|
defaultMaxBodySize = 4096
|
||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
maxHistLimit = 500
|
maxHistLimit = 500
|
||||||
|
cmdPrivmsg = "PRIVMSG"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hdlr *Handlers) maxBodySize() int64 {
|
func (hdlr *Handlers) maxBodySize() int64 {
|
||||||
@@ -232,20 +232,20 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
|
|
||||||
// 001 RPL_WELCOME
|
// 001 RPL_WELCOME
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWelcome, nick, nil,
|
ctx, clientID, "001", nick, nil,
|
||||||
"Welcome to the network, "+nick,
|
"Welcome to the network, "+nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 002 RPL_YOURHOST
|
// 002 RPL_YOURHOST
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplYourHost, nick, nil,
|
ctx, clientID, "002", nick, nil,
|
||||||
"Your host is "+srvName+
|
"Your host is "+srvName+
|
||||||
", running version "+version,
|
", running version "+version,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 003 RPL_CREATED
|
// 003 RPL_CREATED
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplCreated, nick, nil,
|
ctx, clientID, "003", nick, nil,
|
||||||
"This server was created "+
|
"This server was created "+
|
||||||
hdlr.params.Globals.StartTime.
|
hdlr.params.Globals.StartTime.
|
||||||
Format("2006-01-02"),
|
Format("2006-01-02"),
|
||||||
@@ -253,14 +253,14 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
|
|
||||||
// 004 RPL_MYINFO
|
// 004 RPL_MYINFO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplMyInfo, nick,
|
ctx, clientID, "004", nick,
|
||||||
[]string{srvName, version, "", "imnst"},
|
[]string{srvName, version, "", "imnst"},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 005 RPL_ISUPPORT
|
// 005 RPL_ISUPPORT
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplIsupport, nick,
|
ctx, clientID, "005", nick,
|
||||||
[]string{
|
[]string{
|
||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
@@ -305,7 +305,7 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
|
|
||||||
// 251 RPL_LUSERCLIENT
|
// 251 RPL_LUSERCLIENT
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserClient, nick, nil,
|
ctx, clientID, "251", nick, nil,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"There are %d users and 0 invisible on 1 servers",
|
"There are %d users and 0 invisible on 1 servers",
|
||||||
userCount,
|
userCount,
|
||||||
@@ -314,21 +314,21 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
|
|
||||||
// 252 RPL_LUSEROP
|
// 252 RPL_LUSEROP
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserOp, nick,
|
ctx, clientID, "252", nick,
|
||||||
[]string{"0"},
|
[]string{"0"},
|
||||||
"operator(s) online",
|
"operator(s) online",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 254 RPL_LUSERCHANNELS
|
// 254 RPL_LUSERCHANNELS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserChannels, nick,
|
ctx, clientID, "254", nick,
|
||||||
[]string{strconv.FormatInt(chanCount, 10)},
|
[]string{strconv.FormatInt(chanCount, 10)},
|
||||||
"channels formed",
|
"channels formed",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 255 RPL_LUSERME
|
// 255 RPL_LUSERME
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserMe, nick, nil,
|
ctx, clientID, "255", nick, nil,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"I have %d clients and 1 servers",
|
"I have %d clients and 1 servers",
|
||||||
userCount,
|
userCount,
|
||||||
@@ -366,19 +366,19 @@ func (hdlr *Handlers) deliverMOTD(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplMotdStart, nick, nil,
|
ctx, clientID, "375", nick, nil,
|
||||||
"- "+srvName+" 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, irc.RplMotd, nick, nil,
|
ctx, clientID, "372", nick, nil,
|
||||||
"- "+line,
|
"- "+line,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfMotd, nick, nil,
|
ctx, clientID, "376", nick, nil,
|
||||||
"End of /MOTD command.",
|
"End of /MOTD command.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,13 +397,10 @@ func (hdlr *Handlers) serverName() string {
|
|||||||
func (hdlr *Handlers) enqueueNumeric(
|
func (hdlr *Handlers) enqueueNumeric(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
clientID int64,
|
clientID int64,
|
||||||
code int,
|
command, nick string,
|
||||||
nick string,
|
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
command := fmt.Sprintf("%03d", code)
|
|
||||||
|
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -444,17 +441,13 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// HandleState returns the current session's info and
|
||||||
// channels. When called with ?initChannelState=1, it also
|
// channels.
|
||||||
// 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.
|
|
||||||
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
sessionID, clientID, nick, ok :=
|
sessionID, _, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -476,12 +469,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.URL.Query().Get("initChannelState") == "1" {
|
|
||||||
hdlr.initChannelState(
|
|
||||||
request, clientID, sessionID, nick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": nick,
|
||||||
@@ -490,52 +477,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.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -809,38 +750,38 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case cmdPrivmsg, "NOTICE":
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, bodyLines,
|
command, target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdJoin:
|
case "JOIN":
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case irc.CmdPart:
|
case "PART":
|
||||||
hdlr.handlePart(
|
hdlr.handlePart(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target, body,
|
sessionID, clientID, nick, target, body,
|
||||||
)
|
)
|
||||||
case irc.CmdNick:
|
case "NICK":
|
||||||
hdlr.handleNick(
|
hdlr.handleNick(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdTopic:
|
case "TOPIC":
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, body, bodyLines,
|
target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdQuit:
|
case "QUIT":
|
||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case irc.CmdMotd, irc.CmdPing:
|
case "MOTD", "LIST", "WHO", "WHOIS", "PING":
|
||||||
hdlr.dispatchInfoCommand(
|
hdlr.dispatchInfoCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -863,34 +804,34 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdMode:
|
case "MODE":
|
||||||
hdlr.handleMode(
|
hdlr.handleMode(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, bodyLines,
|
target, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdNames:
|
case "NAMES":
|
||||||
hdlr.handleNames(
|
hdlr.handleNames(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case irc.CmdList:
|
case "LIST":
|
||||||
hdlr.handleList(
|
hdlr.handleList(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
)
|
)
|
||||||
case irc.CmdWhois:
|
case "WHOIS":
|
||||||
hdlr.handleWhois(
|
hdlr.handleWhois(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, bodyLines,
|
target, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdWho:
|
case "WHO":
|
||||||
hdlr.handleWho(
|
hdlr.handleWho(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case irc.CmdLusers:
|
case "LUSERS":
|
||||||
hdlr.handleLusers(
|
hdlr.handleLusers(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -898,7 +839,7 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrUnknownCommand, nick, []string{command},
|
"421", nick, []string{command},
|
||||||
"Unknown command",
|
"Unknown command",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -919,7 +860,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
"461", nick, []string{command},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -934,7 +875,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
"461", nick, []string{command},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -968,14 +909,13 @@ func (hdlr *Handlers) respondIRCError(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
code int,
|
numeric, nick string,
|
||||||
nick string,
|
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
code, nick, params, text,
|
numeric, nick, params, text,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -996,7 +936,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{target},
|
"403", nick, []string{target},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1022,7 +962,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNotOnChannel, nick, []string{target},
|
"442", nick, []string{target},
|
||||||
"You're not on that channel",
|
"You're not on that channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1089,7 +1029,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoSuchNick, nick, []string{target},
|
"401", nick, []string{target},
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1133,7 +1073,7 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
|
"461", nick, []string{"JOIN"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1148,7 +1088,7 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
if !validChannelRe.MatchString(channel) {
|
if !validChannelRe.MatchString(channel) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1204,7 +1144,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
request, "JOIN", nick, channel, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.deliverJoinNumerics(
|
hdlr.deliverJoinNumerics(
|
||||||
@@ -1255,12 +1195,12 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
|
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplTopic, nick,
|
ctx, clientID, "332", nick,
|
||||||
[]string{channel}, topic,
|
[]string{channel}, topic,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNoTopic, nick,
|
ctx, clientID, "331", nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]string{channel}, "No topic is set",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1278,14 +1218,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNamReply, nick,
|
ctx, clientID, "353", nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(nicks, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, "366", nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1302,7 +1242,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
|
"461", nick, []string{"PART"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1324,7 +1264,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1340,7 +1280,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
request, "PART", nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = hdlr.params.Database.PartChannel(
|
err = hdlr.params.Database.PartChannel(
|
||||||
@@ -1382,7 +1322,7 @@ func (hdlr *Handlers) handleNick(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
|
"461", nick, []string{"NICK"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1394,7 +1334,7 @@ func (hdlr *Handlers) handleNick(
|
|||||||
if !validNickRe.MatchString(newNick) {
|
if !validNickRe.MatchString(newNick) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrErroneusNickname, nick, []string{newNick},
|
"432", nick, []string{newNick},
|
||||||
"Erroneous nickname",
|
"Erroneous nickname",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1430,7 +1370,7 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
"433", nick, []string{newNick},
|
||||||
"Nickname is already in use",
|
"Nickname is already in use",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1480,7 +1420,7 @@ func (hdlr *Handlers) broadcastNick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), irc.CmdNick, oldNick, "",
|
request.Context(), "NICK", oldNick, "",
|
||||||
nil, json.RawMessage(nickBody), nil,
|
nil, json.RawMessage(nickBody), nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1521,7 +1461,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
"461", nick, []string{"TOPIC"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1532,7 +1472,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
"461", nick, []string{"TOPIC"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1550,7 +1490,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1594,12 +1534,12 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
request, "TOPIC", nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplTopic, nick, []string{channel}, topic,
|
"332", nick, []string{channel}, topic,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
@@ -1611,7 +1551,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dispatchInfoCommand handles informational IRC commands
|
// 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(
|
func (hdlr *Handlers) dispatchInfoCommand(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -1619,20 +1560,31 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
|||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
_ = target
|
|
||||||
_ = bodyLines
|
|
||||||
|
|
||||||
okResp := map[string]string{"status": "ok"}
|
okResp := map[string]string{"status": "ok"}
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdMotd:
|
case "MOTD":
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, nick,
|
request, clientID, sessionID, nick,
|
||||||
)
|
)
|
||||||
case irc.CmdPing:
|
case "LIST":
|
||||||
|
hdlr.handleListCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
case "WHO":
|
||||||
|
hdlr.handleWhoCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
case "WHOIS":
|
||||||
|
hdlr.handleWhoisCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
target, bodyLines,
|
||||||
|
)
|
||||||
|
case "PING":
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"command": irc.CmdPong,
|
"command": "PONG",
|
||||||
"from": hdlr.serverName(),
|
"from": hdlr.serverName(),
|
||||||
},
|
},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1645,6 +1597,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{"WHO"}, "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{"WHOIS"}, "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(
|
func (hdlr *Handlers) handleQuit(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -1663,7 +1831,7 @@ func (hdlr *Handlers) handleQuit(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), irc.CmdQuit, nick, "",
|
request.Context(), "QUIT", nick, "",
|
||||||
nil, body, nil,
|
nil, body, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1716,7 +1884,7 @@ func (hdlr *Handlers) handleMode(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
"461", nick, []string{"MODE"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1728,7 +1896,7 @@ func (hdlr *Handlers) handleMode(
|
|||||||
// User mode query — return empty modes.
|
// User mode query — return empty modes.
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplUmodeIs, nick, nil, "+",
|
"221", nick, nil, "+",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -1760,7 +1928,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1769,7 +1937,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
|
|
||||||
// 324 RPL_CHANNELMODEIS
|
// 324 RPL_CHANNELMODEIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
ctx, clientID, "324", nick,
|
||||||
[]string{channel, "+n"}, "",
|
[]string{channel, "+n"}, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1778,7 +1946,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
GetChannelCreatedAt(ctx, chID)
|
GetChannelCreatedAt(ctx, chID)
|
||||||
if timeErr == nil {
|
if timeErr == nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplCreationTime, nick,
|
ctx, clientID, "329", nick,
|
||||||
[]string{
|
[]string{
|
||||||
channel,
|
channel,
|
||||||
strconv.FormatInt(
|
strconv.FormatInt(
|
||||||
@@ -1805,7 +1973,7 @@ func (hdlr *Handlers) handleNames(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
|
"461", nick, []string{"NAMES"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1825,7 +1993,7 @@ func (hdlr *Handlers) handleNames(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1843,14 +2011,14 @@ func (hdlr *Handlers) handleNames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNamReply, nick,
|
ctx, clientID, "353", nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(nicks, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, "366", nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1888,7 +2056,7 @@ func (hdlr *Handlers) handleList(
|
|||||||
for _, chanInfo := range channels {
|
for _, chanInfo := range channels {
|
||||||
// 322 RPL_LIST
|
// 322 RPL_LIST
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplList, nick,
|
ctx, clientID, "322", nick,
|
||||||
[]string{
|
[]string{
|
||||||
chanInfo.Name,
|
chanInfo.Name,
|
||||||
strconv.FormatInt(
|
strconv.FormatInt(
|
||||||
@@ -1901,7 +2069,7 @@ func (hdlr *Handlers) handleList(
|
|||||||
|
|
||||||
// 323 — end of channel list.
|
// 323 — end of channel list.
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplListEnd, nick, nil,
|
ctx, clientID, "323", nick, nil,
|
||||||
"End of /LIST",
|
"End of /LIST",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1932,7 +2100,7 @@ func (hdlr *Handlers) handleWhois(
|
|||||||
if queryNick == "" {
|
if queryNick == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
|
"461", nick, []string{"WHOIS"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1959,12 +2127,12 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.ErrNoSuchNick, nick,
|
ctx, clientID, "401", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
ctx, clientID, "318", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"End of /WHOIS list",
|
||||||
)
|
)
|
||||||
@@ -1978,14 +2146,14 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
|
|
||||||
// 311 RPL_WHOISUSER
|
// 311 RPL_WHOISUSER
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisUser, nick,
|
ctx, clientID, "311", nick,
|
||||||
[]string{queryNick, queryNick, srvName, "*"},
|
[]string{queryNick, queryNick, srvName, "*"},
|
||||||
queryNick,
|
queryNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 312 RPL_WHOISSERVER
|
// 312 RPL_WHOISSERVER
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisServer, nick,
|
ctx, clientID, "312", nick,
|
||||||
[]string{queryNick, srvName},
|
[]string{queryNick, srvName},
|
||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
@@ -1997,7 +2165,7 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
|
|
||||||
// 318 RPL_ENDOFWHOIS
|
// 318 RPL_ENDOFWHOIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
ctx, clientID, "318", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"End of /WHOIS list",
|
||||||
)
|
)
|
||||||
@@ -2027,7 +2195,7 @@ func (hdlr *Handlers) deliverWhoisChannels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisChannels, nick,
|
ctx, clientID, "319", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
strings.Join(chanNames, " "),
|
strings.Join(chanNames, " "),
|
||||||
)
|
)
|
||||||
@@ -2043,7 +2211,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
|
"461", nick, []string{"WHO"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2064,7 +2232,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// 315 RPL_ENDOFWHO (empty result)
|
// 315 RPL_ENDOFWHO (empty result)
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWho, nick,
|
ctx, clientID, "315", nick,
|
||||||
[]string{target},
|
[]string{target},
|
||||||
"End of /WHO list",
|
"End of /WHO list",
|
||||||
)
|
)
|
||||||
@@ -2083,7 +2251,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
for _, mem := range members {
|
for _, mem := range members {
|
||||||
// 352 RPL_WHOREPLY
|
// 352 RPL_WHOREPLY
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoReply, nick,
|
ctx, clientID, "352", nick,
|
||||||
[]string{
|
[]string{
|
||||||
channel, mem.Nick, srvName,
|
channel, mem.Nick, srvName,
|
||||||
srvName, mem.Nick, "H",
|
srvName, mem.Nick, "H",
|
||||||
@@ -2095,7 +2263,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
|
|
||||||
// 315 RPL_ENDOFWHO
|
// 315 RPL_ENDOFWHO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWho, nick,
|
ctx, clientID, "315", nick,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"End of /WHO list",
|
"End of /WHO list",
|
||||||
)
|
)
|
||||||
@@ -2331,7 +2499,7 @@ func (hdlr *Handlers) cleanupUser(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
ctx, irc.CmdQuit, nick, "",
|
ctx, "QUIT", nick, "",
|
||||||
nil, nil, nil,
|
nil, nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2392,10 +2560,9 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"name": hdlr.params.Config.ServerName,
|
"name": hdlr.params.Config.ServerName,
|
||||||
"version": hdlr.params.Globals.Version,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"users": users,
|
||||||
"users": users,
|
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -468,11 +467,8 @@ func findNumeric(
|
|||||||
msgs []map[string]any,
|
msgs []map[string]any,
|
||||||
numeric string,
|
numeric string,
|
||||||
) bool {
|
) bool {
|
||||||
want, _ := strconv.Atoi(numeric)
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
code, ok := msg["code"].(float64)
|
if msg[commandKey] == numeric {
|
||||||
if ok && int(code) == want {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,12 +182,6 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize channel state so the new client knows
|
|
||||||
// which channels the session already belongs to.
|
|
||||||
hdlr.initChannelState(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package irc
|
|
||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
|
||||||
const (
|
|
||||||
CmdJoin = "JOIN"
|
|
||||||
CmdList = "LIST"
|
|
||||||
CmdLusers = "LUSERS"
|
|
||||||
CmdMode = "MODE"
|
|
||||||
CmdMotd = "MOTD"
|
|
||||||
CmdNames = "NAMES"
|
|
||||||
CmdNick = "NICK"
|
|
||||||
CmdNotice = "NOTICE"
|
|
||||||
CmdPart = "PART"
|
|
||||||
CmdPing = "PING"
|
|
||||||
CmdPong = "PONG"
|
|
||||||
CmdPrivmsg = "PRIVMSG"
|
|
||||||
CmdQuit = "QUIT"
|
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome = 1
|
|
||||||
RplYourHost = 2
|
|
||||||
RplCreated = 3
|
|
||||||
RplMyInfo = 4
|
|
||||||
RplIsupport = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
RplUmodeIs = 221
|
|
||||||
RplLuserClient = 251
|
|
||||||
RplLuserOp = 252
|
|
||||||
RplLuserUnknown = 253
|
|
||||||
RplLuserChannels = 254
|
|
||||||
RplLuserMe = 255
|
|
||||||
RplAway = 301
|
|
||||||
RplUserHost = 302
|
|
||||||
RplIson = 303
|
|
||||||
RplUnaway = 305
|
|
||||||
RplNowAway = 306
|
|
||||||
RplWhoisUser = 311
|
|
||||||
RplWhoisServer = 312
|
|
||||||
RplWhoisOperator = 313
|
|
||||||
RplEndOfWho = 315
|
|
||||||
RplWhoisIdle = 317
|
|
||||||
RplEndOfWhois = 318
|
|
||||||
RplWhoisChannels = 319
|
|
||||||
RplList = 322
|
|
||||||
RplListEnd = 323
|
|
||||||
RplChannelModeIs = 324
|
|
||||||
RplCreationTime = 329
|
|
||||||
RplNoTopic = 331
|
|
||||||
RplTopic = 332
|
|
||||||
RplTopicWhoTime = 333
|
|
||||||
RplInviting = 341
|
|
||||||
RplWhoReply = 352
|
|
||||||
RplNamReply = 353
|
|
||||||
RplEndOfNames = 366
|
|
||||||
RplBanList = 367
|
|
||||||
RplEndOfBanList = 368
|
|
||||||
RplMotd = 372
|
|
||||||
RplMotdStart = 375
|
|
||||||
RplEndOfMotd = 376
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick = 401
|
|
||||||
ErrNoSuchServer = 402
|
|
||||||
ErrNoSuchChannel = 403
|
|
||||||
ErrCannotSendToChan = 404
|
|
||||||
ErrTooManyChannels = 405
|
|
||||||
ErrNoRecipient = 411
|
|
||||||
ErrNoTextToSend = 412
|
|
||||||
ErrUnknownCommand = 421
|
|
||||||
ErrNoNicknameGiven = 431
|
|
||||||
ErrErroneusNickname = 432
|
|
||||||
ErrNicknameInUse = 433
|
|
||||||
ErrUserNotInChannel = 441
|
|
||||||
ErrNotOnChannel = 442
|
|
||||||
ErrNotRegistered = 451
|
|
||||||
ErrNeedMoreParams = 461
|
|
||||||
ErrAlreadyRegistered = 462
|
|
||||||
ErrChannelIsFull = 471
|
|
||||||
ErrInviteOnlyChan = 473
|
|
||||||
ErrBannedFromChan = 474
|
|
||||||
ErrBadChannelKey = 475
|
|
||||||
ErrChanOpPrivsNeeded = 482
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[int]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplIsupport: "RPL_ISUPPORT",
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
func Name(code int) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -16,11 +16,6 @@ import (
|
|||||||
|
|
||||||
const routeTimeout = 60 * time.Second
|
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.
|
// SetupRoutes configures the HTTP routes and middleware.
|
||||||
func (srv *Server) SetupRoutes() {
|
func (srv *Server) SetupRoutes() {
|
||||||
srv.router = chi.NewRouter()
|
srv.router = chi.NewRouter()
|
||||||
@@ -138,11 +133,6 @@ func (srv *Server) setupSPA() {
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
writer.Header().Set(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
cspHeader,
|
|
||||||
)
|
|
||||||
|
|
||||||
readFS, ok := distFS.(fs.ReadFileFS)
|
readFS, ok := distFS.(fs.ReadFileFS)
|
||||||
if !ok {
|
if !ok {
|
||||||
fileServer.ServeHTTP(writer, request)
|
fileServer.ServeHTTP(writer, request)
|
||||||
|
|||||||
2
web/dist/app.js
vendored
Normal file
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
13
web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>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
466
web/dist/style.css
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state?initChannelState=1")
|
api("/state")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -333,24 +333,7 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
if (msg.to && msg.to.startsWith("#")) {
|
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
||||||
// Create a tab when the current user joins a channel
|
|
||||||
// (including JOINs from initChannelState on reconnect).
|
|
||||||
if (msg.from === nickRef.current) {
|
|
||||||
setTabs((prev) => {
|
|
||||||
if (
|
|
||||||
prev.find(
|
|
||||||
(t) => t.type === "channel" && t.name === msg.to,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return prev;
|
|
||||||
|
|
||||||
return [...prev, { type: "channel", name: msg.to }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshMembers(msg.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -653,13 +636,9 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
|
// Request MOTD on resumed sessions (new sessions get
|
||||||
|
// it automatically from the server during creation).
|
||||||
if (isResumed) {
|
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 {
|
try {
|
||||||
await api("/messages", {
|
await api("/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -668,11 +647,8 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// MOTD is non-critical.
|
// MOTD is non-critical.
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh session — join any previously saved channels.
|
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user