f9464130cd
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
428 lines
15 KiB
Go
428 lines
15 KiB
Go
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 — личная беседа со собеседником и последним сообщением.
|
||
// Для групповой беседы (AGENT.md 7.8) вместо собеседника заполняются Name,
|
||
// MemberCount и Participants, а RecipientID остаётся нулевым.
|
||
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
|
||
// IsGroup — участников больше двух; GroupName — имя беседы.
|
||
IsGroup bool
|
||
GroupName string
|
||
MemberCount int
|
||
Participants []DMParticipant
|
||
OwnerID *uint64
|
||
}
|
||
|
||
// ListDMChannels возвращает личные беседы пользователя: и 1:1, и групповые
|
||
// (AGENT.md 7.8). Беседа считается групповой, если в ней больше двух
|
||
// участников; с двумя — обычная личная, с одним — не показывается вовсе.
|
||
func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||
channels, err := s.listDirectChannels(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
groups, err := s.listGroupChannels(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return append(channels, groups...), nil
|
||
}
|
||
|
||
// listDirectChannels возвращает беседы ровно с двумя участниками.
|
||
func (s *Store) listDirectChannels(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 = ?
|
||
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) = 2
|
||
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()
|
||
}
|
||
|
||
// listGroupChannels возвращает групповые беседы: имя, число участников и
|
||
// последнее сообщение. Участники подтягиваются отдельным запросом — групп у
|
||
// пользователя немного, а список нужен интерфейсу для карточки беседы.
|
||
func (s *Store) listGroupChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) {
|
||
rows, err := s.reader.QueryContext(ctx, `
|
||
SELECT c.id, c.name, c.dm_owner_id, c.created_at,
|
||
(SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id),
|
||
(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'
|
||
WHERE p.user_id = ?
|
||
AND (SELECT COUNT(*) FROM dm_participants everyone WHERE everyone.channel_id = c.id) > 2
|
||
ORDER BY c.id DESC`, int64(userID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
groups := make([]DMChannelSummary, 0, 4)
|
||
for rows.Next() {
|
||
var (
|
||
summary DMChannelSummary
|
||
ownerID sql.NullInt64
|
||
createdAt string
|
||
lastMessageID sql.NullInt64
|
||
lastMessageAt sql.NullString
|
||
lastAuthorID sql.NullInt64
|
||
)
|
||
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt,
|
||
&summary.MemberCount, &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil {
|
||
return nil, err
|
||
}
|
||
summary.IsGroup = true
|
||
summary.OwnerID = optionalID(ownerID)
|
||
summary.Channel.Type = ChannelDM
|
||
summary.Channel.Name = summary.GroupName
|
||
summary.Channel.DMOwnerID = summary.OwnerID
|
||
summary.Channel.CreatedAt = parseTimestamp(createdAt)
|
||
if lastMessageID.Valid {
|
||
summary.LastMessageID = uint64(lastMessageID.Int64)
|
||
}
|
||
if lastMessageAt.Valid {
|
||
value := parseTimestamp(lastMessageAt.String)
|
||
summary.LastMessageAt = &value
|
||
}
|
||
summary.LastAuthorID = optionalID(lastAuthorID)
|
||
groups = append(groups, summary)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
for index := range groups {
|
||
participants, err := s.DMParticipantProfiles(ctx, groups[index].Channel.ID, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
groups[index].Participants = participants
|
||
}
|
||
return groups, nil
|
||
}
|
||
|
||
// 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 `+userFrom+`
|
||
WHERE u.deleted_at IS NULL AND u.id <> ? AND u.username_lower LIKE ? ESCAPE '\'
|
||
ORDER BY u.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
|
||
}
|