package push import ( "strings" "unicode" "glchat/internal/store" ) // Правила уведомлений: сервер повторяет логику desktop-уведомлений клиента // (web/src/lib/desktopNotifications.ts, docs/client-tauri.md §5), потому что // push уходит на устройство, где приложение не запущено и решить не может. // // Отличия от клиента ровно два и оба вынужденные: // - «окно в фокусе и комната открыта» сервер не знает, поэтому вместо этого // проверяется наличие активного соединения шлюза: если оно есть, // уведомление покажет сам клиент по своим правилам; // - «уведомления включены» на устройстве — это наличие подписки: отписался, // значит выключил. // previewLimit — сколько символов сообщения показываем в уведомлении. const previewLimit = 140 // NotificationInput — данные, от которых зависит решение об уведомлении. type NotificationInput struct { // MessageType — тип сообщения (AGENT.md 7.6). MessageType store.MessageType Content string Mentions []uint64 // AuthorID — автор сообщения (0 у системных сообщений и вебхуков). AuthorID uint64 // MeID — получатель: свои сообщения не уведомляют. MeID uint64 // IsDirect — личная беседа (в т.ч. групповая). IsDirect bool // ChannelName — имя комнаты для заголовка (у беседы — имя или пусто). ChannelName string // ChannelID — комната сообщения: по ней строится ссылка и ключ схлопывания. ChannelID uint64 // MessageID — сообщение: клик открывает комнату и подсвечивает его. MessageID uint64 // GuildID — сервер комнаты (0 — личная беседа). GuildID uint64 // GuildName — имя сервера для текста уведомления. GuildName string // AuthorName — отображаемое имя автора. AuthorName string // Online — у получателя есть активное соединение шлюза. Online bool } // NotificationFor возвращает уведомление или nil, если показывать не нужно. // Чистая функция: её поведение проверяется тестами без сети. func NotificationFor(input NotificationInput) *Payload { // Системные сообщения (входы, баны) уведомлений не требуют. if input.MessageType == store.MessageSystem { return nil } if input.AuthorID != 0 && input.AuthorID == input.MeID { return nil } mentionsMe := false for _, mentioned := range input.Mentions { if mentioned == input.MeID { mentionsMe = true break } } if !mentionsMe && !input.IsDirect { return nil } // Получатель в приложении: уведомление покажет клиент (и только если окно // неактивно) — второй раз с телефона дёргать не нужно. if input.Online { return nil } body := previewText(input.Content) if body == "" && len(input.Mentions) == 0 { return nil } if body == "" { // Вложение без текста: так же, как в клиенте. body = "Вложение" } kind := "mention" title := "Упоминание" if input.IsDirect { kind = "direct" title = "Личное сообщение" if input.ChannelName != "" { title = "Личное сообщение — " + input.ChannelName } } else if input.ChannelName != "" { title = "Упоминание в #" + input.ChannelName } channel := formatOptionalID(input.ChannelID) tag := "" url := "/app" switch { case input.GuildID != 0 && input.ChannelID != 0: url = "/app/" + formatID(input.GuildID) + "/" + channel tag = "channel-" + channel case input.ChannelID != 0: // Личная беседа: раздел друзей выбирает беседу по идентификатору. url = "/app/friends/" + channel tag = "channel-" + channel } return &Payload{ Title: title, Body: body, Kind: kind, GuildID: formatOptionalID(input.GuildID), GuildName: input.GuildName, ChannelID: channel, ChannelName: input.ChannelName, AuthorID: formatOptionalID(input.AuthorID), AuthorName: input.AuthorName, MessageID: formatOptionalID(input.MessageID), URL: url, Tag: tag, } } // previewText убирает Markdown-разметку и лишние переводы строк — та же // подготовка, что в web/src/lib/desktopNotifications.ts. func previewText(content string) string { plain := stripCodeBlocks(content) plain = stripInlineCode(plain) plain = stripLinks(plain) plain = strings.Map(func(r rune) rune { if strings.ContainsRune("*_~>#", r) { return -1 } return r }, plain) plain = collapseSpaces(plain) runes := []rune(plain) if len(runes) <= previewLimit { return plain } return strings.TrimRight(string(runes[:previewLimit-1]), " ") + "…" } // stripCodeBlocks убирает блоки ```…``` целиком, оставляя пометку. func stripCodeBlocks(content string) string { var builder strings.Builder rest := content for { start := strings.Index(rest, "```") if start < 0 { builder.WriteString(rest) return builder.String() } builder.WriteString(rest[:start]) builder.WriteString(" [код] ") end := strings.Index(rest[start+3:], "```") if end < 0 { return builder.String() } rest = rest[start+3+end+3:] } } // stripInlineCode убирает обратные кавычки, оставляя их содержимое. func stripInlineCode(content string) string { var builder strings.Builder for { start := strings.Index(content, "`") if start < 0 { builder.WriteString(content) return builder.String() } builder.WriteString(content[:start]) rest := content[start+1:] end := strings.Index(rest, "`") if end < 0 { builder.WriteString(rest) return builder.String() } builder.WriteString(rest[:end]) content = rest[end+1:] } } // stripLinks оставляет от ссылки её текст: [текст](url) → текст. func stripLinks(content string) string { var builder strings.Builder rest := content for { open := strings.Index(rest, "](") if open < 0 { builder.WriteString(rest) return builder.String() } start := strings.LastIndex(rest[:open], "[") if start < 0 { builder.WriteString(rest[:open+2]) rest = rest[open+2:] continue } builder.WriteString(rest[:start]) builder.WriteString(rest[start+1 : open]) end := strings.Index(rest[open+2:], ")") if end < 0 { return builder.String() } rest = rest[open+2+end+1:] } } // collapseSpaces схлопывает любые пробельные последовательности в один пробел. func collapseSpaces(value string) string { var builder strings.Builder space := false for _, r := range value { if unicode.IsSpace(r) { if !space && builder.Len() > 0 { builder.WriteRune(' ') } space = true continue } space = false builder.WriteRune(r) } return strings.TrimSpace(builder.String()) } // formatOptionalID печатает идентификатор в десятичном виде, пусто для нуля. func formatOptionalID(id uint64) string { if id == 0 { return "" } return formatID(id) }