Files
glchat/internal/store/social.go
T

428 lines
15 KiB
Go
Raw Normal View History

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
}