Show an IRC listener user what their session sends from other clients (closes #179)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The relay dropped every PRIVMSG, NOTICE, JOIN, PART, NICK, QUIT, TOPIC, MODE and KICK sent under the connection's own nick, so an IRC listener connection never saw what its session did from another client. The relay now drops nothing by nick. Instead, the service methods that fan out a user's own command take the client whose queue to skip: the IRC listener passes its own client, which has already written the echo, and the HTTP API passes 0. Channel messages and PARTs now also go to the sender's own session, so the HTTP handler no longer queues the channel-message echo itself, SendChannelMessage no longer returns the message's row ID, and neoirc-cli ignores the PART event for its own part, which it has already shown. Model: opus-5-5
This commit is contained in:
+64
-68
@@ -312,7 +312,7 @@ func (s *Service) SendWallops(
|
||||
|
||||
_, _, err = s.FanOut(
|
||||
ctx, irc.CmdWallops, nick, "*", 0, 0,
|
||||
nil, body, nil, recipients,
|
||||
nil, body, nil, recipients, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wallops: %w", err)
|
||||
@@ -347,12 +347,19 @@ type DirectMsgResult struct {
|
||||
// session IDs, notifying each via the broker. toSessionID
|
||||
// and toChannelID are the recipient InsertMessage records
|
||||
// for history, 0 when there is none.
|
||||
//
|
||||
// skipClientID is the client whose queue the message is
|
||||
// not added to: the IRC listener connection that sent the
|
||||
// command, which has already shown it to its user. The HTTP
|
||||
// API passes 0, as its clients see their own commands
|
||||
// through their queues.
|
||||
func (s *Service) FanOut(
|
||||
ctx context.Context,
|
||||
command, from, to string,
|
||||
toSessionID, toChannelID int64,
|
||||
params, body, meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
skipClientID int64,
|
||||
) (int64, string, error) {
|
||||
dbID, msgUUID, err := s.db.InsertMessage(
|
||||
ctx, command, from, to,
|
||||
@@ -363,58 +370,40 @@ func (s *Service) FanOut(
|
||||
}
|
||||
|
||||
for _, sid := range sessionIDs {
|
||||
_ = s.db.EnqueueToSession(ctx, sid, dbID)
|
||||
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID)
|
||||
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 checks membership, bans and
|
||||
// moderation, then the hashcash stamp of a PRIVMSG to a +H
|
||||
// channel, then fans out a message to all channel members
|
||||
// except the sender. The stamp is checked after the other
|
||||
// checks so that a message they refuse does not spend it.
|
||||
// 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).
|
||||
// channel, then fans out a message to all channel members,
|
||||
// the sender's session included, skipping client
|
||||
// skipClientID as FanOut does. The stamp is checked after
|
||||
// the other checks so that a message they refuse does not
|
||||
// spend it. Returns the message UUID and any error.
|
||||
func (s *Service) SendChannelMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
nick, command, channel string,
|
||||
body, meta json.RawMessage,
|
||||
) (int64, string, error) {
|
||||
) (string, error) {
|
||||
noSuchChannel := &IRCError{
|
||||
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
|
||||
}
|
||||
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return 0, "", noSuchChannel
|
||||
return "", noSuchChannel
|
||||
}
|
||||
|
||||
isMember, _ := s.db.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
return 0, "", &IRCError{
|
||||
return "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel",
|
||||
@@ -426,7 +415,7 @@ func (s *Service) SendChannelMessage(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if banErr == nil && isBanned {
|
||||
return 0, "", &IRCError{
|
||||
return "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel (+b)",
|
||||
@@ -443,7 +432,7 @@ func (s *Service) SendChannelMessage(
|
||||
)
|
||||
|
||||
if !isOp && !isVoiced {
|
||||
return 0, "", &IRCError{
|
||||
return "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel (+m)",
|
||||
@@ -456,35 +445,35 @@ func (s *Service) SendChannelMessage(
|
||||
ctx, chID, channel, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
dbID, uuid, fanErr := s.FanOut(
|
||||
_, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, channel, 0, chID,
|
||||
nil, body, meta, recipients,
|
||||
nil, body, meta, memberIDs, skipClientID,
|
||||
)
|
||||
// The channel was deleted after the lookup above.
|
||||
if db.IsForeignKeyConstraintError(fanErr) {
|
||||
return 0, "", noSuchChannel
|
||||
return "", noSuchChannel
|
||||
}
|
||||
|
||||
if fanErr != nil {
|
||||
return 0, "", fanErr
|
||||
return "", fanErr
|
||||
}
|
||||
|
||||
return dbID, uuid, nil
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
// SendDirectMessage validates the target and sends a
|
||||
// direct message, returning the message UUID and any away
|
||||
// message set on the target.
|
||||
// direct message to the target and the sender's session,
|
||||
// skipping client skipClientID as FanOut does, and returns
|
||||
// the message UUID and any away message set on the target.
|
||||
func (s *Service) SendDirectMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
nick, command, target string,
|
||||
body, meta json.RawMessage,
|
||||
) (*DirectMsgResult, error) {
|
||||
@@ -506,7 +495,7 @@ func (s *Service) SendDirectMessage(
|
||||
|
||||
_, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, target, targetSID, 0,
|
||||
nil, body, meta, recipients,
|
||||
nil, body, meta, recipients, skipClientID,
|
||||
)
|
||||
// The target's session was deleted after the lookup above.
|
||||
if db.IsForeignKeyConstraintError(fanErr) {
|
||||
@@ -522,11 +511,12 @@ func (s *Service) SendDirectMessage(
|
||||
|
||||
// JoinChannel creates or joins a channel, making the
|
||||
// first joiner the operator. Fans out the JOIN to all
|
||||
// channel members. A channel name that validChannelRe
|
||||
// does not match gets ERR_NOSUCHCHANNEL.
|
||||
// channel members, skipping client skipClientID as FanOut
|
||||
// does. A channel name that validChannelRe does not match
|
||||
// gets ERR_NOSUCHCHANNEL.
|
||||
func (s *Service) JoinChannel(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
nick, channel, suppliedKey string,
|
||||
) (*JoinResult, error) {
|
||||
if !validChannelRe.MatchString(channel) {
|
||||
@@ -549,7 +539,7 @@ func (s *Service) JoinChannel(
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdJoin, nick, channel, 0, chID,
|
||||
nil, body, nil, memberIDs,
|
||||
nil, body, nil, memberIDs, skipClientID,
|
||||
)
|
||||
|
||||
return &JoinResult{
|
||||
@@ -558,12 +548,12 @@ func (s *Service) JoinChannel(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PartChannel validates membership, broadcasts PART to
|
||||
// remaining members, removes the user, and cleans up empty
|
||||
// channels.
|
||||
// PartChannel validates membership, broadcasts PART to all
|
||||
// channel members, skipping client skipClientID as FanOut
|
||||
// does, removes the user, and cleans up empty channels.
|
||||
func (s *Service) PartChannel(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
nick, channel, reason string,
|
||||
) error {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
@@ -587,12 +577,11 @@ func (s *Service) PartChannel(
|
||||
}
|
||||
|
||||
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, 0, chID,
|
||||
nil, body, nil, recipients,
|
||||
nil, body, nil, memberIDs, skipClientID,
|
||||
)
|
||||
|
||||
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||
@@ -601,10 +590,11 @@ func (s *Service) PartChannel(
|
||||
}
|
||||
|
||||
// SetTopic validates membership and topic-lock, sets the
|
||||
// topic, and broadcasts the change.
|
||||
// topic, and broadcasts the change to all channel members,
|
||||
// skipping client skipClientID as FanOut does.
|
||||
func (s *Service) SetTopic(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
nick, channel, topic string,
|
||||
) error {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
@@ -653,18 +643,19 @@ func (s *Service) SetTopic(
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdTopic, nick, channel, 0, chID,
|
||||
nil, body, nil, memberIDs,
|
||||
nil, body, nil, memberIDs, skipClientID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// KickUser validates operator status and target
|
||||
// membership, broadcasts the KICK, removes the target,
|
||||
// and cleans up empty channels.
|
||||
// membership, broadcasts the KICK to all channel members,
|
||||
// skipping client skipClientID as FanOut does, removes the
|
||||
// target, and cleans up empty channels.
|
||||
func (s *Service) KickUser(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
nick, channel, targetNick, reason string,
|
||||
) error {
|
||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||
@@ -717,7 +708,7 @@ func (s *Service) KickUser(
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdKick, nick, channel, 0, chID,
|
||||
params, body, nil, memberIDs,
|
||||
params, body, nil, memberIDs, skipClientID,
|
||||
)
|
||||
|
||||
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||
@@ -798,10 +789,11 @@ func (s *Service) InviteUser(
|
||||
}
|
||||
|
||||
// ChangeNick changes a user's nickname and broadcasts the
|
||||
// change to all users sharing channels.
|
||||
// change to the user's own session and all users sharing
|
||||
// channels, skipping client skipClientID as FanOut does.
|
||||
func (s *Service) ChangeNick(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
oldNick, newNick string,
|
||||
) error {
|
||||
err := s.db.ChangeNick(ctx, sessionID, newNick)
|
||||
@@ -822,7 +814,9 @@ func (s *Service) ChangeNick(
|
||||
}
|
||||
}
|
||||
|
||||
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
|
||||
s.broadcastNickChange(
|
||||
ctx, sessionID, skipClientID, oldNick, newNick,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1042,9 +1036,10 @@ func (s *Service) SetChannelFlag(
|
||||
}
|
||||
|
||||
// BroadcastMode fans out a MODE change to all channel
|
||||
// members.
|
||||
// members, skipping client skipClientID as FanOut does.
|
||||
func (s *Service) BroadcastMode(
|
||||
ctx context.Context,
|
||||
skipClientID int64,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeText string,
|
||||
@@ -1054,7 +1049,7 @@ func (s *Service) BroadcastMode(
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdMode, nick, channel, 0, chID,
|
||||
nil, body, nil, memberIDs,
|
||||
nil, body, nil, memberIDs, skipClientID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1306,11 +1301,12 @@ func (s *Service) checkChannelHashcash(
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
// broadcastNickChange notifies the user's own session,
|
||||
// except client skipClientID, and channel peers of a nick
|
||||
// change.
|
||||
func (s *Service) broadcastNickChange(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
sessionID, skipClientID int64,
|
||||
oldNick, newNick string,
|
||||
) {
|
||||
channels, err := s.db.GetSessionChannels(
|
||||
@@ -1332,7 +1328,7 @@ func (s *Service) broadcastNickChange(
|
||||
}
|
||||
|
||||
// Notify the user themselves (for multi-client sync).
|
||||
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
|
||||
_ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID)
|
||||
s.broker.Notify(sessionID)
|
||||
notified[sessionID] = true
|
||||
|
||||
@@ -1351,7 +1347,7 @@ func (s *Service) broadcastNickChange(
|
||||
|
||||
notified[mid] = true
|
||||
|
||||
_ = s.db.EnqueueToSession(ctx, mid, dbID)
|
||||
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0)
|
||||
s.broker.Notify(mid)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user