2026-09-20 01:11:04 +03:00
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// RelationshipType — тип связи между пользователями (AGENT.md 7.8).
|
|
|
|
|
type RelationshipType string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
RelationshipFriend RelationshipType = "friend"
|
|
|
|
|
RelationshipOutgoing RelationshipType = "outgoing"
|
|
|
|
|
RelationshipIncoming RelationshipType = "incoming"
|
|
|
|
|
RelationshipBlocked RelationshipType = "blocked"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Relationship — связь «пользователь → другой пользователь».
|
|
|
|
|
type Relationship struct {
|
|
|
|
|
UserID uint64
|
|
|
|
|
TargetID uint64
|
|
|
|
|
Type RelationshipType
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetRelationship записывает связь в одну сторону.
|
|
|
|
|
func (s *Store) SetRelationship(ctx context.Context, userID, targetID uint64, kind RelationshipType) error {
|
|
|
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
|
|
|
INSERT INTO relationships (user_id, target_id, type, created_at, updated_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
ON CONFLICT (user_id, target_id) DO UPDATE SET type = excluded.type, updated_at = excluded.updated_at`,
|
|
|
|
|
int64(userID), int64(targetID), string(kind), s.Now(), s.Now())
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RemoveRelationship удаляет связь в одну сторону.
|
|
|
|
|
func (s *Store) RemoveRelationship(ctx context.Context, userID, targetID uint64) error {
|
|
|
|
|
_, err := s.writer.ExecContext(ctx,
|
|
|
|
|
`DELETE FROM relationships WHERE user_id = ? AND target_id = ?`, int64(userID), int64(targetID))
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetRelationship возвращает связь или ErrNotFound.
|
|
|
|
|
func (s *Store) GetRelationship(ctx context.Context, userID, targetID uint64) (*Relationship, error) {
|
|
|
|
|
var (
|
|
|
|
|
relation Relationship
|
|
|
|
|
createdAt string
|
|
|
|
|
)
|
|
|
|
|
err := s.reader.QueryRowContext(ctx, `
|
|
|
|
|
SELECT user_id, target_id, type, created_at FROM relationships
|
|
|
|
|
WHERE user_id = ? AND target_id = ?`, int64(userID), int64(targetID)).
|
|
|
|
|
Scan(&relation.UserID, &relation.TargetID, &relation.Type, &createdAt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, mapError(err)
|
|
|
|
|
}
|
|
|
|
|
relation.CreatedAt = parseTimestamp(createdAt)
|
|
|
|
|
return &relation, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListRelationships возвращает связи пользователя указанного типа вместе с
|
|
|
|
|
// профилями собеседников (для списка друзей).
|
|
|
|
|
func (s *Store) ListRelationships(ctx context.Context, userID uint64, kind RelationshipType) ([]RelationshipProfile, error) {
|
|
|
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
|
|
|
SELECT r.target_id, r.created_at, u.username, u.display_name, u.avatar_file_id,
|
|
|
|
|
u.status, u.custom_status, u.badges_json, u.last_seen_at, u.timezone, u.is_instance_admin
|
|
|
|
|
FROM relationships r
|
|
|
|
|
JOIN users u ON u.id = r.target_id
|
|
|
|
|
WHERE r.user_id = ? AND r.type = ? AND u.deleted_at IS NULL
|
|
|
|
|
ORDER BY u.username`, int64(userID), string(kind))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
profiles := make([]RelationshipProfile, 0, 8)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
profile, err := scanRelationshipProfile(rows)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
profiles = append(profiles, *profile)
|
|
|
|
|
}
|
|
|
|
|
return profiles, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RelationshipProfile — связь вместе с публичным профилем собеседника.
|
|
|
|
|
type RelationshipProfile struct {
|
|
|
|
|
UserID uint64
|
|
|
|
|
Username string
|
|
|
|
|
DisplayName string
|
|
|
|
|
AvatarFileID *uint64
|
|
|
|
|
Status string
|
|
|
|
|
CustomStatus string
|
|
|
|
|
Badges []string
|
|
|
|
|
IsInstanceAdmin bool
|
|
|
|
|
LastSeenAt *time.Time
|
|
|
|
|
Timezone string
|
|
|
|
|
Since time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CountFriends возвращает число друзей пользователя.
|
|
|
|
|
func (s *Store) CountFriends(ctx context.Context, userID uint64) (int, error) {
|
|
|
|
|
var count int
|
|
|
|
|
err := s.reader.QueryRowContext(ctx,
|
|
|
|
|
`SELECT COUNT(*) FROM relationships WHERE user_id = ? AND type = 'friend'`,
|
|
|
|
|
int64(userID)).Scan(&count)
|
|
|
|
|
return count, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DMParticipants возвращает участников личной беседы.
|
|
|
|
|
func (s *Store) DMParticipants(ctx context.Context, channelID uint64) ([]uint64, error) {
|
|
|
|
|
rows, err := s.reader.QueryContext(ctx,
|
|
|
|
|
`SELECT user_id FROM dm_participants WHERE channel_id = ? ORDER BY joined_at`, int64(channelID))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
users := make([]uint64, 0, 2)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var userID uint64
|
|
|
|
|
if err := rows.Scan(&userID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
users = append(users, userID)
|
|
|
|
|
}
|
|
|
|
|
return users, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IsDMParticipant проверяет участие пользователя в личной беседе.
|
|
|
|
|
func (s *Store) IsDMParticipant(ctx context.Context, channelID, userID uint64) (bool, error) {
|
|
|
|
|
var exists int
|
|
|
|
|
err := s.reader.QueryRowContext(ctx,
|
|
|
|
|
`SELECT 1 FROM dm_participants WHERE channel_id = ? AND user_id = ?`,
|
|
|
|
|
int64(channelID), int64(userID)).Scan(&exists)
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateDMChannel создаёт личную беседу двух пользователей.
|
|
|
|
|
func (s *Store) CreateDMChannel(ctx context.Context, firstID, secondID uint64) (*Channel, error) {
|
|
|
|
|
channelID := s.NextID()
|
|
|
|
|
if err := s.InTx(ctx, func(tx *sql.Tx) error {
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
|
|
|
INSERT INTO channels (id, guild_id, type, name, position, created_at)
|
|
|
|
|
VALUES (?, NULL, 'dm', '', 0, ?)`, int64(channelID), s.Now()); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, userID := range []uint64{firstID, secondID} {
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
|
|
|
INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?)`,
|
|
|
|
|
int64(channelID), int64(userID), s.Now()); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return nil, mapError(err)
|
|
|
|
|
}
|
|
|
|
|
return s.GetChannel(ctx, channelID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FindDMChannel ищет существующую личную беседу двух пользователей.
|
|
|
|
|
func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*Channel, error) {
|
|
|
|
|
var channelID uint64
|
|
|
|
|
err := s.reader.QueryRowContext(ctx, `
|
|
|
|
|
SELECT p1.channel_id
|
|
|
|
|
FROM dm_participants p1
|
|
|
|
|
JOIN dm_participants p2 ON p2.channel_id = p1.channel_id
|
|
|
|
|
JOIN channels c ON c.id = p1.channel_id
|
|
|
|
|
WHERE p1.user_id = ? AND p2.user_id = ? AND c.type = 'dm'
|
|
|
|
|
ORDER BY p1.channel_id LIMIT 1`, int64(firstID), int64(secondID)).Scan(&channelID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, mapError(err)
|
|
|
|
|
}
|
|
|
|
|
return s.GetChannel(ctx, channelID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DMChannelSummary — личная беседа со собеседником и последним сообщением.
|
|
|
|
|
type DMChannelSummary struct {
|
|
|
|
|
Channel Channel
|
|
|
|
|
RecipientID uint64
|
|
|
|
|
RecipientName string
|
|
|
|
|
RecipientLogin string
|
|
|
|
|
AvatarFileID *uint64
|
|
|
|
|
Status string
|
|
|
|
|
LastSeenAt *time.Time
|
|
|
|
|
Timezone string
|
|
|
|
|
LastMessageID uint64
|
|
|
|
|
LastMessageAt *time.Time
|
|
|
|
|
LastAuthorID *uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListDMChannels возвращает личные беседы пользователя.
|
|
|
|
|
func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
|
|
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
|
|
|
SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status,
|
|
|
|
|
u.last_seen_at, u.timezone,
|
|
|
|
|
(SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
|
|
|
|
(SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1),
|
|
|
|
|
(SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1)
|
|
|
|
|
FROM dm_participants p
|
|
|
|
|
JOIN channels c ON c.id = p.channel_id AND c.type = 'dm'
|
|
|
|
|
JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id
|
|
|
|
|
JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL
|
|
|
|
|
WHERE p.user_id = ?
|
|
|
|
|
ORDER BY c.id DESC`, int64(userID))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
channels := make([]DMChannelSummary, 0, 8)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var (
|
|
|
|
|
summary DMChannelSummary
|
|
|
|
|
createdAt string
|
|
|
|
|
avatarID sql.NullInt64
|
|
|
|
|
lastSeenAt sql.NullString
|
|
|
|
|
lastMessageID sql.NullInt64
|
|
|
|
|
lastMessageAt sql.NullString
|
|
|
|
|
lastAuthorID sql.NullInt64
|
|
|
|
|
)
|
|
|
|
|
if err := rows.Scan(&summary.Channel.ID, &createdAt, &summary.RecipientID, &summary.RecipientName,
|
|
|
|
|
&summary.RecipientLogin, &avatarID, &summary.Status, &lastSeenAt, &summary.Timezone,
|
|
|
|
|
&lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if lastSeenAt.Valid {
|
|
|
|
|
value := parseTimestamp(lastSeenAt.String)
|
|
|
|
|
summary.LastSeenAt = &value
|
|
|
|
|
}
|
|
|
|
|
summary.Channel.Type = ChannelDM
|
|
|
|
|
summary.Channel.CreatedAt = parseTimestamp(createdAt)
|
|
|
|
|
summary.AvatarFileID = optionalID(avatarID)
|
|
|
|
|
if lastMessageID.Valid {
|
|
|
|
|
summary.LastMessageID = uint64(lastMessageID.Int64)
|
|
|
|
|
}
|
|
|
|
|
if lastMessageAt.Valid {
|
|
|
|
|
value := parseTimestamp(lastMessageAt.String)
|
|
|
|
|
summary.LastMessageAt = &value
|
|
|
|
|
}
|
|
|
|
|
summary.LastAuthorID = optionalID(lastAuthorID)
|
|
|
|
|
channels = append(channels, summary)
|
|
|
|
|
}
|
|
|
|
|
return channels, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TouchLastSeen обновляет время последней активности пользователя.
|
|
|
|
|
func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error {
|
|
|
|
|
_, err := s.writer.ExecContext(ctx,
|
|
|
|
|
`UPDATE users SET last_seen_at = ? WHERE id = ? AND deleted_at IS NULL`, s.Now(), int64(userID))
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetTimezone сохраняет выбранный часовой пояс пользователя.
|
|
|
|
|
func (s *Store) SetTimezone(ctx context.Context, userID uint64, timezone string) error {
|
|
|
|
|
_, err := s.writer.ExecContext(ctx,
|
|
|
|
|
`UPDATE users SET timezone = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
|
|
|
|
timezone, s.Now(), int64(userID))
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SearchUsersByUsername ищет пользователей по началу username (для поиска друзей).
|
|
|
|
|
func (s *Store) SearchUsersByUsername(ctx context.Context, query string, excludeID uint64, limit int) ([]User, error) {
|
|
|
|
|
if limit <= 0 || limit > 50 {
|
|
|
|
|
limit = 20
|
|
|
|
|
}
|
|
|
|
|
// ESCAPE обязателен: логины содержат «_», который в LIKE — подстановочный
|
|
|
|
|
// символ, поэтому экранируем его и сообщаем об этом SQLite.
|
|
|
|
|
rows, err := s.reader.QueryContext(ctx, `
|
2026-09-22 18:59:53 +03:00
|
|
|
SELECT `+userColumns+` FROM `+userFrom+`
|
|
|
|
|
WHERE u.deleted_at IS NULL AND u.id <> ? AND u.username_lower LIKE ? ESCAPE '\'
|
|
|
|
|
ORDER BY u.username_lower LIMIT ?`,
|
2026-09-20 01:11:04 +03:00
|
|
|
int64(excludeID), escapeLike(strings.ToLower(query))+"%", limit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
users := make([]User, 0, limit)
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
user, err := scanUser(rows)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
users = append(users, *user)
|
|
|
|
|
}
|
|
|
|
|
return users, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// escapeLike экранирует служебные символы LIKE, чтобы поиск не превращался в шаблон.
|
|
|
|
|
func escapeLike(value string) string {
|
|
|
|
|
replacer := []struct{ from, to string }{
|
|
|
|
|
{"\\", "\\\\"},
|
|
|
|
|
{"%", "\\%"},
|
|
|
|
|
{"_", "\\_"},
|
|
|
|
|
}
|
|
|
|
|
result := value
|
|
|
|
|
for _, rule := range replacer {
|
|
|
|
|
result = strings.ReplaceAll(result, rule.from, rule.to)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func scanRelationshipProfile(scanner interface{ Scan(...any) error }) (*RelationshipProfile, error) {
|
|
|
|
|
var (
|
|
|
|
|
profile RelationshipProfile
|
|
|
|
|
avatarID sql.NullInt64
|
|
|
|
|
badges string
|
|
|
|
|
lastSeen sql.NullString
|
|
|
|
|
isAdmin int
|
|
|
|
|
createdAt string
|
|
|
|
|
)
|
|
|
|
|
err := scanner.Scan(&profile.UserID, &createdAt, &profile.Username, &profile.DisplayName, &avatarID,
|
|
|
|
|
&profile.Status, &profile.CustomStatus, &badges, &lastSeen, &profile.Timezone, &isAdmin)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, mapError(err)
|
|
|
|
|
}
|
|
|
|
|
profile.AvatarFileID = optionalID(avatarID)
|
|
|
|
|
profile.IsInstanceAdmin = isAdmin == 1
|
|
|
|
|
if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil {
|
|
|
|
|
profile.Badges = nil
|
|
|
|
|
}
|
|
|
|
|
if lastSeen.Valid {
|
|
|
|
|
value := parseTimestamp(lastSeen.String)
|
|
|
|
|
profile.LastSeenAt = &value
|
|
|
|
|
}
|
|
|
|
|
profile.Since = parseTimestamp(createdAt)
|
|
|
|
|
return &profile, nil
|
|
|
|
|
}
|