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

@@ -72,11 +72,13 @@ type ChannelInfo struct {
// MemberInfo represents a channel member.
type MemberInfo struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Username string `json:"username"`
Hostname string `json:"hostname"`
LastSeen time.Time `json:"lastSeen"`
ID int64 `json:"id"`
Nick string `json:"nick"`
Username string `json:"username"`
Hostname string `json:"hostname"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
LastSeen time.Time `json:"lastSeen"`
}
// Hostmask returns the IRC hostmask in
@@ -436,6 +438,237 @@ func (database *Database) JoinChannel(
return nil
}
// JoinChannelAsOperator adds a session to a channel with
// operator status. Used when a user creates a new channel.
func (database *Database) JoinChannelAsOperator(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, is_operator, joined_at)
VALUES (?, ?, 1, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf(
"join channel as operator: %w", err,
)
}
return nil
}
// CountChannelMembers returns the number of members in
// a channel.
func (database *Database) CountChannelMembers(
ctx context.Context,
channelID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_members
WHERE channel_id = ?`,
channelID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"count channel members: %w", err,
)
}
return count, nil
}
// IsChannelOperator checks if a session has operator
// status in a channel.
func (database *Database) IsChannelOperator(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var isOp int
err := database.conn.QueryRowContext(ctx,
`SELECT is_operator FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&isOp)
if err != nil {
return false, fmt.Errorf(
"check channel operator: %w", err,
)
}
return isOp != 0, nil
}
// IsChannelVoiced checks if a session has voice status
// in a channel.
func (database *Database) IsChannelVoiced(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var isVoiced int
err := database.conn.QueryRowContext(ctx,
`SELECT is_voiced FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&isVoiced)
if err != nil {
return false, fmt.Errorf(
"check channel voiced: %w", err,
)
}
return isVoiced != 0, nil
}
// SetChannelMemberOperator sets or clears operator status
// for a session in a channel.
func (database *Database) SetChannelMemberOperator(
ctx context.Context,
channelID, sessionID int64,
isOp bool,
) error {
val := 0
if isOp {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channel_members
SET is_operator = ?
WHERE channel_id = ? AND session_id = ?`,
val, channelID, sessionID)
if err != nil {
return fmt.Errorf(
"set channel member operator: %w", err,
)
}
return nil
}
// SetChannelMemberVoiced sets or clears voice status
// for a session in a channel.
func (database *Database) SetChannelMemberVoiced(
ctx context.Context,
channelID, sessionID int64,
isVoiced bool,
) error {
val := 0
if isVoiced {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channel_members
SET is_voiced = ?
WHERE channel_id = ? AND session_id = ?`,
val, channelID, sessionID)
if err != nil {
return fmt.Errorf(
"set channel member voiced: %w", err,
)
}
return nil
}
// IsChannelModerated returns whether a channel has +m set.
func (database *Database) IsChannelModerated(
ctx context.Context,
channelID int64,
) (bool, error) {
var isMod int
err := database.conn.QueryRowContext(ctx,
`SELECT is_moderated FROM channels
WHERE id = ?`,
channelID,
).Scan(&isMod)
if err != nil {
return false, fmt.Errorf(
"check channel moderated: %w", err,
)
}
return isMod != 0, nil
}
// SetChannelModerated sets or clears +m on a channel.
func (database *Database) SetChannelModerated(
ctx context.Context,
channelID int64,
moderated bool,
) error {
val := 0
if moderated {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_moderated = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel moderated: %w", err,
)
}
return nil
}
// IsChannelTopicLocked returns whether a channel has
// +t set.
func (database *Database) IsChannelTopicLocked(
ctx context.Context,
channelID int64,
) (bool, error) {
var isLocked int
err := database.conn.QueryRowContext(ctx,
`SELECT is_topic_locked FROM channels
WHERE id = ?`,
channelID,
).Scan(&isLocked)
if err != nil {
return false, fmt.Errorf(
"check channel topic locked: %w", err,
)
}
return isLocked != 0, nil
}
// SetChannelTopicLocked sets or clears +t on a channel.
func (database *Database) SetChannelTopicLocked(
ctx context.Context,
channelID int64,
locked bool,
) error {
val := 0
if locked {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_topic_locked = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel topic locked: %w", err,
)
}
return nil
}
// PartChannel removes a session from a channel.
func (database *Database) PartChannel(
ctx context.Context,
@@ -547,7 +780,8 @@ func (database *Database) ChannelMembers(
) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick, s.username,
s.hostname, s.last_seen
s.hostname, cm.is_operator, cm.is_voiced,
s.last_seen
FROM sessions s
INNER JOIN channel_members cm
ON cm.session_id = s.id
@@ -564,11 +798,16 @@ func (database *Database) ChannelMembers(
var members []MemberInfo
for rows.Next() {
var member MemberInfo
var (
member MemberInfo
isOp int
isV int
)
err = rows.Scan(
&member.ID, &member.Nick,
&member.Username, &member.Hostname,
&isOp, &isV,
&member.LastSeen,
)
if err != nil {
@@ -577,6 +816,9 @@ func (database *Database) ChannelMembers(
)
}
member.IsOperator = isOp != 0
member.IsVoiced = isV != 0
members = append(members, member)
}

View File

@@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS channels (
topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
is_moderated INTEGER NOT NULL DEFAULT 0,
is_topic_locked INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -49,6 +51,8 @@ CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
is_operator INTEGER NOT NULL DEFAULT 0,
is_voiced INTEGER NOT NULL DEFAULT 0,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, session_id)
);