Files
glchat/internal/store/social.go
T
grendervill 8696d85514 feat(dm): иконка групповой беседы в хранилище и API (Фаза 7)
D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.

Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.

Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
2026-09-26 16:48:01 +03:00

432 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, c.icon_file_id,
(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
iconID sql.NullInt64
lastMessageID sql.NullInt64
lastMessageAt sql.NullString
lastAuthorID sql.NullInt64
)
if err := rows.Scan(&summary.Channel.ID, &summary.GroupName, &ownerID, &createdAt, &iconID,
&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
// Иконка группы — своя (D-070), в отличие от 1:1, где показывается
// аватар собеседника (AvatarFileID).
summary.Channel.IconFileID = optionalID(iconID)
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
}