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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -218,11 +218,10 @@ func (hdlr *Handlers) fanOutSilent(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) error {
|
||||
_, err := hdlr.fanOut(
|
||||
request, command, from, target, body, meta, sessionIDs,
|
||||
request, command, from, target, body, nil, sessionIDs,
|
||||
)
|
||||
|
||||
return err
|
||||
@@ -448,7 +447,7 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
// 004 RPL_MYINFO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplMyInfo, nick,
|
||||
[]string{srvName, version, "", "imnst"},
|
||||
[]string{srvName, version, "", "mnst"},
|
||||
"",
|
||||
)
|
||||
|
||||
@@ -458,7 +457,8 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
[]string{
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
"CHANMODES=,,H," + "imnst",
|
||||
"PREFIX=(ov)@+",
|
||||
"CHANMODES=,,H,mnst",
|
||||
"NETWORK=neoirc",
|
||||
"CASEMAPPING=ascii",
|
||||
},
|
||||
@@ -1051,6 +1051,12 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
sessionID, clientID, nick,
|
||||
target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdKick:
|
||||
hdlr.handleKick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdQuit:
|
||||
hdlr.handleQuit(
|
||||
writer, request, sessionID, nick, body,
|
||||
@@ -1214,8 +1220,10 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
request.Context(), target,
|
||||
ctx, target,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
@@ -1228,7 +1236,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
}
|
||||
|
||||
isMember, err := hdlr.params.Database.IsChannelMember(
|
||||
request.Context(), chID, sessionID,
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
@@ -1253,20 +1261,73 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
return
|
||||
}
|
||||
|
||||
hashcashErr := hdlr.validateChannelHashcash(
|
||||
request, clientID, sessionID,
|
||||
writer, nick, target, body, meta, chID,
|
||||
)
|
||||
if hashcashErr != nil {
|
||||
// Enforce +m (moderated): only +o and +v can send.
|
||||
if !hdlr.checkModeratedSend(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target, chID,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// NOTICE skips hashcash validation on +H channels
|
||||
// (servers and services use NOTICE).
|
||||
isNotice := command == irc.CmdNotice
|
||||
|
||||
if !isNotice {
|
||||
hashcashErr := hdlr.validateChannelHashcash(
|
||||
request, clientID, sessionID,
|
||||
writer, nick, target, body, meta, chID,
|
||||
)
|
||||
if hashcashErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hdlr.sendChannelMsg(
|
||||
writer, request, command, nick, target,
|
||||
body, meta, chID,
|
||||
)
|
||||
}
|
||||
|
||||
// checkModeratedSend checks if a user can send to a
|
||||
// moderated channel. Returns true if sending is allowed.
|
||||
func (hdlr *Handlers) checkModeratedSend(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, target string,
|
||||
chID int64,
|
||||
) bool {
|
||||
ctx := request.Context()
|
||||
|
||||
isModerated, err := hdlr.params.Database.
|
||||
IsChannelModerated(ctx, chID)
|
||||
if err != nil || !isModerated {
|
||||
return true
|
||||
}
|
||||
|
||||
isOp, opErr := hdlr.params.Database.
|
||||
IsChannelOperator(ctx, chID, sessionID)
|
||||
if opErr == nil && isOp {
|
||||
return true
|
||||
}
|
||||
|
||||
isVoiced, vErr := hdlr.params.Database.
|
||||
IsChannelVoiced(ctx, chID, sessionID)
|
||||
if vErr == nil && isVoiced {
|
||||
return true
|
||||
}
|
||||
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrCannotSendToChan, nick,
|
||||
[]string{target},
|
||||
"Cannot send to channel (+m)",
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// validateChannelHashcash checks whether the channel
|
||||
// requires hashcash proof-of-work for messages and
|
||||
// validates the stamp from the message meta field.
|
||||
@@ -1508,17 +1569,20 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
return
|
||||
}
|
||||
|
||||
// If the target is away, send RPL_AWAY to the sender.
|
||||
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
||||
request.Context(), targetSID,
|
||||
)
|
||||
if awayErr == nil && awayMsg != "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplAway, nick,
|
||||
[]string{target}, awayMsg,
|
||||
// Per RFC 2812: NOTICE must NOT trigger auto-replies
|
||||
// including RPL_AWAY.
|
||||
if command != irc.CmdNotice {
|
||||
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
||||
request.Context(), targetSID,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
if awayErr == nil && awayMsg != "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplAway, nick,
|
||||
[]string{target}, awayMsg,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -1569,8 +1633,10 @@ func (hdlr *Handlers) executeJoin(
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
chID, err := hdlr.params.Database.GetOrCreateChannel(
|
||||
request.Context(), channel,
|
||||
ctx, channel,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
@@ -1585,9 +1651,28 @@ func (hdlr *Handlers) executeJoin(
|
||||
return
|
||||
}
|
||||
|
||||
err = hdlr.params.Database.JoinChannel(
|
||||
request.Context(), chID, sessionID,
|
||||
)
|
||||
// Check if channel is empty before joining — first
|
||||
// joiner becomes operator.
|
||||
memberCount, countErr := hdlr.params.Database.
|
||||
CountChannelMembers(ctx, chID)
|
||||
if countErr != nil {
|
||||
hdlr.log.Error(
|
||||
"count members failed", "error", countErr,
|
||||
)
|
||||
}
|
||||
|
||||
isCreator := countErr == nil && memberCount == 0
|
||||
|
||||
if isCreator {
|
||||
err = hdlr.params.Database.JoinChannelAsOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
} else {
|
||||
err = hdlr.params.Database.JoinChannel(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"join channel failed", "error", err,
|
||||
@@ -1602,11 +1687,11 @@ func (hdlr *Handlers) executeJoin(
|
||||
}
|
||||
|
||||
memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs(
|
||||
request.Context(), chID,
|
||||
ctx, chID,
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.deliverJoinNumerics(
|
||||
@@ -1696,6 +1781,21 @@ func (hdlr *Handlers) deliverTopicNumerics(
|
||||
}
|
||||
}
|
||||
|
||||
// memberPrefix returns the IRC prefix character for a
|
||||
// channel member: "@" for operators, "+" for voiced, or
|
||||
// "" for regular members.
|
||||
func memberPrefix(mem *db.MemberInfo) string {
|
||||
if mem.IsOperator {
|
||||
return "@"
|
||||
}
|
||||
|
||||
if mem.IsVoiced {
|
||||
return "+"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// deliverNamesNumerics sends RPL_NAMREPLY and
|
||||
// RPL_ENDOFNAMES for a channel.
|
||||
func (hdlr *Handlers) deliverNamesNumerics(
|
||||
@@ -1711,8 +1811,12 @@ func (hdlr *Handlers) deliverNamesNumerics(
|
||||
if memErr == nil && len(members) > 0 {
|
||||
entries := make([]string, 0, len(members))
|
||||
|
||||
for _, mem := range members {
|
||||
entries = append(entries, mem.Hostmask())
|
||||
for idx := range members {
|
||||
prefix := memberPrefix(&members[idx])
|
||||
entries = append(
|
||||
entries,
|
||||
prefix+members[idx].Hostmask(),
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
@@ -1776,7 +1880,7 @@ func (hdlr *Handlers) handlePart(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
err = hdlr.params.Database.PartChannel(
|
||||
@@ -2035,6 +2139,27 @@ func (hdlr *Handlers) executeTopic(
|
||||
body json.RawMessage,
|
||||
chID int64,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
// Enforce +t: only operators can change topic when
|
||||
// topic lock is active.
|
||||
isLocked, lockErr := hdlr.params.Database.
|
||||
IsChannelTopicLocked(ctx, chID)
|
||||
if lockErr == nil && isLocked {
|
||||
isOp, opErr := hdlr.params.Database.
|
||||
IsChannelOperator(ctx, chID, sessionID)
|
||||
if opErr != nil || !isOp {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrChanOpPrivsNeeded, nick,
|
||||
[]string{channel},
|
||||
"You're not channel operator",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setErr := hdlr.params.Database.SetTopicMeta(
|
||||
request.Context(), channel, topic, nick,
|
||||
)
|
||||
@@ -2056,7 +2181,7 @@ func (hdlr *Handlers) executeTopic(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
@@ -2267,9 +2392,39 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
)
|
||||
}
|
||||
|
||||
// buildChannelModeString constructs the current mode
|
||||
// string for a channel, including +n (always on), +t, +m,
|
||||
// and +H with its parameter.
|
||||
func (hdlr *Handlers) buildChannelModeString(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
) string {
|
||||
modes := "+n"
|
||||
|
||||
isTopicLocked, tlErr := hdlr.params.Database.
|
||||
IsChannelTopicLocked(ctx, chID)
|
||||
if tlErr == nil && isTopicLocked {
|
||||
modes += "t"
|
||||
}
|
||||
|
||||
isModerated, modErr := hdlr.params.Database.
|
||||
IsChannelModerated(ctx, chID)
|
||||
if modErr == nil && isModerated {
|
||||
modes += "m"
|
||||
}
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.
|
||||
GetChannelHashcashBits(ctx, chID)
|
||||
if bitsErr == nil && bits > 0 {
|
||||
modes += fmt.Sprintf("H %d", bits)
|
||||
}
|
||||
|
||||
return modes
|
||||
}
|
||||
|
||||
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||
// RPL_CREATIONTIME for a channel. Includes +H if
|
||||
// the channel has a hashcash requirement.
|
||||
// RPL_CREATIONTIME for a channel. Includes +t, +m, +H
|
||||
// as appropriate.
|
||||
func (hdlr *Handlers) queryChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -2279,13 +2434,7 @@ func (hdlr *Handlers) queryChannelMode(
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
modeStr := "+n"
|
||||
|
||||
bits, bitsErr := hdlr.params.Database.
|
||||
GetChannelHashcashBits(ctx, chID)
|
||||
if bitsErr == nil && bits > 0 {
|
||||
modeStr = fmt.Sprintf("+nH %d", bits)
|
||||
}
|
||||
modeStr := hdlr.buildChannelModeString(ctx, chID)
|
||||
|
||||
// 324 RPL_CHANNELMODEIS
|
||||
hdlr.enqueueNumeric(
|
||||
@@ -2315,8 +2464,35 @@ func (hdlr *Handlers) queryChannelMode(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// requireChannelOp checks that the session has +o in the
|
||||
// channel. If not, it sends ERR_CHANOPRIVSNEEDED and
|
||||
// returns false.
|
||||
func (hdlr *Handlers) requireChannelOp(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
) bool {
|
||||
isOp, err := hdlr.params.Database.IsChannelOperator(
|
||||
request.Context(), chID, sessionID,
|
||||
)
|
||||
if err != nil || !isOp {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrChanOpPrivsNeeded, nick,
|
||||
[]string{channel},
|
||||
"You're not channel operator",
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// applyChannelMode handles setting channel modes.
|
||||
// Currently supports +H/-H for hashcash bits.
|
||||
// Supports +o/-o, +v/-v, +m/-m, +t/-t, +H/-H.
|
||||
func (hdlr *Handlers) applyChannelMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -2329,6 +2505,42 @@ func (hdlr *Handlers) applyChannelMode(
|
||||
modeStr := modeArgs[0]
|
||||
|
||||
switch modeStr {
|
||||
case "+o", "-o":
|
||||
hdlr.applyUserMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, modeArgs, true,
|
||||
)
|
||||
case "+v", "-v":
|
||||
hdlr.applyUserMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, modeArgs, false,
|
||||
)
|
||||
case "+m":
|
||||
hdlr.setChannelFlag(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, "m", true,
|
||||
)
|
||||
case "-m":
|
||||
hdlr.setChannelFlag(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, "m", false,
|
||||
)
|
||||
case "+t":
|
||||
hdlr.setChannelFlag(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, "t", true,
|
||||
)
|
||||
case "-t":
|
||||
hdlr.setChannelFlag(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, "t", false,
|
||||
)
|
||||
case "+H":
|
||||
hdlr.setHashcashMode(
|
||||
writer, request,
|
||||
@@ -2355,6 +2567,224 @@ func (hdlr *Handlers) applyChannelMode(
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUserModeTarget validates a user-mode change
|
||||
// target and returns the target session ID if valid.
|
||||
// Returns -1 on error (error response already sent).
|
||||
func (hdlr *Handlers) resolveUserModeTarget(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
) (int64, string, bool) {
|
||||
if !hdlr.requireChannelOp(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
) {
|
||||
return -1, "", false
|
||||
}
|
||||
|
||||
if len(modeArgs) < 2 { //nolint:mnd // mode + nick
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdMode},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return -1, "", false
|
||||
}
|
||||
|
||||
ctx := request.Context()
|
||||
targetNick := modeArgs[1]
|
||||
|
||||
targetSID, err := hdlr.params.Database.
|
||||
GetSessionByNick(ctx, targetNick)
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchNick, nick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
|
||||
return -1, "", false
|
||||
}
|
||||
|
||||
isMember, memErr := hdlr.params.Database.
|
||||
IsChannelMember(ctx, chID, targetSID)
|
||||
if memErr != nil || !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUserNotInChannel, nick,
|
||||
[]string{targetNick, channel},
|
||||
"They aren't on that channel",
|
||||
)
|
||||
|
||||
return -1, "", false
|
||||
}
|
||||
|
||||
return targetSID, targetNick, true
|
||||
}
|
||||
|
||||
// applyUserMode handles +o/-o and +v/-v mode changes.
|
||||
// isOperMode=true for +o/-o, false for +v/-v.
|
||||
func (hdlr *Handlers) applyUserMode(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeArgs []string,
|
||||
isOperMode bool,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
targetSID, targetNick, ok := hdlr.resolveUserModeTarget(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, chID, modeArgs,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
setting := strings.HasPrefix(modeArgs[0], "+")
|
||||
|
||||
var err error
|
||||
if isOperMode {
|
||||
err = hdlr.params.Database.SetChannelMemberOperator(
|
||||
ctx, chID, targetSID, setting,
|
||||
)
|
||||
} else {
|
||||
err = hdlr.params.Database.SetChannelMemberVoiced(
|
||||
ctx, chID, targetSID, setting,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set user mode failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.broadcastUserModeChange(
|
||||
request, nick, channel, chID,
|
||||
modeArgs[0], targetNick,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// broadcastUserModeChange fans out a user-mode change
|
||||
// to all channel members.
|
||||
func (hdlr *Handlers) broadcastUserModeChange(
|
||||
request *http.Request,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeStr, targetNick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
memberIDs, _ := hdlr.params.Database.
|
||||
GetChannelMemberIDs(ctx, chID)
|
||||
|
||||
modeBody, err := json.Marshal(
|
||||
[]string{modeStr, targetNick},
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
}
|
||||
|
||||
// setChannelFlag handles +m/-m and +t/-t mode changes.
|
||||
func (hdlr *Handlers) setChannelFlag(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
flag string,
|
||||
setting bool,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
if !hdlr.requireChannelOp(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
switch flag {
|
||||
case "m":
|
||||
err = hdlr.params.Database.SetChannelModerated(
|
||||
ctx, chID, setting,
|
||||
)
|
||||
case "t":
|
||||
err = hdlr.params.Database.SetChannelTopicLocked(
|
||||
ctx, chID, setting,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
"set channel flag failed", "error", err,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast the MODE change.
|
||||
modeStr := "+" + flag
|
||||
if !setting {
|
||||
modeStr = "-" + flag
|
||||
}
|
||||
|
||||
memberIDs, _ := hdlr.params.Database.
|
||||
GetChannelMemberIDs(ctx, chID)
|
||||
|
||||
modeBody, mErr := json.Marshal([]string{modeStr})
|
||||
if mErr != nil {
|
||||
hdlr.log.Error(
|
||||
"marshal mode body", "error", mErr,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
const (
|
||||
// minHashcashBits is the minimum allowed hashcash
|
||||
// difficulty for channels.
|
||||
@@ -2416,12 +2846,11 @@ func (hdlr *Handlers) setHashcashMode(
|
||||
return
|
||||
}
|
||||
|
||||
modeStr := hdlr.buildChannelModeString(ctx, chID)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{
|
||||
channel,
|
||||
fmt.Sprintf("+H %d", bits),
|
||||
}, "",
|
||||
[]string{channel, modeStr}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -2455,9 +2884,11 @@ func (hdlr *Handlers) clearHashcashMode(
|
||||
return
|
||||
}
|
||||
|
||||
modeStr := hdlr.buildChannelModeString(ctx, chID)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
[]string{channel, "+n"}, "",
|
||||
[]string{channel, modeStr}, "",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -2508,8 +2939,12 @@ func (hdlr *Handlers) handleNames(
|
||||
if memErr == nil && len(members) > 0 {
|
||||
entries := make([]string, 0, len(members))
|
||||
|
||||
for _, mem := range members {
|
||||
entries = append(entries, mem.Hostmask())
|
||||
for idx := range members {
|
||||
prefix := memberPrefix(&members[idx])
|
||||
entries = append(
|
||||
entries,
|
||||
prefix+members[idx].Hostmask(),
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
@@ -3326,6 +3761,222 @@ func (hdlr *Handlers) handleAway(
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// handleKick handles the KICK command.
|
||||
func (hdlr *Handlers) handleKick(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, target string,
|
||||
body json.RawMessage,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
if target == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdKick},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := target
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
lines := bodyLines()
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick,
|
||||
[]string{irc.CmdKick},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetNick := lines[0]
|
||||
|
||||
reason := nick
|
||||
if len(lines) > 1 {
|
||||
reason = lines[1]
|
||||
}
|
||||
|
||||
hdlr.executeKick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, targetNick, reason, body,
|
||||
)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) executeKick(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel, targetNick, reason string,
|
||||
_ json.RawMessage,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
chID, targetSID, ok := hdlr.validateKick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
channel, targetNick,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !hdlr.broadcastKick(
|
||||
writer, request,
|
||||
nick, channel, targetNick, reason, chID,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the kicked user from the channel.
|
||||
_ = hdlr.params.Database.PartChannel(
|
||||
ctx, chID, targetSID,
|
||||
)
|
||||
|
||||
// Clean up empty channel.
|
||||
_ = hdlr.params.Database.DeleteChannelIfEmpty(
|
||||
ctx, chID,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{"status": "ok"},
|
||||
http.StatusOK)
|
||||
}
|
||||
|
||||
// validateKick checks the channel exists, the kicker is
|
||||
// an operator, and the target is in the channel.
|
||||
func (hdlr *Handlers) validateKick(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID, clientID int64,
|
||||
nick, channel, targetNick string,
|
||||
) (int64, int64, bool) {
|
||||
ctx := request.Context()
|
||||
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
ctx, channel,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
if !hdlr.requireChannelOp(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, channel, chID,
|
||||
) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
targetSID, err := hdlr.params.Database.
|
||||
GetSessionByNick(ctx, targetNick)
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchNick, nick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
isMember, memErr := hdlr.params.Database.
|
||||
IsChannelMember(ctx, chID, targetSID)
|
||||
if memErr != nil || !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrUserNotInChannel, nick,
|
||||
[]string{targetNick, channel},
|
||||
"They aren't on that channel",
|
||||
)
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return chID, targetSID, true
|
||||
}
|
||||
|
||||
// broadcastKick inserts a KICK message and fans it out
|
||||
// to all channel members.
|
||||
func (hdlr *Handlers) broadcastKick(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
nick, channel, targetNick, reason string,
|
||||
chID int64,
|
||||
) bool {
|
||||
ctx := request.Context()
|
||||
|
||||
memberIDs, _ := hdlr.params.Database.
|
||||
GetChannelMemberIDs(ctx, chID)
|
||||
|
||||
kickBody, bErr := json.Marshal([]string{reason})
|
||||
if bErr != nil {
|
||||
hdlr.log.Error("marshal kick body", "error", bErr)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
kickParams, pErr := json.Marshal(
|
||||
[]string{targetNick},
|
||||
)
|
||||
if pErr != nil {
|
||||
hdlr.log.Error(
|
||||
"marshal kick params", "error", pErr,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
dbID, _, insertErr := hdlr.params.Database.
|
||||
InsertMessage(
|
||||
ctx, irc.CmdKick, nick, channel,
|
||||
json.RawMessage(kickParams),
|
||||
json.RawMessage(kickBody), nil,
|
||||
)
|
||||
if insertErr != nil {
|
||||
hdlr.log.Error(
|
||||
"insert kick message", "error", insertErr,
|
||||
)
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"internal error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
for _, sid := range memberIDs {
|
||||
enqErr := hdlr.params.Database.EnqueueToSession(
|
||||
ctx, sid, dbID,
|
||||
)
|
||||
if enqErr != nil {
|
||||
hdlr.log.Error("enqueue kick failed",
|
||||
"error", enqErr, "session_id", sid)
|
||||
}
|
||||
|
||||
hdlr.broker.Notify(sid)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
|
||||
// time and signon time.
|
||||
func (hdlr *Handlers) deliverWhoisIdle(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user