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, ` SELECT `+userColumns+` FROM users WHERE deleted_at IS NULL AND id <> ? AND username_lower LIKE ? ESCAPE '\' ORDER BY username_lower LIMIT ?`, 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 }