feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE
All checks were successful
check / check (push) Successful in 1m4s
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:
@@ -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