feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13): - главного сервера как точки входа больше нет: новичок начинает с пустым списком серверов, вход — только по приглашению или созданием своего; - друзья: поиск по логину с экранированием LIKE, заявки (POST /users/@me/relationships), принятие, удаление/отклонение, списки friends/ incoming/outgoing/blocked; - личные беседы: POST /users/@me/channels (идемпотентно), GET /users/@me/channels со собеседником, статусом и последним сообщением; сообщения в DM работают через общие ручки комнат, доступ — только участникам (посторонний получает 404, события в Gateway тоже фильтруются); - присутствие: last_seen_at обновляется при активности, «невидимка» и простой дольше двух минут выглядят как офлайн, смена статуса рассылает PRESENCE_UPDATE друзьям; - READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение); - профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me, загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл удаляется с диска; - тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
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
|
||||
}
|
||||
+22
-2
@@ -31,6 +31,10 @@ type User struct {
|
||||
DeletedAt *time.Time
|
||||
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
|
||||
OnboardingCompletedAt *time.Time
|
||||
// LastSeenAt — время последней активности (для статусов друзей).
|
||||
LastSeenAt *time.Time
|
||||
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
||||
@@ -48,7 +52,7 @@ type CreateUserParams struct {
|
||||
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
||||
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
|
||||
onboarding_completed_at`
|
||||
onboarding_completed_at, last_seen_at, timezone`
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||
if params.ID == 0 {
|
||||
@@ -235,6 +239,14 @@ func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAvatar убирает ссылку на файл аватара.
|
||||
func (s *Store) ClearAvatar(ctx context.Context, id uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE users SET avatar_file_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
s.Now(), int64(id))
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
|
||||
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
@@ -273,13 +285,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
updatedAt string
|
||||
deletedAt sql.NullString
|
||||
onboardingAt sql.NullString
|
||||
lastSeenAt sql.NullString
|
||||
emailEncrypted string
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
||||
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
||||
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
||||
&onboardingAt,
|
||||
&onboardingAt, &lastSeenAt, &user.Timezone,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
@@ -306,6 +319,13 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
value := parseTimestamp(onboardingAt.String)
|
||||
user.OnboardingCompletedAt = &value
|
||||
}
|
||||
if lastSeenAt.Valid {
|
||||
value := parseTimestamp(lastSeenAt.String)
|
||||
user.LastSeenAt = &value
|
||||
}
|
||||
if user.Timezone == "" {
|
||||
user.Timezone = "Europe/Moscow"
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user