Show an IRC listener user what their session sends from other clients (closes #179)
check / check (push) Canceled after 0s

The IRC listener's relay dropped every line sent under the connection's own nick, so a user on the web client and an IRC client at once never saw on IRC what they did from the web client. The relay now drops nothing by nick. Instead the fan-out skips only the sending client's own queue: the IRC listener passes its own client, whose handler already wrote the echo, and the HTTP API skips none. Channel messages and PARTs now reach the sender's other clients, so an HTTP client gets its own PART event; neoirc-cli ignores it, as it already shows its own part. README.md names the commands covered.

Judgement call: a PRIVMSG or NOTICE sent on an IRC connection is not echoed back to it, as in IRC.

Model: opus-5-5
This commit was merged in pull request #183.
This commit is contained in:
2026-10-21 15:23:13 +02:00
parent d539b4c558
commit 129d87d578
13 changed files with 455 additions and 247 deletions
+64 -68
View File
@@ -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)
}
}