236 lines
7.9 KiB
Go
236 lines
7.9 KiB
Go
|
|
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)
|
||
|
|
}
|