Files
chat/internal/service/service.go
clawbot 92d5145ac6
Some checks failed
check / check (push) Failing after 46s
feat: add IRC wire protocol listener with shared service layer
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>
2026-03-25 18:01:36 -07:00

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)
}
}
}