Some checks failed
check / check (push) Failing after 46s
Add a traditional IRC wire protocol listener (RFC 1459/2812) on configurable port (default :6667), sharing business logic with the HTTP API via a new service layer. - IRC listener: NICK, USER, PASS, JOIN, PART, PRIVMSG, NOTICE, TOPIC, MODE, KICK, QUIT, NAMES, LIST, WHOIS, WHO, AWAY, OPER, INVITE, LUSERS, MOTD, PING/PONG, CAP - Service layer: shared logic for both transports including channel join (with Tier 2 checks: ban/invite/key/limit), message send (with ban + moderation checks), nick change, topic, kick, mode, quit broadcast, away, oper, invite - BroadcastQuit uses FanOut pattern (one insert, N enqueues) - HTTP handlers delegate to service for all command logic - Tier 2 mode operations (+b/+i/+s/+k/+l) use service methods Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
746 lines
16 KiB
Go
746 lines
16 KiB
Go
// Package service provides shared business logic for both
|
|
// the IRC wire protocol and HTTP/JSON transports.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
// Params defines the dependencies for creating a Service.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Config *config.Config
|
|
Database *db.Database
|
|
Broker *broker.Broker
|
|
}
|
|
|
|
// Service provides shared business logic for IRC commands.
|
|
type Service struct {
|
|
DB *db.Database
|
|
Broker *broker.Broker
|
|
Config *config.Config
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// New creates a new Service.
|
|
func New(params Params) *Service {
|
|
return &Service{
|
|
DB: params.Database,
|
|
Broker: params.Broker,
|
|
Config: params.Config,
|
|
Log: params.Logger.Get(),
|
|
}
|
|
}
|
|
|
|
// IRCError represents an IRC protocol-level error with a
|
|
// numeric code that both transports can map to responses.
|
|
type IRCError struct {
|
|
Code irc.IRCMessageType
|
|
Params []string
|
|
Message string
|
|
}
|
|
|
|
func (e *IRCError) Error() string { return e.Message }
|
|
|
|
// JoinResult contains the outcome of a channel join.
|
|
type JoinResult struct {
|
|
ChannelID int64
|
|
IsCreator bool
|
|
}
|
|
|
|
// DirectMsgResult contains the outcome of a direct message.
|
|
type DirectMsgResult struct {
|
|
UUID string
|
|
AwayMsg string
|
|
}
|
|
|
|
// FanOut inserts a message and enqueues it to all given
|
|
// session IDs, notifying each via the broker.
|
|
func (s *Service) FanOut(
|
|
ctx context.Context,
|
|
command, from, to string,
|
|
params, body, meta json.RawMessage,
|
|
sessionIDs []int64,
|
|
) (int64, string, error) {
|
|
dbID, msgUUID, err := s.DB.InsertMessage(
|
|
ctx, command, from, to, params, body, meta,
|
|
)
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("insert message: %w", err)
|
|
}
|
|
|
|
for _, sid := range sessionIDs {
|
|
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
|
s.Broker.Notify(sid)
|
|
}
|
|
|
|
return dbID, msgUUID, nil
|
|
}
|
|
|
|
// excludeSession returns a copy of ids without the given
|
|
// session.
|
|
func excludeSession(
|
|
ids []int64,
|
|
exclude int64,
|
|
) []int64 {
|
|
out := make([]int64, 0, len(ids))
|
|
|
|
for _, id := range ids {
|
|
if id != exclude {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// SendChannelMessage validates membership and moderation,
|
|
// then fans out a message to all channel members except
|
|
// the sender. Returns the database row ID, message UUID,
|
|
// and any error. The dbID lets callers enqueue the same
|
|
// message to the sender when echo is needed (HTTP
|
|
// transport).
|
|
func (s *Service) SendChannelMessage(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, command, channel string,
|
|
body, meta json.RawMessage,
|
|
) (int64, string, error) {
|
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return 0, "", &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
"No such channel",
|
|
}
|
|
}
|
|
|
|
isMember, _ := s.DB.IsChannelMember(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isMember {
|
|
return 0, "", &IRCError{
|
|
irc.ErrCannotSendToChan,
|
|
[]string{channel},
|
|
"Cannot send to channel",
|
|
}
|
|
}
|
|
|
|
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
|
if moderated {
|
|
isOp, _ := s.DB.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
isVoiced, _ := s.DB.IsChannelVoiced(
|
|
ctx, chID, sessionID,
|
|
)
|
|
|
|
if !isOp && !isVoiced {
|
|
return 0, "", &IRCError{
|
|
irc.ErrCannotSendToChan,
|
|
[]string{channel},
|
|
"Cannot send to channel (+m)",
|
|
}
|
|
}
|
|
}
|
|
|
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
|
recipients := excludeSession(memberIDs, sessionID)
|
|
|
|
dbID, uuid, fanErr := s.FanOut(
|
|
ctx, command, nick, channel,
|
|
nil, body, meta, recipients,
|
|
)
|
|
if fanErr != nil {
|
|
return 0, "", fanErr
|
|
}
|
|
|
|
return dbID, uuid, nil
|
|
}
|
|
|
|
// SendDirectMessage validates the target and sends a
|
|
// direct message, returning the message UUID and any away
|
|
// message set on the target.
|
|
func (s *Service) SendDirectMessage(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, command, target string,
|
|
body, meta json.RawMessage,
|
|
) (*DirectMsgResult, error) {
|
|
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
|
if err != nil {
|
|
return nil, &IRCError{
|
|
irc.ErrNoSuchNick,
|
|
[]string{target},
|
|
"No such nick",
|
|
}
|
|
}
|
|
|
|
away, _ := s.DB.GetAway(ctx, targetSID)
|
|
|
|
recipients := []int64{targetSID}
|
|
if targetSID != sessionID {
|
|
recipients = append(recipients, sessionID)
|
|
}
|
|
|
|
_, uuid, fanErr := s.FanOut(
|
|
ctx, command, nick, target,
|
|
nil, body, meta, recipients,
|
|
)
|
|
if fanErr != nil {
|
|
return nil, fanErr
|
|
}
|
|
|
|
return &DirectMsgResult{UUID: uuid, AwayMsg: away}, nil
|
|
}
|
|
|
|
// JoinChannel creates or joins a channel, making the
|
|
// first joiner the operator. Fans out the JOIN to all
|
|
// channel members.
|
|
func (s *Service) JoinChannel(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, channel string,
|
|
) (*JoinResult, error) {
|
|
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get/create channel: %w", err)
|
|
}
|
|
|
|
memberCount, countErr := s.DB.CountChannelMembers(
|
|
ctx, chID,
|
|
)
|
|
isCreator := countErr == nil && memberCount == 0
|
|
|
|
if isCreator {
|
|
err = s.DB.JoinChannelAsOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
} else {
|
|
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("join channel: %w", err)
|
|
}
|
|
|
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
|
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
|
|
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
|
ctx, irc.CmdJoin, nick, channel,
|
|
nil, body, nil, memberIDs,
|
|
)
|
|
|
|
return &JoinResult{
|
|
ChannelID: chID,
|
|
IsCreator: isCreator,
|
|
}, nil
|
|
}
|
|
|
|
// PartChannel validates membership, broadcasts PART to
|
|
// remaining members, removes the user, and cleans up empty
|
|
// channels.
|
|
func (s *Service) PartChannel(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, channel, reason string,
|
|
) error {
|
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
"No such channel",
|
|
}
|
|
}
|
|
|
|
isMember, _ := s.DB.IsChannelMember(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isMember {
|
|
return &IRCError{
|
|
irc.ErrNotOnChannel,
|
|
[]string{channel},
|
|
"You're not on that channel",
|
|
}
|
|
}
|
|
|
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
|
recipients := excludeSession(memberIDs, sessionID)
|
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
|
|
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
|
ctx, irc.CmdPart, nick, channel,
|
|
nil, body, nil, recipients,
|
|
)
|
|
|
|
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
|
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetTopic validates membership and topic-lock, sets the
|
|
// topic, and broadcasts the change.
|
|
func (s *Service) SetTopic(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, channel, topic string,
|
|
) error {
|
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
"No such channel",
|
|
}
|
|
}
|
|
|
|
isMember, _ := s.DB.IsChannelMember(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isMember {
|
|
return &IRCError{
|
|
irc.ErrNotOnChannel,
|
|
[]string{channel},
|
|
"You're not on that channel",
|
|
}
|
|
}
|
|
|
|
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
|
if topicLocked {
|
|
isOp, _ := s.DB.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isOp {
|
|
return &IRCError{
|
|
irc.ErrChanOpPrivsNeeded,
|
|
[]string{channel},
|
|
"You're not channel operator",
|
|
}
|
|
}
|
|
}
|
|
|
|
if setErr := s.DB.SetTopic(
|
|
ctx, channel, topic,
|
|
); setErr != nil {
|
|
return fmt.Errorf("set topic: %w", setErr)
|
|
}
|
|
|
|
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
|
|
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
|
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
|
|
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
|
ctx, irc.CmdTopic, nick, channel,
|
|
nil, body, nil, memberIDs,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// KickUser validates operator status and target
|
|
// membership, broadcasts the KICK, removes the target,
|
|
// and cleans up empty channels.
|
|
func (s *Service) KickUser(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, channel, targetNick, reason string,
|
|
) error {
|
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
"No such channel",
|
|
}
|
|
}
|
|
|
|
isOp, _ := s.DB.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isOp {
|
|
return &IRCError{
|
|
irc.ErrChanOpPrivsNeeded,
|
|
[]string{channel},
|
|
"You're not channel operator",
|
|
}
|
|
}
|
|
|
|
targetSID, err := s.DB.GetSessionByNick(
|
|
ctx, targetNick,
|
|
)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrNoSuchNick,
|
|
[]string{targetNick},
|
|
"No such nick/channel",
|
|
}
|
|
}
|
|
|
|
isMember, _ := s.DB.IsChannelMember(
|
|
ctx, chID, targetSID,
|
|
)
|
|
if !isMember {
|
|
return &IRCError{
|
|
irc.ErrUserNotInChannel,
|
|
[]string{targetNick, channel},
|
|
"They aren't on that channel",
|
|
}
|
|
}
|
|
|
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
|
params, _ := json.Marshal( //nolint:errchkjson
|
|
[]string{targetNick},
|
|
)
|
|
|
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
|
ctx, irc.CmdKick, nick, channel,
|
|
params, body, nil, memberIDs,
|
|
)
|
|
|
|
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
|
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
|
|
|
return nil
|
|
}
|
|
|
|
// ChangeNick changes a user's nickname and broadcasts the
|
|
// change to all users sharing channels.
|
|
func (s *Service) ChangeNick(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
oldNick, newNick string,
|
|
) error {
|
|
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE") ||
|
|
db.IsUniqueConstraintError(err) {
|
|
return &IRCError{
|
|
irc.ErrNicknameInUse,
|
|
[]string{newNick},
|
|
"Nickname is already in use",
|
|
}
|
|
}
|
|
|
|
return &IRCError{
|
|
irc.ErrErroneusNickname,
|
|
[]string{newNick},
|
|
"Erroneous nickname",
|
|
}
|
|
}
|
|
|
|
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
|
|
|
|
return nil
|
|
}
|
|
|
|
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
|
// parts all channels, and deletes the session. Uses the
|
|
// FanOut pattern: one message row fanned out to all unique
|
|
// peer sessions.
|
|
func (s *Service) BroadcastQuit(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, reason string,
|
|
) {
|
|
channels, err := s.DB.GetSessionChannels(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
notified := make(map[int64]bool)
|
|
|
|
for _, ch := range channels {
|
|
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
|
ctx, ch.ID,
|
|
)
|
|
if memErr != nil {
|
|
continue
|
|
}
|
|
|
|
for _, mid := range memberIDs {
|
|
if mid == sessionID || notified[mid] {
|
|
continue
|
|
}
|
|
|
|
notified[mid] = true
|
|
}
|
|
}
|
|
|
|
if len(notified) > 0 {
|
|
recipients := make([]int64, 0, len(notified))
|
|
for sid := range notified {
|
|
recipients = append(recipients, sid)
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
|
|
|
_, _, _ = s.FanOut(
|
|
ctx, irc.CmdQuit, nick, "",
|
|
nil, body, nil, recipients,
|
|
)
|
|
}
|
|
|
|
for _, ch := range channels {
|
|
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
|
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
|
}
|
|
|
|
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
|
}
|
|
|
|
// SetAway sets or clears the away message. Returns true
|
|
// if the message was cleared (empty string).
|
|
func (s *Service) SetAway(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
message string,
|
|
) (bool, error) {
|
|
err := s.DB.SetAway(ctx, sessionID, message)
|
|
if err != nil {
|
|
return false, fmt.Errorf("set away: %w", err)
|
|
}
|
|
|
|
return message == "", nil
|
|
}
|
|
|
|
// Oper validates operator credentials and grants oper
|
|
// status to the session.
|
|
func (s *Service) Oper(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
name, password string,
|
|
) error {
|
|
cfgName := s.Config.OperName
|
|
cfgPassword := s.Config.OperPassword
|
|
|
|
// Use constant-time comparison and return the same
|
|
// error for all failures to prevent information
|
|
// leakage about valid operator names.
|
|
if cfgName == "" || cfgPassword == "" ||
|
|
subtle.ConstantTimeCompare(
|
|
[]byte(name), []byte(cfgName),
|
|
) != 1 ||
|
|
subtle.ConstantTimeCompare(
|
|
[]byte(password), []byte(cfgPassword),
|
|
) != 1 {
|
|
return &IRCError{
|
|
irc.ErrNoOperHost,
|
|
nil,
|
|
"No O-lines for your host",
|
|
}
|
|
}
|
|
|
|
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateChannelOp checks that the session is a channel
|
|
// operator. Returns the channel ID.
|
|
func (s *Service) ValidateChannelOp(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
channel string,
|
|
) (int64, error) {
|
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return 0, &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
"No such channel",
|
|
}
|
|
}
|
|
|
|
isOp, _ := s.DB.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isOp {
|
|
return 0, &IRCError{
|
|
irc.ErrChanOpPrivsNeeded,
|
|
[]string{channel},
|
|
"You're not channel operator",
|
|
}
|
|
}
|
|
|
|
return chID, nil
|
|
}
|
|
|
|
// ApplyMemberMode applies +o/-o or +v/-v on a channel
|
|
// member after validating the target.
|
|
func (s *Service) ApplyMemberMode(
|
|
ctx context.Context,
|
|
chID int64,
|
|
channel, targetNick string,
|
|
mode rune,
|
|
adding bool,
|
|
) error {
|
|
targetSID, err := s.DB.GetSessionByNick(
|
|
ctx, targetNick,
|
|
)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrNoSuchNick,
|
|
[]string{targetNick},
|
|
"No such nick/channel",
|
|
}
|
|
}
|
|
|
|
isMember, _ := s.DB.IsChannelMember(
|
|
ctx, chID, targetSID,
|
|
)
|
|
if !isMember {
|
|
return &IRCError{
|
|
irc.ErrUserNotInChannel,
|
|
[]string{targetNick, channel},
|
|
"They aren't on that channel",
|
|
}
|
|
}
|
|
|
|
switch mode {
|
|
case 'o':
|
|
_ = s.DB.SetChannelMemberOperator(
|
|
ctx, chID, targetSID, adding,
|
|
)
|
|
case 'v':
|
|
_ = s.DB.SetChannelMemberVoiced(
|
|
ctx, chID, targetSID, adding,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
|
func (s *Service) SetChannelFlag(
|
|
ctx context.Context,
|
|
chID int64,
|
|
flag rune,
|
|
setting bool,
|
|
) error {
|
|
switch flag {
|
|
case 'm':
|
|
if err := s.DB.SetChannelModerated(
|
|
ctx, chID, setting,
|
|
); err != nil {
|
|
return fmt.Errorf("set moderated: %w", err)
|
|
}
|
|
case 't':
|
|
if err := s.DB.SetChannelTopicLocked(
|
|
ctx, chID, setting,
|
|
); err != nil {
|
|
return fmt.Errorf("set topic locked: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BroadcastMode fans out a MODE change to all channel
|
|
// members.
|
|
func (s *Service) BroadcastMode(
|
|
ctx context.Context,
|
|
nick, channel string,
|
|
chID int64,
|
|
modeText string,
|
|
) {
|
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
|
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
|
|
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
|
ctx, irc.CmdMode, nick, channel,
|
|
nil, body, nil, memberIDs,
|
|
)
|
|
}
|
|
|
|
// QueryChannelMode returns the channel mode string.
|
|
func (s *Service) QueryChannelMode(
|
|
ctx context.Context,
|
|
chID int64,
|
|
) string {
|
|
modes := "+"
|
|
|
|
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
|
if moderated {
|
|
modes += "m"
|
|
}
|
|
|
|
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
|
if topicLocked {
|
|
modes += "t"
|
|
}
|
|
|
|
return modes
|
|
}
|
|
|
|
// broadcastNickChange notifies channel peers of a nick
|
|
// change.
|
|
func (s *Service) broadcastNickChange(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
oldNick, newNick string,
|
|
) {
|
|
channels, err := s.DB.GetSessionChannels(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
|
notified := make(map[int64]bool)
|
|
|
|
dbID, _, insErr := s.DB.InsertMessage(
|
|
ctx, irc.CmdNick, oldNick, "",
|
|
nil, body, nil,
|
|
)
|
|
if insErr != nil {
|
|
return
|
|
}
|
|
|
|
// Notify the user themselves (for multi-client sync).
|
|
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
|
s.Broker.Notify(sessionID)
|
|
notified[sessionID] = true
|
|
|
|
for _, ch := range channels {
|
|
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
|
ctx, ch.ID,
|
|
)
|
|
if memErr != nil {
|
|
continue
|
|
}
|
|
|
|
for _, mid := range memberIDs {
|
|
if notified[mid] {
|
|
continue
|
|
}
|
|
|
|
notified[mid] = true
|
|
|
|
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
|
s.Broker.Notify(mid)
|
|
}
|
|
}
|
|
}
|