package service import ( "context" "fmt" "log/slog" "time" "github.com/J3vb/OwnCord/Server/auth" "github.com/J3vb/OwnCord/Server/db" "github.com/J3vb/OwnCord/Server/telemetry" ) // DMService handles direct message channel operations. type DMService struct { st Store // online reports whether userID currently holds a live WebSocket // connection. It is wired by the ws layer (Hub.IsUserConnected) after // both are constructed, mirroring MessageService.online (see its doc // comment in message.go) — so every DM payload this service builds can // apply the same "no live connection is offline, whatever users.status // stores" rule ws/serve_ready.go's presentableMembers/ // presentableDMChannels apply to the ready payload and members list. // users.status keeps a *chosen* idle/dnd/invisible across a disconnect by // design (MarkUserDisconnected only ever rewrites "online" -> // "offline"), so without this a signed-out user's last chosen status // leaks into the DM sidebar as if they were still connected. nil (the // zero value, e.g. in tests and any caller with no hub) means "no // live-connection information available" and applies no extra // narrowing, preserving prior behavior. online func(userID int64) bool } // NewDMService creates a DMService. func NewDMService(st Store) *DMService { return &DMService{st: st} } // SetOnlineChecker wires the live-connection predicate every DM payload this // service builds consults in addition to users.status. Passing nil clears // it. Safe to call once at startup (the ws layer, after constructing both // the Hub and the Services) or from a test. func (s *DMService) SetOnlineChecker(online func(userID int64) bool) { s.online = online } // PresentableStatus narrows status to db.StatusOffline when subjectID holds // no live connection, whatever status says — the second half of the rule // ws/serve_ready.go's presentableMembers documents: users.status keeps a // *chosen* idle/dnd/invisible across a disconnect (MarkUserDisconnected only // ever rewrites "online" -> "offline") so the next connect can honour it, // which means every read path must apply this narrowing itself rather than // trusting the stored value alone. Exported so a caller that hand-builds a // db.DMUser outside this package (POST /dms) applies the identical rule // DMSummaryFor/ListDMs/CreateGroupDM apply via presentableDMChannelInfo // below. A checker that was never wired (SetOnlineChecker not called, e.g. // in most tests) leaves status untouched. func (s *DMService) PresentableStatus(subjectID int64, status string) string { if s.online != nil && !s.online(subjectID) { return db.StatusOffline } return status } // presentableDMUser applies PresentableStatus to one DM participant. func (s *DMService) presentableDMUser(u db.DMUser) db.DMUser { u.Status = s.PresentableStatus(u.ID, u.Status) return u } // presentableDMChannelInfo applies PresentableStatus to every participant of // a DM payload — Recipient (the legacy single-recipient field) and every // entry of Recipients, since a 1:1 DM's Recipient is a copy of // Recipients[0], not a shared reference. Mirrors Hub.presentableDMChannels // (ws/serve_ready.go) at the service layer so every REST response and push // event built from a db.DMChannelInfo need not duplicate the rule itself. func (s *DMService) presentableDMChannelInfo(info db.DMChannelInfo) db.DMChannelInfo { if info.Recipient.ID != 0 { info.Recipient = s.presentableDMUser(info.Recipient) } for i := range info.Recipients { info.Recipients[i] = s.presentableDMUser(info.Recipients[i]) } return info } // CreateDMResult holds the result of creating or fetching a DM channel. type CreateDMResult struct { Channel *db.Channel Created bool Recipient *db.User } // CreateDM creates or retrieves a DM channel between two users. // Validates that neither user has blocked the other. func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*CreateDMResult, error) { ctx, span := telemetry.GlobalTracer("service/dm").Start(ctx, "DMService.CreateDM", telemetry.Int64("user_id", userID), telemetry.Int64("recipient_id", recipientID), ) start := time.Now() defer func() { telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start, telemetry.String("method", "CreateDM")) span.End() }() if recipientID <= 0 { return nil, fmt.Errorf("%w: recipient_id must be positive", ErrBadRequest) } if userID == recipientID { return nil, fmt.Errorf("%w: cannot create DM with yourself", ErrBadRequest) } recipient, err := s.st.GetUserByID(ctx, recipientID) if err != nil || recipient == nil { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } // GetUserByID has no banned filter (unlike the lookups that normally // surface a user to a caller, e.g. ListMembers), and a hand-crafted // recipient_id naming a deleted/banned account otherwise creates a // dead-end DM channel plus participant rows for the tombstone user. // Gated on IsEffectivelyBanned rather than the raw flag so a lapsed // temporary ban — which login/WS already treat as not-banned — still // permits the DM once it expires. if auth.IsEffectivelyBanned(recipient) { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } blocked, err := s.st.IsEitherBlocked(ctx, userID, recipientID) if err != nil { return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err) } if blocked { return nil, fmt.Errorf("%w: cannot create DM — user is blocked", ErrForbidden) } ch, created, err := s.st.GetOrCreateDMChannel(ctx, userID, recipientID) if err != nil { slog.Error("DMService.CreateDM", "err", err) return nil, fmt.Errorf("%w: failed to create DM channel", ErrInternal) } return &CreateDMResult{ Channel: ch, Created: created, Recipient: recipient, }, nil } // ListDMs returns all open DM channels for a user. func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) { dms, err := s.st.GetUserDMChannels(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to list DMs: %v", ErrInternal, err) } // GetUserDMChannels only applies db.StatusForViewer (invisible -> // offline); apply the "no live connection" half too, see // presentableDMChannelInfo. for i := range dms { dms[i] = s.presentableDMChannelInfo(dms[i]) } return dms, nil } // CloseDMResult describes what closing a DM actually did, so the caller knows // which events to send. type CloseDMResult struct { // Left is true when the caller left a *group* DM: they are no longer a // participant, and the remaining members need to be told. Left bool // ChannelDeleted is true when the caller was the last participant of a // group and the channel row went with them. ChannelDeleted bool // RemainingParticipantIDs is who is still in the group after a leave. Empty // for a 1:1 close, which changes nothing for the other party. RemainingParticipantIDs []int64 } // CloseDM closes a DM channel for a user. // // For a 1:1 DM this hides the conversation from the caller's sidebar and // nothing more — the other party keeps their copy, and the next message from // either side re-opens it. For a group DM it is a *leave*: the caller comes // out of dm_participants, stops receiving the group's messages, and cannot // re-open it without being re-added. The two meanings share a route because // they share a gesture ("get this out of my list"), but they are different // operations and the result says which one ran. func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) (*CloseDMResult, error) { if channelID <= 0 { return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, fmt.Errorf("%w: not a participant in this DM", ErrNotFound) } isGroup, err := s.st.IsGroupDM(ctx, channelID) if err != nil { return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) } if !isGroup { if err := s.st.CloseDM(ctx, userID, channelID); err != nil { return nil, fmt.Errorf("%w: failed to close DM: %v", ErrInternal, err) } slog.Debug("DM closed", "user_id", userID, "channel_id", channelID) return &CloseDMResult{}, nil } // Read the survivors *before* the leave: after it, the caller is gone from // dm_participants and a post-hoc read could not tell "left" from "was // never in it" if the delete half-succeeded. remaining, err := s.st.GetDMParticipantIDs(ctx, channelID) if err != nil { return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) } survivors := make([]int64, 0, len(remaining)) for _, pid := range remaining { if pid != userID { survivors = append(survivors, pid) } } deleted, err := s.st.LeaveGroupDM(ctx, userID, channelID) if err != nil { return nil, fmt.Errorf("%w: failed to leave group DM: %v", ErrInternal, err) } slog.Debug("group DM left", "user_id", userID, "channel_id", channelID, "deleted", deleted) return &CloseDMResult{ Left: true, ChannelDeleted: deleted, RemainingParticipantIDs: survivors, }, nil } // ─── Group DMs ────────────────────────────────────────────────────────────── // MaxGroupDMNameLen bounds the optional group name. It matches the channel // name cap: a group DM name renders in the same sidebar row a channel name // does, so a longer one would only ever be shown clipped. const MaxGroupDMNameLen = 100 // CreateGroupDMResult holds the result of creating a group DM. type CreateGroupDMResult struct { Channel *db.Channel Participants []db.DMUser // ParticipantIDs is every member including the creator, which is the set // the caller fans dm_channel_open out to. ParticipantIDs []int64 } // CreateGroupDM creates a group DM between the caller and 2..8 other users // (3..10 total, matching db.MaxGroupDMParticipants). // // Blocks are enforced in both directions, per recipient: a user cannot pull // someone they have blocked into a room with them, and cannot use a group to // reach someone who blocked them. The check is deliberately *creation-time // only* — once the group exists, sending into it is not block-checked, because // a group DM is a shared room and silently dropping one member's messages for // one other member would make the conversation lie to everybody in it. That is // also why the composer gate on the client applies to 1:1 DMs alone. // // Unlike CreateDM this always creates a new channel: the same set of people may // want more than one group, so there is no "the group for these users" to find. func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientIDs []int64, name string) (*CreateGroupDMResult, error) { ctx, span := telemetry.GlobalTracer("service/dm").Start(ctx, "DMService.CreateGroupDM", telemetry.Int64("user_id", userID), ) start := time.Now() defer func() { telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start, telemetry.String("method", "CreateGroupDM")) span.End() }() // De-duplicate and drop the caller: a payload naming the same person twice // is a client bug, not a reason to refuse, but it must not inflate the // participant count or double-insert. seen := map[int64]bool{userID: true} unique := make([]int64, 0, len(recipientIDs)) for _, rid := range recipientIDs { if rid <= 0 { return nil, fmt.Errorf("%w: recipient_ids must be positive", ErrBadRequest) } if seen[rid] { continue } seen[rid] = true unique = append(unique, rid) } if len(unique) < 2 { return nil, fmt.Errorf("%w: a group DM needs at least 2 other users", ErrBadRequest) } if len(unique)+1 > db.MaxGroupDMParticipants { return nil, fmt.Errorf("%w: a group DM holds at most %d users", ErrBadRequest, db.MaxGroupDMParticipants) } // OC-0195 sibling: bound the raw bytes before cleanText (sanitizeToFixpoint) // runs — see cleanTextBounded's doc comment (user.go). cleanName, err := cleanTextBounded(name, MaxGroupDMNameLen, "name") if err != nil { return nil, err } for _, rid := range unique { user, err := s.st.GetUserByID(ctx, rid) if err != nil || user == nil { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } // See the matching check in CreateDM: gate on effective ban status, // not the raw flag, so a lapsed temporary ban does not wrongly // refuse the group. if auth.IsEffectivelyBanned(user) { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } } // Block-check every pair in the room, not just creator-vs-recipient: // group DMs are exempt from the send-time block gate (requireDMNotBlocked // skips groups entirely) on the strength of this creation-time check, so // two mutually-blocked recipients must not both end up in the same group // even when neither of them blocked the creator. n <= MaxGroupDMParticipants, // so the O(n^2) scan is trivial. participantIDs := append([]int64{userID}, unique...) for i := range participantIDs { for j := i + 1; j < len(participantIDs); j++ { blocked, err := s.st.IsEitherBlocked(ctx, participantIDs[i], participantIDs[j]) if err != nil { return nil, fmt.Errorf("%w: failed to check block status: %v", ErrInternal, err) } if blocked { return nil, fmt.Errorf("%w: cannot add a blocked user to a group DM", ErrForbidden) } } } ch, err := s.st.CreateGroupDMChannel(ctx, cleanName, participantIDs) if err != nil { slog.Error("DMService.CreateGroupDM", "err", err) return nil, fmt.Errorf("%w: failed to create group DM", ErrInternal) } // The channel, all dm_participants rows and all dm_open_state rows are // already committed at this point, so this read must not turn a fully- // persisted group DM into a reported failure (OC-0004): it runs // uncancellable (context.WithoutCancel) so a client disconnect landing // right after the commit can't fail it via ctx.Err(), and any other // failure is logged rather than propagated so the caller still gets a // usable result to fan dm_channel_open out from. Group DMs are // duplicate-by-design — there is no "the group for these users" to find — // so telling the caller creation failed when it actually committed only // invites a retry that creates a second, indistinguishable group. participants, err := s.st.GetDMParticipants(context.WithoutCancel(ctx), ch.ID, userID) if err != nil { slog.Error("DMService.CreateGroupDM: failed to read participants after commit", "err", err, "channel_id", ch.ID) participants = nil } // See presentableDMChannelInfo: a participant with no live connection // must read as offline, whatever users.status stored for them. for i := range participants { participants[i] = s.presentableDMUser(participants[i]) } return &CreateGroupDMResult{ Channel: ch, Participants: participants, ParticipantIDs: participantIDs, }, nil } // RenameGroupDM sets (or, with an empty name, clears) a group DM's name. // // Any participant may rename it, which is Discord's rule and the only one that // works here: a group DM has no owner column and no roles, so "who may rename" // has exactly one answer that does not require inventing an ownership model. // A 1:1 DM refuses — its name is who is in it. func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64, name string) (*db.Channel, error) { if channelID <= 0 { return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, fmt.Errorf("%w: not a participant in this DM", ErrNotFound) } isGroup, err := s.st.IsGroupDM(ctx, channelID) if err != nil { return nil, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) } if !isGroup { return nil, fmt.Errorf("%w: only group DMs can be named", ErrBadRequest) } // OC-0195 sibling: bound the raw bytes before cleanText (sanitizeToFixpoint) // runs — see cleanTextBounded's doc comment (user.go). cleanName, err := cleanTextBounded(name, MaxGroupDMNameLen, "name") if err != nil { return nil, err } if err := s.st.SetDMChannelName(ctx, channelID, cleanName); err != nil { return nil, fmt.Errorf("%w: failed to rename group DM: %v", ErrInternal, err) } ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } return ch, nil } // DMSummaryFor returns one DM's payload shape as viewerID sees it: the group // name, the participants other than them, and whether it is a group. // // Every push of a DM's membership — group create, rename, leave — goes through // it, so the shape a client receives from an event is the same one it receives // from GET /dms and from `ready`. Membership is checked here rather than by // each caller. func (s *DMService) DMSummaryFor(ctx context.Context, viewerID, channelID int64) (db.DMChannelInfo, error) { ok, err := s.st.IsDMParticipant(ctx, viewerID, channelID) if err != nil || !ok { return db.DMChannelInfo{}, fmt.Errorf("%w: not a participant in this DM", ErrNotFound) } participants, err := s.st.GetDMParticipants(ctx, channelID, viewerID) if err != nil { return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) } ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return db.DMChannelInfo{}, fmt.Errorf("%w: channel not found", ErrNotFound) } isGroup, err := s.st.IsGroupDM(ctx, channelID) if err != nil { return db.DMChannelInfo{}, fmt.Errorf("%w: failed to read DM kind: %v", ErrInternal, err) } // See presentableDMChannelInfo: this is the single place broadcastDMOpen // (group create/rename/leave refresh) and PATCH /dms/{id}'s response // build their payload from, so applying the "no live connection" rule // here covers every push of a DM's membership. return s.presentableDMChannelInfo(db.NewDMChannelInfo(channelID, ch.Name, isGroup, participants, viewerID)), nil } // SharedOneToOneDM returns the id of the 1:1 DM channel the two users share, // or ok=false when they have none. Group DMs never match, mirroring the // block-enforcement boundary (requireDMNotBlocked exempts groups). func (s *DMService) SharedOneToOneDM(ctx context.Context, userA, userB int64) (int64, bool, error) { id, ok, err := s.st.FindDMChannelIDBetween(ctx, userA, userB) if err != nil { return 0, false, fmt.Errorf("%w: failed to look up shared DM: %v", ErrInternal, err) } return id, ok, nil } // RingTargets returns the other participants of a DM the caller is in — the // people a call_ring or call_decline is addressed to. // // Ringing carries no state: a "call" in a DM *is* somebody being present in // that DM's voice channel, and the ring is a nudge to come look. That is why // this is a permission check and a fan-out list rather than a call record — // there is nothing to persist that presence does not already say, and a // persisted call would be one more thing that can be left dangling by a crash. func (s *DMService) RingTargets(ctx context.Context, userID, channelID int64) ([]int64, error) { if channelID <= 0 { return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil { return nil, fmt.Errorf("%w: failed to check DM participation: %v", ErrInternal, err) } if !ok { return nil, fmt.Errorf("%w: not a participant in this DM", ErrForbidden) } // A ring is a DM interaction like any other sink: without this check a // blocked user could still make the blocker's client ring (A-2026-08-03). // Group DMs are exempt inside requireDMNotBlocked, matching every other // sink — blocks are enforced at group creation instead. if err := requireDMNotBlocked(ctx, s.st, userID, channelID); err != nil { return nil, err } ids, err := s.st.GetDMParticipantIDs(ctx, channelID) if err != nil { return nil, fmt.Errorf("%w: failed to read DM participants: %v", ErrInternal, err) } targets := make([]int64, 0, len(ids)) for _, pid := range ids { if pid != userID { targets = append(targets, pid) } } return targets, nil }