feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE (#88)
Some checks failed
check / check (push) Failing after 1m37s

## Summary

Implement the core IRC channel functionality that users will immediately notice is missing. This is the foundation for all other mode enforcement.

closes #85

## Changes

### 1. Channel Member Flags Schema
- Added `is_operator INTEGER NOT NULL DEFAULT 0` and `is_voiced INTEGER NOT NULL DEFAULT 0` columns to `channel_members` table
- Proper boolean columns per sneak's instruction (not text string modes)

### 2. MODE +o/+v/-o/-v (User Channel Modes)
- `MODE #channel +o nick` / `-o` / `+v` / `-v` with permission checks
- Only existing `+o` users can grant/revoke modes
- NAMES reply shows `@nick` for operators, `+nick` for voiced users
- ISUPPORT advertises `PREFIX=(ov)@+`

### 3. MODE +m (Moderated)
- Added `is_moderated INTEGER NOT NULL DEFAULT 0` to `channels` table
- When +m is active, only +o and +v users can send PRIVMSG/NOTICE
- Others receive `ERR_CANNOTSENDTOCHAN` (404)

### 4. MODE +t (Topic Lock)
- Added `is_topic_locked INTEGER NOT NULL DEFAULT 1` to `channels` table
- Default ON for new channels (standard IRC behavior)
- When +t is active, only +o users can change the topic
- Others receive `ERR_CHANOPRIVSNEEDED` (482)

### 5. KICK Command
- `KICK #channel nick [:reason]` — operator-only
- Broadcasts KICK to all channel members including kicked user
- Removes kicked user from channel
- Proper error handling (482, 441, 403)

### 6. NOTICE Differentiation
- NOTICE does NOT trigger RPL_AWAY auto-replies
- NOTICE skips hashcash validation on +H channels
- Follows RFC 2812 (no auto-replies)

### Additional Improvements
- Channel creator auto-gets +o on first JOIN
- ISUPPORT: `PREFIX=(ov)@+`, `CHANMODES=,,H,mnst`
- MODE query shows accurate mode string (+nt, +m, +H)
- Fixed pre-existing unparam lint issue in fanOutSilent

## Testing

22 new tests covering:
- Operator auto-grant on channel creation
- Second joiner does NOT get +o
- MODE +o/+v/-o/-v with permission checks
- Non-operator cannot grant modes (482)
- +m enforcement (blocks non-voiced, allows op and voiced)
- +t enforcement (blocks non-op topic change, allows op)
- +t disable allows anyone to change topic
- KICK by operator (success + removal verification)
- KICK by non-operator (482)
- KICK target not in channel (441)
- KICK broadcast to all members
- KICK default reason
- NOTICE no AWAY reply
- PRIVMSG DOES trigger AWAY reply
- NOTICE skips hashcash on +H
- +m blocks NOTICE too
- Non-op cannot set +m
- ISUPPORT PREFIX=(ov)@+
- MODE query shows +m

## CI

`docker build .` passes — lint (0 issues), fmt-check, and all tests green.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #88
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #88.
This commit is contained in:
2026-03-25 02:08:28 +01:00
committed by Jeffrey Paul
parent 08f57bc105
commit 4b445e6383
6 changed files with 2074 additions and 83 deletions

View File

@@ -824,8 +824,9 @@ Set or change a channel's topic.
- Updates the channel's topic in the database.
- The TOPIC event is broadcast to all channel members.
- If the channel doesn't exist, the server returns an error.
- If the channel has mode `+t` (topic lock), only operators can change the
topic (not yet enforced).
- If the channel has mode `+t` (topic lock, default: ON for new channels),
only operators (`+o`) can change the topic. Non-operators receive
`ERR_CHANOPRIVSNEEDED` (482).
**Response:** `200 OK`
```json
@@ -1020,16 +1021,23 @@ WHOIS responses (e.g., target user's current client IP and hostname).
**IRC reference:** RFC 1459 §4.1.5
#### KICK — Kick User (Planned)
#### KICK — Kick User
Remove a user from a channel.
Remove a user from a channel. Only channel operators (`+o`) can use this
command. The kicked user and all channel members receive the KICK message.
**C2S:**
```json
{"command": "KICK", "to": "#general", "params": ["bob"], "body": ["misbehaving"]}
{"command": "KICK", "to": "#general", "body": ["bob", "misbehaving"]}
```
**Status:** Not yet implemented.
The first element of `body` is the target nick, the second (optional) is the
reason. If no reason is provided, the kicker's nick is used as the default.
**Errors:**
- `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator
- `441` (ERR_USERNOTINCHANNEL) — target is not in the channel
- `403` (ERR_NOSUCHCHANNEL) — channel does not exist
**IRC reference:** RFC 1459 §4.2.8
@@ -1071,8 +1079,8 @@ the server to the client (never C2S) and use 3-digit string codes in the
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` |
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","mnst"]}` |
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=,,H,mnst","NETWORK=neoirc"],"body":["are supported by this server"]}` |
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
@@ -1118,25 +1126,34 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified:
| Mode | Name | Meaning |
|------|----------------|---------|
| `+i` | Invite-only | Only invited users can join |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
| `+s` | Secret | Channel hidden from LIST response |
| `+t` | Topic lock | Only operators can change the topic |
| `+n` | No external | Only channel members can send messages to the channel |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
| Mode | Name | Meaning | Status |
|------|----------------|---------|--------|
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
| `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** |
| `+i` | Invite-only | Only invited users can join | Not yet enforced |
| `+s` | Secret | Channel hidden from LIST response | Not yet enforced |
**User channel modes (set per-user per-channel):**
| Mode | Meaning | Display prefix |
|------|---------|----------------|
| `+o` | Operator | `@` in NAMES reply |
| `+v` | Voice | `+` in NAMES reply |
| Mode | Meaning | Display prefix | Status |
|------|---------|----------------|--------|
| `+o` | Operator | `@` in NAMES reply | **Enforced** |
| `+v` | Voice | `+` in NAMES reply | **Enforced** |
**Status:** Channel modes are defined but not yet enforced. The `modes` column
exists in the channels table but the server does not check modes on actions.
Exception: `+H` (hashcash) is fully enforced — see below.
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
automatically receives `+o` operator status.
**KICK command:** Channel operators can remove users with `KICK #channel nick
[:reason]`. The kicked user and all channel members receive the KICK message.
**NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including
RPL_AWAY), and skips hashcash validation on +H channels (servers and services
use NOTICE).
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
`CHANMODES=,,H,mnst` in RPL_ISUPPORT (005).
### Per-Channel Hashcash (Anti-Spam)
@@ -2677,17 +2694,19 @@ guess is borne by the server (bcrypt), not the client.
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
- [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
- [x] **MODE command** — query channel and user modes (set not yet implemented)
- [x] **NAMES command** — query channel member list
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
- [ ] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
- [x] **KICK command** — operator-only channel kick with broadcast
- [x] **MODE command** — query and set channel/user modes
- [x] **NAMES command** — query channel member list with @/+ prefixes
- [x] **LIST command** — list all channels with member counts
- [x] **WHOIS command** — query user information and channel membership
- [x] **WHO command** — query channel user list
- [x] **LUSERS command** — query server statistics
- [x] **Connection registration numerics** — 001-005 sent on session creation
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS
- [ ] **KICK command** — remove users from channels
- [x] **KICK command** — remove users from channels (operator-only)
- [x] **Numeric replies** — send IRC numeric codes via the message queue
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE,
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)