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>
525 lines
20 KiB
Go
525 lines
20 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/owncord/server/db/dbgen"
|
|
)
|
|
|
|
// ─── DM Models ──────────────────────────────────────────────────────────────
|
|
|
|
// MaxGroupDMParticipants is the total participant ceiling for a group DM,
|
|
// creator included. Discord's is 10; matching it keeps the fan-out per message
|
|
// bounded and keeps a "group DM" from becoming an unmoderated guild.
|
|
const MaxGroupDMParticipants = 10
|
|
|
|
// DMChannelInfo holds a DM channel summary for the channel list.
|
|
type DMChannelInfo struct {
|
|
ChannelID int64 `json:"channel_id"`
|
|
// Recipient is the OTHER participant of a two-person DM. It is retained
|
|
// for backward compatibility with clients that predate group DMs and is
|
|
// only meaningful when IsGroup is false; for a group it carries the
|
|
// lowest-id other participant so such a client still renders something.
|
|
Recipient DMUser `json:"recipient"`
|
|
// Recipients is every participant except the viewer. This is the field
|
|
// group-aware clients read; for a 1:1 DM it holds exactly Recipient.
|
|
Recipients []DMUser `json:"recipients"`
|
|
// Name is the optional group name (channels.name). Always "" for a 1:1 DM
|
|
// — a two-person DM is named by who is in it, not by a title.
|
|
Name string `json:"name"`
|
|
// IsGroup is channels.is_group: decided once when the DM is created and
|
|
// never recomputed from the live participant count, so a group that people
|
|
// have left stays a group (see migration 028).
|
|
IsGroup bool `json:"is_group"`
|
|
LastMessageID *int64 `json:"last_message_id"`
|
|
LastMessage string `json:"last_message"`
|
|
LastMessageAt string `json:"last_message_at"`
|
|
UnreadCount int `json:"unread_count"`
|
|
// MentionCount is read_states.mention_count for this DM. It is not part of
|
|
// the GetUserDMChannels query — buildReady fills it from the unread map so
|
|
// a DM mention badge survives a reconnect.
|
|
MentionCount int `json:"mention_count"`
|
|
}
|
|
|
|
// DMUser is the public-facing shape for a DM participant.
|
|
type DMUser struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Avatar string `json:"avatar"`
|
|
Status string `json:"status"`
|
|
// DisplayName is the participant's chosen nickname, "" when unset. Clients
|
|
// fall back to Username, exactly as they do everywhere else.
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
// NewDMChannelInfo assembles the payload shape for one DM from its channel id,
|
|
// optional group name, group flag and full participant list (the viewer
|
|
// included), as seen by viewerID.
|
|
//
|
|
// It is the single place that answers "which of these is the recipient", so
|
|
// the REST list, the ready payload and the dm_channel_open event cannot
|
|
// disagree about a channel — a disagreement that would show up as a DM whose
|
|
// name changes depending on which event drew it.
|
|
func NewDMChannelInfo(channelID int64, name string, isGroup bool, participants []DMUser, viewerID int64) DMChannelInfo {
|
|
others := make([]DMUser, 0, len(participants))
|
|
for i := range participants {
|
|
if participants[i].ID == viewerID {
|
|
continue
|
|
}
|
|
others = append(others, participants[i])
|
|
}
|
|
info := DMChannelInfo{
|
|
ChannelID: channelID,
|
|
Recipients: others,
|
|
Name: name,
|
|
IsGroup: isGroup,
|
|
}
|
|
if len(others) > 0 {
|
|
info.Recipient = others[0]
|
|
}
|
|
return info
|
|
}
|
|
|
|
// ─── GetOrCreateDMChannel ───────────────────────────────────────────────────
|
|
|
|
// GetOrCreateDMChannel finds or creates a DM channel between two users.
|
|
// Returns the channel, whether it was newly created, and any error.
|
|
// The entire lookup+create is wrapped in a single IMMEDIATE transaction to
|
|
// prevent a TOCTOU race where two concurrent requests both see ErrNoRows and
|
|
// each create a separate DM channel for the same user pair.
|
|
func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*Channel, bool, error) {
|
|
tx, err := d.writer.BeginTx(ctx, &sql.TxOptions{
|
|
Isolation: sql.LevelSerializable,
|
|
})
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel begin tx: %w", err)
|
|
}
|
|
|
|
// Check for an existing DM channel inside the transaction.
|
|
//
|
|
// The is_group clause is what keeps group DMs out of this lookup. Without
|
|
// it a group that happens to contain both users matches the join, and
|
|
// "message Bob" would silently drop the message into a five-person group.
|
|
// It is the stored flag rather than a live participant count because a
|
|
// group people have left can have exactly two members and must still not
|
|
// answer "the DM between these two".
|
|
var existingID int64
|
|
err = tx.QueryRow(
|
|
`SELECT dp1.channel_id FROM dm_participants dp1
|
|
JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id
|
|
JOIN channels c ON c.id = dp1.channel_id
|
|
WHERE dp1.user_id = ? AND dp2.user_id = ? AND c.type = 'dm' AND c.is_group = 0
|
|
LIMIT 1`,
|
|
user1ID, user2ID,
|
|
).Scan(&existingID)
|
|
|
|
if err == nil {
|
|
// Existing channel found — ensure the calling user has it open (re-open
|
|
// is idempotent). Without this, a user who previously closed the DM would
|
|
// not see it in their sidebar after the other party re-initiates.
|
|
_, _ = tx.Exec(
|
|
`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?)`,
|
|
user1ID, existingID,
|
|
)
|
|
if commitErr := tx.Commit(); commitErr != nil {
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel commit existing: %w", commitErr)
|
|
}
|
|
ch, getErr := d.GetChannel(ctx, existingID)
|
|
if getErr != nil {
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch existing: %w", getErr)
|
|
}
|
|
if ch == nil {
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel: channel %d vanished", existingID)
|
|
}
|
|
return ch, false, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
_ = tx.Rollback()
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel lookup: %w", err)
|
|
}
|
|
|
|
// No existing DM — create one inside the same transaction.
|
|
|
|
// Insert channel with type 'dm' and empty name.
|
|
res, err := tx.Exec(
|
|
`INSERT INTO channels (name, type) VALUES ('', 'dm')`,
|
|
)
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel insert channel: %w", err)
|
|
}
|
|
channelID, err := res.LastInsertId()
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel last insert id: %w", err)
|
|
}
|
|
|
|
// Insert both participants.
|
|
_, err = tx.Exec(
|
|
`INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?), (?, ?)`,
|
|
channelID, user1ID, channelID, user2ID,
|
|
)
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel insert participants: %w", err)
|
|
}
|
|
|
|
// Open the DM for both users.
|
|
_, err = tx.Exec(
|
|
`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?), (?, ?)`,
|
|
user1ID, channelID, user2ID, channelID,
|
|
)
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel open dm: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel commit: %w", err)
|
|
}
|
|
|
|
ch, err := d.GetChannel(ctx, channelID)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch new: %w", err)
|
|
}
|
|
return ch, true, nil
|
|
}
|
|
|
|
// FindDMChannelIDBetween returns the id of the 1:1 DM channel the two users
|
|
// share, or ok=false when none exists. It never creates anything, and group
|
|
// DMs never match — blocks do not gate them, so side effects keyed off a
|
|
// block (like voice eviction) must not reach a group call.
|
|
func (d *DB) FindDMChannelIDBetween(ctx context.Context, user1ID, user2ID int64) (int64, bool, error) {
|
|
id, err := d.q.FindDMChannelIDBetween(ctx, dbgen.FindDMChannelIDBetweenParams{
|
|
UserID: user1ID,
|
|
UserID_2: user2ID,
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, fmt.Errorf("FindDMChannelIDBetween: %w", err)
|
|
}
|
|
return id, true, nil
|
|
}
|
|
|
|
// ─── GetUserDMChannels ──────────────────────────────────────────────────────
|
|
|
|
// GetUserDMChannels returns all open DM channels for a user with the full
|
|
// participant list, last message preview, and unread count. Ordered by most
|
|
// recent activity.
|
|
//
|
|
// It is two queries, not one: dm_participants holds N users per channel, so a
|
|
// single joined query returns one row per (channel, participant) pair and the
|
|
// caller has to de-duplicate anyway. Fetching the participants for every open
|
|
// DM in one extra pass keeps the cost at O(1) queries rather than the O(n) a
|
|
// per-channel participant lookup would cost.
|
|
//
|
|
// Note: the JOIN on dm_open_state already restricts results to DM channels
|
|
// (dm_open_state only contains rows for DM channels), and the explicit
|
|
// "c.type = 'dm'" predicate provides a defensive second check.
|
|
func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelInfo, error) {
|
|
rows, err := d.q.GetUserDMChannels(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetUserDMChannels: %w", err)
|
|
}
|
|
|
|
parts, err := d.q.GetDMParticipantsForUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetUserDMChannels participants: %w", err)
|
|
}
|
|
byChannel := make(map[int64][]DMUser, len(rows))
|
|
for i := range parts {
|
|
if parts[i].ID == userID {
|
|
continue
|
|
}
|
|
byChannel[parts[i].ChannelID] = append(byChannel[parts[i].ChannelID], DMUser{
|
|
ID: parts[i].ID,
|
|
Username: parts[i].Username,
|
|
Avatar: parts[i].Avatar,
|
|
Status: StatusForViewer(parts[i].Status, parts[i].ID, userID),
|
|
DisplayName: parts[i].DisplayName,
|
|
})
|
|
}
|
|
|
|
result := make([]DMChannelInfo, 0, len(rows))
|
|
for i := range rows {
|
|
recipients := byChannel[rows[i].ChannelID]
|
|
if recipients == nil {
|
|
recipients = []DMUser{}
|
|
}
|
|
info := DMChannelInfo{
|
|
ChannelID: rows[i].ChannelID,
|
|
Recipients: recipients,
|
|
Name: rows[i].Name,
|
|
IsGroup: rows[i].IsGroup != 0,
|
|
LastMessageID: rows[i].LastMessageID,
|
|
LastMessage: rows[i].LastMessage,
|
|
LastMessageAt: rows[i].LastMessageAt,
|
|
UnreadCount: int(rows[i].UnreadCount),
|
|
}
|
|
if len(recipients) > 0 {
|
|
info.Recipient = recipients[0]
|
|
}
|
|
result = append(result, info)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ─── Group DM mutation ──────────────────────────────────────────────────────
|
|
|
|
// CreateGroupDMChannel creates a new group DM channel with the given
|
|
// participants (creator included in participantIDs) and opens it for all of
|
|
// them. It always creates: unlike a 1:1 DM there is no canonical "the DM
|
|
// between these people", because the same set of people may reasonably want
|
|
// two separate groups.
|
|
//
|
|
// The whole insert runs in one transaction so a crash cannot leave a channel
|
|
// with no participants — which would be an unreachable, undeletable row.
|
|
func (d *DB) CreateGroupDMChannel(ctx context.Context, name string, participantIDs []int64) (*Channel, error) {
|
|
if len(participantIDs) < 3 {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel: need at least 3 participants, got %d", len(participantIDs))
|
|
}
|
|
tx, err := d.writer.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }() //nolint:errcheck // no-op after a successful Commit
|
|
|
|
res, err := tx.ExecContext(ctx, `INSERT INTO channels (name, type, is_group) VALUES (?, 'dm', 1)`, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel insert channel: %w", err)
|
|
}
|
|
channelID, err := res.LastInsertId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel last insert id: %w", err)
|
|
}
|
|
|
|
for _, pid := range participantIDs {
|
|
if _, err = tx.ExecContext(ctx,
|
|
`INSERT OR IGNORE INTO dm_participants (channel_id, user_id) VALUES (?, ?)`,
|
|
channelID, pid,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel insert participant: %w", err)
|
|
}
|
|
if _, err = tx.ExecContext(ctx,
|
|
`INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?)`,
|
|
pid, channelID,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel open dm: %w", err)
|
|
}
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel commit: %w", err)
|
|
}
|
|
|
|
ch, err := d.GetChannel(ctx, channelID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreateGroupDMChannel fetch new: %w", err)
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
// LeaveGroupDM removes userID from a group DM's participant list and from
|
|
// their open list, and reports whether that emptied the channel.
|
|
//
|
|
// When the last participant leaves, the channel row is deleted: a DM channel
|
|
// with no participants is reachable by nobody and would sit in the database
|
|
// forever, and its messages/attachments cascade off the channels row. Leaving
|
|
// is therefore destructive for the last leaver only — everyone else's leave is
|
|
// just a removal.
|
|
func (d *DB) LeaveGroupDM(ctx context.Context, userID, channelID int64) (deleted bool, err error) {
|
|
tx, err := d.writer.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
|
if err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }() //nolint:errcheck // no-op after a successful Commit
|
|
|
|
if _, err = tx.ExecContext(ctx,
|
|
`DELETE FROM dm_participants WHERE channel_id = ? AND user_id = ?`, channelID, userID,
|
|
); err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM remove participant: %w", err)
|
|
}
|
|
if _, err = tx.ExecContext(ctx,
|
|
`DELETE FROM dm_open_state WHERE channel_id = ? AND user_id = ?`, channelID, userID,
|
|
); err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM close dm: %w", err)
|
|
}
|
|
|
|
var remaining int
|
|
if err = tx.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM dm_participants WHERE channel_id = ?`, channelID,
|
|
).Scan(&remaining); err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM count: %w", err)
|
|
}
|
|
if remaining == 0 {
|
|
// Unlink attachments before the channel delete below: messages.channel_id
|
|
// and attachments.message_id both cascade ON DELETE (migrations/001), so
|
|
// without this the cascade destroys the attachment rows along with the
|
|
// channel — the only handle the orphan sweep (main.go's maintenance
|
|
// tick, DeleteOrphanedAttachments) has on the uploaded files, stranding
|
|
// them on disk forever. Setting message_id to NULL first turns them
|
|
// into ordinary orphaned attachments the sweep already reclaims.
|
|
if _, err = tx.ExecContext(ctx,
|
|
`UPDATE attachments SET message_id = NULL
|
|
WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)`,
|
|
channelID,
|
|
); err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM unlink attachments: %w", err)
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, channelID); err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM delete channel: %w", err)
|
|
}
|
|
deleted = true
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return false, fmt.Errorf("LeaveGroupDM commit: %w", err)
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// CountDMParticipants returns how many users are in a DM channel.
|
|
func (d *DB) CountDMParticipants(ctx context.Context, channelID int64) (int, error) {
|
|
n, err := d.q.CountDMParticipants(ctx, channelID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CountDMParticipants: %w", err)
|
|
}
|
|
return int(n), nil
|
|
}
|
|
|
|
// IsGroupDM reports whether a DM channel was created as a group. False for a
|
|
// non-existent channel and for anything that is not a DM, which is what every
|
|
// caller wants: "treat it as a 1:1" is the conservative answer.
|
|
func (d *DB) IsGroupDM(ctx context.Context, channelID int64) (bool, error) {
|
|
flag, err := d.q.IsGroupDM(ctx, channelID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("IsGroupDM: %w", err)
|
|
}
|
|
return flag != 0, nil
|
|
}
|
|
|
|
// SetDMChannelName sets the optional group name on a DM channel. The type
|
|
// predicate lives in the SQL so a stray channel id cannot rename a guild
|
|
// channel through the DM route.
|
|
func (d *DB) SetDMChannelName(ctx context.Context, channelID int64, name string) error {
|
|
if err := d.q.SetDMChannelName(ctx, dbgen.SetDMChannelNameParams{
|
|
Name: name,
|
|
ID: channelID,
|
|
}); err != nil {
|
|
return fmt.Errorf("SetDMChannelName: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetDMParticipants returns every participant of a DM channel, viewer-adjusted
|
|
// (an invisible participant reads as offline to anyone but themselves).
|
|
func (d *DB) GetDMParticipants(ctx context.Context, channelID, viewerID int64) ([]DMUser, error) {
|
|
rows, err := d.q.GetDMParticipants(ctx, channelID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetDMParticipants: %w", err)
|
|
}
|
|
out := make([]DMUser, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, DMUser{
|
|
ID: rows[i].ID,
|
|
Username: rows[i].Username,
|
|
Avatar: rows[i].Avatar,
|
|
Status: StatusForViewer(rows[i].Status, rows[i].ID, viewerID),
|
|
DisplayName: rows[i].DisplayName,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ─── OpenDM / CloseDM ──────────────────────────────────────────────────────
|
|
|
|
// OpenDM adds a DM channel to a user's open list (idempotent). The bool
|
|
// reports whether the DM was actually (re)opened by this call — false when it
|
|
// was already open, via the INSERT OR IGNORE's affected-row count — so a
|
|
// caller can distinguish a genuine open from a no-op on an already-open DM.
|
|
func (d *DB) OpenDM(ctx context.Context, userID, channelID int64) (bool, error) {
|
|
rows, err := d.q.OpenDM(ctx, dbgen.OpenDMParams{
|
|
UserID: userID,
|
|
ChannelID: channelID,
|
|
})
|
|
if err != nil {
|
|
return false, fmt.Errorf("OpenDM: %w", err)
|
|
}
|
|
return rows > 0, nil
|
|
}
|
|
|
|
// CloseDM removes a DM channel from a user's open list.
|
|
func (d *DB) CloseDM(ctx context.Context, userID, channelID int64) error {
|
|
if err := d.q.CloseDM(ctx, dbgen.CloseDMParams{
|
|
UserID: userID,
|
|
ChannelID: channelID,
|
|
}); err != nil {
|
|
return fmt.Errorf("CloseDM: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Participant helpers ────────────────────────────────────────────────────
|
|
|
|
// IsDMParticipant checks if a user is a participant in a DM channel.
|
|
func (d *DB) IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) {
|
|
_, err := d.q.IsDMParticipant(ctx, dbgen.IsDMParticipantParams{
|
|
UserID: userID,
|
|
ChannelID: channelID,
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("IsDMParticipant: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// GetUserDMChannelIDs returns the channel IDs of all DMs the user has open.
|
|
// It reads only the dm_open_state primary key, so callers that just need the
|
|
// ID set (access computation, search scoping) skip the recipient/preview/
|
|
// unread work GetUserDMChannels pays for.
|
|
func (d *DB) GetUserDMChannelIDs(ctx context.Context, userID int64) ([]int64, error) {
|
|
ids, err := d.q.GetUserDMChannelIDs(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetUserDMChannelIDs: %w", err)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// GetDMParticipantIDs returns all participant user IDs for a DM channel.
|
|
func (d *DB) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) {
|
|
ids, err := d.q.GetDMParticipantIDs(ctx, channelID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetDMParticipantIDs: %w", err)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// GetDMRecipient returns the other participant in a DM channel.
|
|
func (d *DB) GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*User, error) {
|
|
var recipientID int64
|
|
err := d.reader.QueryRowContext(ctx,
|
|
`SELECT user_id FROM dm_participants
|
|
WHERE channel_id = ? AND user_id != ?
|
|
LIMIT 1`,
|
|
channelID, requestingUserID,
|
|
).Scan(&recipientID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetDMRecipient lookup: %w", err)
|
|
}
|
|
return d.GetUserByID(ctx, recipientID)
|
|
}
|