mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
`Server/go.mod` declared `github.com/owncord/server` while the public repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is no `owncord` GitHub org and no vanity-import host serving go-import metadata for it — so every import line in the tree named a location that does not exist. It compiles because a main module's own path is never fetched, which is exactly why it went unnoticed. The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) — is wrong here, and provably so. Six of the 722 occurrences are not imports at all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern), `telemetry/metrics.go:17-19` (three OTel instrumentation-scope names), `invariants/syncutil_locks.go:73` (a diagnostic message), and `invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go fixture). An import rewriter touches none of them, and the compiler cannot see any of them either. Done as one scripted substitution over `git ls-files`, anchored on the full `github.com/owncord/server` string. The anchor matters: `owncord-server` is a different identifier — the OTel `service.name` (`config/config.go`, `telemetry/telemetry_otel.go`) and the GHCR image name (`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern would have moved it. It is untouched: 10 occurrences across 9 files, before and after. 350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files, plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`, `docs/architecture/server.md:5`, and the ledger pair (`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of `FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in `Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard` rule keys on the module path, so import grouping is not configured anywhere). The plan's blast-radius estimate missed one thing, and it is the one that would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase letter, so in the 36 files where a module-local import shares a contiguous group with a third-party one, the module's imports must move above `github.com/go-chi/...`. `gofmt -l` was clean before the substitution and listed exactly 36 files after it; `gofmt -w` on those 36 restores it to clean. `gofmt` is an enforced gate — the `formatters` block in `Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails Lint. Verified: both directions, and the line accounting is exact. Every added line in this diff contains the new module path (728) and every removed line contains the old one (728); the count of changed lines containing neither is **zero**, so the gofmt re-sort moved module-path lines only and touched no third-party import. The residual check (`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns exactly two hits, both deliberately out of scope: the RL-13 row in `docs/audit-2026-08-23-repository-layout.md` and the measurement row in this phase's own plan. The compiler-invisible half was proven by reverting *only* `api/main_test.go:20` to the old path on the otherwise-renamed tree: `go build ./...` and `go vet ./api/` both still pass — they see nothing wrong — while `go test ./api/` FAILS, because the runtime function name now carries the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and none was needed). All four build-tag variants compile; `go vet ./...`, `go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass; `go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...` passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel) runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally against Go 1.26 because the packaged binary cannot load a 1.26 config — reports **0 issues**. `go run ./cmd/genprotocol` leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the rename does not reach the generated protocol constants. `npx prettier --check .` and `node .superpowers/render-ledger.mjs --check` pass. Not included: `docs/audit-2026-08-23-repository-layout.md` and `docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they are the audit row and the measurement that motivated this change, and rewriting them would erase the record of what was measured. They are why the residual check needs a two-path allowance rather than being empty; that allowance is stated above rather than hidden in a pathspec. `telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package that does not exist; the substitution carried the dead path forward verbatim as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because correcting a real observability bug inside a mechanical rename would hide it in a 350-file diff. It needs its own item. No `go.work`, no second module, and no vanity-import host was set up — the new path resolves against the real repository, but nothing imports this module as a library, so `go get` reachability was not exercised either way. Refs RL-13, L-12
525 lines
20 KiB
Go
525 lines
20 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/J3vb/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)
|
|
}
|