mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs(plans): phased remediation plan for the 2026-08-19 audit Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per finding group, statuses updated in place as phases land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * test(client): give the renderWindow-breaker test its own timeout (audit F-5) 30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a loaded runner; the test timed out once under CI-like load and passes in isolation, so it now carries an explicit 20s budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10) schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL (matching 030's rebuild), index inventory rewritten from cumulative migration state, writer/reader pool split described, default-roles table made a consistent post-migration snapshot, dbgen preamble updated. protocol.md: DM chat events documented as sequenced/ring-buffered/replayable (they are), plugin_broadcast seq flipped to Yes, retry_after claim removed (no WS error carries it), the five enforced-but-documented-as-None rate limits added (channel_focus, mark_read, call_decline, chat_command, ping), E2EE announce/offer budgets corrected incl. the per-target inner cap, BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/ roles field lists completed, member_join top-level status documented. api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP limiter and host:port livekit_url, error-code table now matches emitted codes (INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap exemptions listed, identity_public_key documented on PATCH /users/me, plugin endpoints' plain-text errors + X-Plugin-Runtime header documented, /health 503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply restart-conflict 409s added. Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes (buildReady comment, e2e spec-count comments, logctx stray word, three plan status headers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16) Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6 session-cap eviction failure in CreateSession (db/auth_queries.go), and the channel_focus read-state write failure (service/channel.go) all discarded their errors with no trace — a brute-force lockout could silently fail to survive a restart. In-memory behavior is unchanged (warn-and-continue); the lockout write paths are pinned by tests mirroring OC-0061's load-path test. The session-cap and read-state sites are log-only additions on seams the existing suites already exercise on the success path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1) The block gate ran only at voice_join and voluntary voice_token_refresh, so a blocked user already in the shared 1:1 DM call kept their session indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser now severs the call through the dmVoiceEvictor capability handleCloseDM already exercises, using a new find-only FindDMChannelIDBetween lookup (sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler tests: shared-DM eviction, no-DM no-op, group-only no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): close the role-reassignment/handshake race (audit F-2) A role reassignment landing mid-handshake was invisible for the socket's whole life: both handshake paths resolved permissions from the auth-time c.user snapshot, revokeUnreadableChannels early-returns for a user not yet in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a reconnect replaced the client. Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the resume-fallback path re-reads the role once more after registerNow and runs the revocation pass when it moved, so the reassignment-vs-registration orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the live client immediately before acting, mirroring RefreshChannelVisibility. Pinned by four tests driving real WS handshakes through the existing race hooks plus a new pre-register/pre-act hook pair; ws suite green under the default and deadlock builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * refactor(client): remove the inert replay-dedup machinery (audit F-6) The server writes auth_ok before the replay burst, so replayDedup — created on socket-open and cleared when auth_ok is processed — could never be active for a real replayed frame, and the dispatcher's isReplaying() unread gates never fired. Their no-op behavior is the correct behavior (a buffer/db resume has no ready payload, so replayed frames must count as unread), so the machinery, the gates, and the misleading comments are removed rather than repaired. The pinning tests injected replay frames in an order a spec-compliant server never produces; they are replaced by a test pinning the real contract (frames after auth_ok are dispatched verbatim; duplicate handling belongs to the stores). Client suite green: 5036/5036. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs(plans): mark remediation phases 1-6 done Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): nolint the context-less revoke call golangci-lint flags revokeUnreadableChannels takes no context by design (admin HubBroadcaster interface); annotate the one call site inside a ctx-taking function, matching the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ --------- Co-authored-by: Claude <noreply@anthropic.com>
416 lines
16 KiB
Go
416 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/telemetry"
|
|
)
|
|
|
|
// DMService handles direct message channel operations.
|
|
type DMService struct {
|
|
st Store
|
|
}
|
|
|
|
// NewDMService creates a DMService.
|
|
func NewDMService(st Store) *DMService {
|
|
return &DMService{st: st}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
cleanName := cleanText(name)
|
|
if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen {
|
|
return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
cleanName := cleanText(name)
|
|
if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen {
|
|
return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen)
|
|
}
|
|
|
|
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)
|
|
}
|
|
return 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
|
|
}
|