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

@@ -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(