feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE (#88)
Some checks failed
check / check (push) Failing after 1m37s
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user