feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE
All checks were successful
check / check (push) Successful in 1m4s

Implement core IRC channel functionality:

1. Channel member flags: is_operator and is_voiced columns in
   channel_members table (proper boolean columns per sneak's
   instruction, not text string modes).

2. MODE +o/+v/-o/-v: Grant/revoke operator and voice status.
   Permission-checked (only +o can grant). NAMES replies show
   @nick for operators and +nick for voiced users.

3. MODE +m (moderated): Only +o and +v users can send PRIVMSG
   or NOTICE to moderated channels. Others get ERR_CANNOTSENDTOCHAN.

4. MODE +t (topic lock): Only +o can change topic when active.
   Default ON for new channels (standard IRC behavior). Others
   get ERR_CHANOPRIVSNEEDED.

5. KICK command: Operator-only, removes user from channel,
   broadcasts to all members including kicked user.

6. NOTICE differentiation: No RPL_AWAY auto-reply, skips hashcash
   validation on +H channels per RFC 2812.

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

Includes 22 new tests covering all requirements.

closes #85
This commit is contained in:
user
2026-03-22 03:23:18 -07:00
parent 08f57bc105
commit 3851909a96
6 changed files with 2073 additions and 82 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)
}