2026-09-19 21:50:06 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-09-20 01:11:04 +03:00
|
|
|
"log/slog"
|
2026-09-19 21:50:06 +03:00
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-09-19 21:55:52 +03:00
|
|
|
"time"
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
|
|
|
|
|
|
"glchat/internal/permissions"
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// profilePayload — публичный профиль пользователя (AGENT.md 8.2).
|
|
|
|
|
type profilePayload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
DisplayName string `json:"display_name"`
|
|
|
|
|
Bio string `json:"bio"`
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
CustomStatus string `json:"custom_status"`
|
|
|
|
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
|
|
|
|
BannerFileID string `json:"banner_file_id,omitempty"`
|
|
|
|
|
IsInstanceAdmin bool `json:"is_instance_admin"`
|
|
|
|
|
Badges []string `json:"badges"`
|
|
|
|
|
Locale string `json:"locale"`
|
|
|
|
|
// OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2).
|
|
|
|
|
OnboardingCompleted bool `json:"onboarding_completed"`
|
2026-09-19 22:03:46 +03:00
|
|
|
// TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код
|
|
|
|
|
// при step-up и показывать ли QR при настройке (AGENT.md 7.1).
|
|
|
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
2026-09-20 01:11:04 +03:00
|
|
|
// Timezone — часовой пояс пользователя (по умолчанию МСК): по нему клиент
|
|
|
|
|
// показывает время последнего входа друзьям.
|
|
|
|
|
Timezone string `json:"timezone"`
|
|
|
|
|
// LastSeenAt — когда пользователя видели последний раз (для друзей).
|
|
|
|
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
2026-09-19 22:03:46 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
|
|
|
|
// аутентификации (секрет в БД зашифрован, наружу отдаём только факт).
|
|
|
|
|
func (s *Server) profileFromUser(ctx context.Context, user *store.User, includePrivate bool) profilePayload {
|
|
|
|
|
payload := profileFromUser(user, includePrivate)
|
|
|
|
|
if s.auth != nil {
|
|
|
|
|
if enabled, err := s.auth.TOTPEnabled(ctx, user.ID); err == nil {
|
|
|
|
|
payload.TOTPEnabled = enabled
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return payload
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
|
|
|
|
payload := profilePayload{
|
|
|
|
|
ID: formatSnowflake(user.ID),
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
DisplayName: user.DisplayName,
|
|
|
|
|
Bio: user.Bio,
|
|
|
|
|
Status: user.Status,
|
|
|
|
|
CustomStatus: user.CustomStatus,
|
|
|
|
|
IsInstanceAdmin: user.IsInstanceAdmin,
|
|
|
|
|
Badges: user.Badges,
|
|
|
|
|
OnboardingCompleted: user.OnboardingCompletedAt != nil,
|
|
|
|
|
}
|
|
|
|
|
if payload.Badges == nil {
|
|
|
|
|
payload.Badges = []string{}
|
|
|
|
|
}
|
|
|
|
|
if user.AvatarFileID != nil {
|
|
|
|
|
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
|
|
|
|
|
}
|
|
|
|
|
if user.BannerFileID != nil {
|
|
|
|
|
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
|
|
|
|
|
}
|
|
|
|
|
if includePrivate {
|
|
|
|
|
payload.Locale = user.Locale
|
|
|
|
|
}
|
2026-09-20 01:11:04 +03:00
|
|
|
payload.Timezone = user.Timezone
|
|
|
|
|
if payload.Timezone == "" {
|
|
|
|
|
payload.Timezone = "Europe/Moscow"
|
|
|
|
|
}
|
|
|
|
|
if user.LastSeenAt != nil {
|
|
|
|
|
payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339)
|
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type meOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
User profilePayload `json:"user"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type userOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
User profilePayload `json:"user"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type okOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
OK bool `json:"ok"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newOKOutput() *okOutput {
|
|
|
|
|
output := &okOutput{}
|
|
|
|
|
output.Body.OK = true
|
|
|
|
|
return output
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type updateProfileInput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
|
|
|
|
|
Bio *string `json:"bio,omitempty" maxLength:"500"`
|
|
|
|
|
Status *string `json:"status,omitempty" enum:"online,idle,dnd,invisible"`
|
|
|
|
|
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
|
|
|
|
|
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
|
|
|
|
|
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
2026-09-20 01:11:04 +03:00
|
|
|
Timezone *string `json:"timezone,omitempty" maxLength:"64"`
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type changePasswordInput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
CurrentPassword string `json:"current_password" minLength:"1"`
|
|
|
|
|
NewPassword string `json:"new_password" minLength:"1"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type onboardingInput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
|
|
|
|
|
Bio *string `json:"bio,omitempty" maxLength:"500"`
|
|
|
|
|
Locale *string `json:"locale,omitempty" enum:"ru,en"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:55:52 +03:00
|
|
|
// securityEventPayload — событие безопасности без служебных метаданных.
|
|
|
|
|
type securityEventPayload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
IP string `json:"ip,omitempty"`
|
|
|
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type securityEventListOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Events []securityEventPayload `json:"events"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
type guildSummary struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
IconFileID string `json:"icon_file_id,omitempty"`
|
|
|
|
|
OwnerID string `json:"owner_id"`
|
|
|
|
|
IsMain bool `json:"is_main"`
|
|
|
|
|
MemberCount int `json:"member_count"`
|
|
|
|
|
MyRoleIDs []string `json:"my_role_ids"`
|
|
|
|
|
MyPermissions []string `json:"my_permissions"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type guildListOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
Guilds []guildSummary `json:"guilds"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registerUserRoutes описывает ручки профиля, онбординга и списка серверов.
|
|
|
|
|
func (s *Server) registerUserRoutes(api huma.API) {
|
|
|
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "getCurrentUser",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/users/@me",
|
|
|
|
|
Summary: "Текущий пользователь",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, _ *struct{}) (*meOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
output := &meOutput{}
|
2026-09-19 22:03:46 +03:00
|
|
|
output.Body.User = s.profileFromUser(ctx, user, true)
|
2026-09-19 21:50:06 +03:00
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "updateCurrentUser",
|
|
|
|
|
Method: http.MethodPatch,
|
|
|
|
|
Path: "/users/@me",
|
|
|
|
|
Summary: "Изменить профиль",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *updateProfileInput) (*meOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
params := store.UpdateUserParams{
|
|
|
|
|
DisplayName: input.Body.DisplayName,
|
|
|
|
|
Bio: input.Body.Bio,
|
|
|
|
|
Status: input.Body.Status,
|
|
|
|
|
CustomStatus: input.Body.CustomStatus,
|
|
|
|
|
CustomStatusEmoji: input.Body.CustomStatusEmoji,
|
|
|
|
|
Locale: input.Body.Locale,
|
|
|
|
|
}
|
2026-09-20 01:11:04 +03:00
|
|
|
if input.Body.Timezone != nil {
|
|
|
|
|
if _, err := time.LoadLocation(*input.Body.Timezone); err != nil {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown timezone")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
if input.Body.DisplayName != nil {
|
|
|
|
|
trimmed := strings.TrimSpace(*input.Body.DisplayName)
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "display name must not be empty")
|
|
|
|
|
}
|
|
|
|
|
params.DisplayName = &trimmed
|
|
|
|
|
}
|
|
|
|
|
updated, err := s.store.UpdateUser(ctx, user.ID, params)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
2026-09-20 01:11:04 +03:00
|
|
|
if input.Body.Timezone != nil {
|
|
|
|
|
if err := s.store.SetTimezone(ctx, user.ID, *input.Body.Timezone); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
updated, err = s.store.GetUser(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3),
|
|
|
|
|
// а друзья — обновление присутствия (AGENT.md 7.16).
|
2026-09-19 21:50:06 +03:00
|
|
|
s.dispatchUserUpdate(updated)
|
2026-09-20 01:11:04 +03:00
|
|
|
if input.Body.Status != nil || input.Body.CustomStatus != nil {
|
|
|
|
|
s.dispatchPresenceUpdate(ctx, updated)
|
|
|
|
|
}
|
2026-09-19 21:50:06 +03:00
|
|
|
output := &meOutput{}
|
2026-09-19 22:03:46 +03:00
|
|
|
output.Body.User = s.profileFromUser(ctx, updated, true)
|
2026-09-19 21:50:06 +03:00
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "changePassword",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/users/@me/password",
|
|
|
|
|
Summary: "Сменить пароль (требует step-up)",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *changePasswordInput) (*okOutput, error) {
|
|
|
|
|
user, session, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "completeOnboarding",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/users/@me/onboarding/complete",
|
|
|
|
|
Summary: "Завершить первичную настройку",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *onboardingInput) (*meOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
params := store.UpdateUserParams{
|
|
|
|
|
Bio: input.Body.Bio,
|
|
|
|
|
Locale: input.Body.Locale,
|
|
|
|
|
}
|
|
|
|
|
if input.Body.DisplayName != nil {
|
|
|
|
|
if trimmed := strings.TrimSpace(*input.Body.DisplayName); trimmed != "" {
|
|
|
|
|
params.DisplayName = &trimmed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.store.UpdateUser(ctx, user.ID, params); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.MarkOnboardingCompleted(ctx, user.ID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
updated, err := s.store.GetUser(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.dispatchUserUpdate(updated)
|
|
|
|
|
output := &meOutput{}
|
2026-09-19 22:03:46 +03:00
|
|
|
output.Body.User = s.profileFromUser(ctx, updated, true)
|
2026-09-19 21:50:06 +03:00
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 21:55:52 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "listSecurityEvents",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/users/@me/security-events",
|
|
|
|
|
Summary: "Последние события безопасности аккаунта (входы, смена пароля, 2FA)",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
Limit int `query:"limit" default:"20" minimum:"1" maximum:"100"`
|
|
|
|
|
},
|
|
|
|
|
) (*securityEventListOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
events, err := s.store.ListSecurityEvents(ctx, user.ID, input.Limit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &securityEventListOutput{}
|
|
|
|
|
output.Body.Events = make([]securityEventPayload, 0, len(events))
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
output.Body.Events = append(output.Body.Events, securityEventPayload{
|
|
|
|
|
ID: formatSnowflake(event.ID),
|
|
|
|
|
Type: event.Type,
|
|
|
|
|
IP: event.IP,
|
|
|
|
|
UserAgent: event.UserAgent,
|
|
|
|
|
CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "getUser",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/users/{user_id}",
|
|
|
|
|
Summary: "Публичный профиль пользователя",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*userOutput, error) {
|
|
|
|
|
if _, _, err := requireUser(ctx); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
id, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
user, err := s.store.GetUser(ctx, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &userOutput{}
|
2026-09-19 22:03:46 +03:00
|
|
|
output.Body.User = s.profileFromUser(ctx, user, false)
|
2026-09-19 21:50:06 +03:00
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "listMyGuilds",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/users/@me/guilds",
|
|
|
|
|
Summary: "Серверы текущего пользователя",
|
|
|
|
|
Tags: []string{"Users"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, _ *struct{}) (*guildListOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
summaries := make([]guildSummary, 0, len(guilds))
|
|
|
|
|
for _, guild := range guilds {
|
|
|
|
|
resolved, err := s.guildPermissions(ctx, guild.ID, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if !resolved.Has(permissions.ViewGuild) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
summary, err := s.guildSummary(ctx, guild, user.ID, resolved)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
summaries = append(summaries, summary)
|
|
|
|
|
}
|
|
|
|
|
output := &guildListOutput{}
|
|
|
|
|
output.Body.Guilds = summaries
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// guildSummary собирает краткую карточку сервера для списка.
|
|
|
|
|
func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uint64, resolved permissions.Resolved) (guildSummary, error) {
|
|
|
|
|
summary := guildSummary{
|
|
|
|
|
ID: formatSnowflake(guild.ID),
|
|
|
|
|
Name: guild.Name,
|
|
|
|
|
OwnerID: formatSnowflake(guild.OwnerID),
|
|
|
|
|
IsMain: guild.IsMain,
|
|
|
|
|
MyRoleIDs: []string{},
|
|
|
|
|
MyPermissions: permissions.Names(resolved.Guild),
|
|
|
|
|
}
|
|
|
|
|
if guild.IconFileID != nil {
|
|
|
|
|
summary.IconFileID = formatSnowflake(*guild.IconFileID)
|
|
|
|
|
}
|
|
|
|
|
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return guildSummary{}, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
for _, roleID := range roleIDs {
|
|
|
|
|
summary.MyRoleIDs = append(summary.MyRoleIDs, formatSnowflake(roleID))
|
|
|
|
|
}
|
|
|
|
|
count, err := s.store.CountGuildMembers(ctx, guild.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return guildSummary{}, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
summary.MemberCount = count
|
|
|
|
|
return summary, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 01:11:04 +03:00
|
|
|
// dispatchPresenceUpdate рассылает статус пользователя его друзьям.
|
|
|
|
|
func (s *Server) dispatchPresenceUpdate(ctx context.Context, user *store.User) {
|
|
|
|
|
if s.gateway == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
friends, err := s.store.ListRelationships(ctx, user.ID, store.RelationshipFriend)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.WarnContext(ctx, "failed to list friends for presence", slog.Any("error", err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
"user_id": formatSnowflake(user.ID),
|
|
|
|
|
"status": user.Status,
|
|
|
|
|
"visible_status": visibleStatus(user.Status, user.LastSeenAt),
|
|
|
|
|
"custom_status": user.CustomStatus,
|
|
|
|
|
}
|
|
|
|
|
if user.LastSeenAt != nil {
|
|
|
|
|
payload["last_seen_at"] = user.LastSeenAt.UTC().Format(timeLayout)
|
|
|
|
|
}
|
|
|
|
|
for _, friend := range friends {
|
|
|
|
|
s.gateway.SendToUser(friend.UserID, "PRESENCE_UPDATE", payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
|
|
|
|
|
func (s *Server) dispatchUserUpdate(user *store.User) {
|
|
|
|
|
if s.gateway == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{
|
2026-09-19 22:03:46 +03:00
|
|
|
"user": s.profileFromUser(context.Background(), user, true),
|
2026-09-19 21:50:06 +03:00
|
|
|
})
|
|
|
|
|
}
|