package server import ( "context" "log/slog" "glchat/internal/push" "glchat/internal/store" ) // Web Push о новых сообщениях (AGENT.md 7.16, Фаза 7). // // Правила те же, что у нативных уведомлений desktop-обёртки // (web/src/lib/desktopNotifications.ts): упоминания и личные беседы, без своих // и системных сообщений. Отличие одно: сервер не знает про фокус окна, поэтому // вместо «комната открыта и окно активно» проверяется наличие соединения // шлюза — если пользователь в приложении, уведомление покажет клиент. // pushSender — то, что серверу нужно от Web Push. Интерфейс позволяет // подменить отправителя в тестах: доставка идёт в фоне и наружу не видна. type pushSender interface { Enabled() bool PublicKey() string Enqueue(subscription store.PushSubscription, payload push.Payload) bool } // displayNameOf — имя автора для уведомления: display_name, а при пустом — // логин (у новых аккаунтов display_name заполняется логином, но вебхуки и // ручные данные могут его не иметь). func displayNameOf(user *store.User) string { if user == nil { return "" } if user.DisplayName != "" { return user.DisplayName } return user.Username } // notifyPushAboutMessage ставит уведомления в очередь push-сервиса. // // authorName — отображаемое имя автора: в личной беседе 1:1 оно и есть имя // «комнаты» (отдельного названия у неё нет). func (s *Server) notifyPushAboutMessage(ctx context.Context, message *store.Message, channel *store.Channel, authorName string) { if !s.push.Enabled() || channel == nil { return } recipients := s.pushRecipients(ctx, message, channel) if len(recipients) == 0 { return } authorID := uint64(0) if message.AuthorID != nil { authorID = *message.AuthorID } guildID := uint64(0) guildName := "" if channel.GuildID != nil { guildID = *channel.GuildID if guild, err := s.store.GetGuild(ctx, guildID); err == nil { guildName = guild.Name } } channelName := s.pushChannelName(channel, authorName) for _, recipient := range recipients { if recipient == 0 || recipient == authorID { continue } // Получатель в приложении: уведомление покажет клиент, а не телефон. if s.gateway.IsUserOnline(recipient) { continue } // Лимит на получателя: массовые упоминания не должны превращаться в // поток push-сообщений (AGENT.md 8.6). if allowed, _ := s.pushNotifyLimiter.Allow(pushLimitKey(recipient)); !allowed { continue } payload := push.NotificationFor(push.NotificationInput{ MessageType: message.Type, Content: message.Content, Mentions: message.Mentions, AuthorID: authorID, MeID: recipient, IsDirect: channel.GuildID == nil, ChannelName: channelName, ChannelID: message.ChannelID, MessageID: message.ID, GuildID: guildID, GuildName: guildName, AuthorName: authorName, }) if payload == nil { continue } subscriptions, err := s.store.ListPushSubscriptions(ctx, recipient) if err != nil { s.logger.WarnContext(ctx, "push subscriptions read failed", slog.Any("error", err)) continue } for i := range subscriptions { s.push.Enqueue(subscriptions[i], *payload) } } } // pushRecipients перечисляет, кому адресовано уведомление: в комнате сервера — // упомянутым, в личной беседе — всем остальным участникам. func (s *Server) pushRecipients(ctx context.Context, message *store.Message, channel *store.Channel) []uint64 { if channel.GuildID != nil { return message.Mentions } participants, err := s.store.DMParticipants(ctx, channel.ID) if err != nil { s.logger.WarnContext(ctx, "dm participants read failed", slog.Any("error", err)) return nil } return participants } // pushChannelName подбирает название для заголовка уведомления: у групповой // беседы — её имя, у 1:1 своего имени нет, поэтому берём имя собеседника // (автора сообщения). func (s *Server) pushChannelName(channel *store.Channel, authorName string) string { if channel.GuildID == nil && channel.DMOwnerID == nil { return authorName } return channel.Name }