3c7b8278ce
- usePermissions ORs current user roles + @everyone only (not all server roles) - cache myRolesByServer; load on active server; refresh after self role edit - gate/notify @everyone and @channel; plain @username push; special mention UI - refresh FEATURE_PARITY (DMs exist; drop stale critical gaps) - README production deploy notes dumpster.service - unit tests for permission bits and broadcast mention tokens
208 lines
5.4 KiB
Go
208 lines
5.4 KiB
Go
package message
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
|
)
|
|
|
|
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
|
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
|
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
|
|
|
// MentionHandler dispatches push notifications for @mentions.
|
|
type MentionHandler struct {
|
|
db *sql.DB
|
|
push *push.Handler
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logger) *MentionHandler {
|
|
return &MentionHandler{
|
|
db: db,
|
|
push: pushHandler,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// hasBroadcastToken reports whether content contains @everyone / @channel as a whole token.
|
|
func hasBroadcastToken(content, token string) bool {
|
|
// token like "everyone" or "channel" (without @)
|
|
needle := "@" + token
|
|
idx := 0
|
|
for {
|
|
i := strings.Index(strings.ToLower(content[idx:]), needle)
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
i += idx
|
|
end := i + len(needle)
|
|
if end >= len(content) || !isUsernameChar(rune(content[end])) {
|
|
return true
|
|
}
|
|
idx = end
|
|
}
|
|
}
|
|
|
|
func isUsernameChar(r rune) bool {
|
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
|
|
}
|
|
|
|
// ParseAndNotify parses message content for mentions and sends push notifications.
|
|
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
|
mentionedUsers := make(map[string]bool)
|
|
|
|
// Discord-style ID mentions
|
|
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
|
if len(match) > 1 {
|
|
mentionedUsers[match[1]] = true
|
|
}
|
|
}
|
|
|
|
// Get channel info for notification
|
|
var serverID, channelName string
|
|
err := m.db.QueryRowContext(ctx,
|
|
`SELECT server_id, name FROM channels WHERE id = $1`, channelID,
|
|
).Scan(&serverID, &channelName)
|
|
if err != nil {
|
|
m.logger.Error("failed to get channel info for mentions", "error", err)
|
|
return
|
|
}
|
|
|
|
// Get author info
|
|
var authorName string
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(display_name, username) FROM users WHERE id = $1`, authorID,
|
|
).Scan(&authorName)
|
|
if err != nil {
|
|
m.logger.Error("failed to get author info for mentions", "error", err)
|
|
return
|
|
}
|
|
|
|
// Truncate content for notification
|
|
notifContent := content
|
|
if len(notifContent) > 200 {
|
|
notifContent = notifContent[:200] + "..."
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"title": authorName + " in #" + channelName,
|
|
"body": notifContent,
|
|
"url": "/channels/" + channelID,
|
|
}
|
|
|
|
// @everyone / @channel — fan out to server members (permission gated at create).
|
|
// ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
|
|
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
|
|
rows, err := m.db.QueryContext(ctx,
|
|
`SELECT m.user_id FROM members m
|
|
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
|
WHERE m.server_id = $1 AND m.user_id != $2
|
|
AND (ns.level IS NULL OR ns.level != 'none')`,
|
|
serverID, authorID, channelID,
|
|
)
|
|
if err != nil {
|
|
m.logger.Error("failed to query server members for broadcast mention", "error", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var userID string
|
|
if err := rows.Scan(&userID); err != nil {
|
|
continue
|
|
}
|
|
go m.push.SendPush(ctx, userID, payload)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Plain @username mentions (what the frontend actually stores)
|
|
usernames := make([]string, 0)
|
|
seenUsernames := make(map[string]bool)
|
|
for _, match := range plainUsernameMention.FindAllStringSubmatch(content, -1) {
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
u := strings.ToLower(match[1])
|
|
if u == "everyone" || u == "channel" || u == "here" {
|
|
continue
|
|
}
|
|
if !seenUsernames[u] {
|
|
seenUsernames[u] = true
|
|
usernames = append(usernames, match[1])
|
|
}
|
|
}
|
|
if len(usernames) > 0 {
|
|
// Resolve usernames that are members of this server.
|
|
for _, uname := range usernames {
|
|
var uid string
|
|
err := m.db.QueryRowContext(ctx, `
|
|
SELECT u.id FROM users u
|
|
JOIN members m ON m.user_id = u.id
|
|
WHERE m.server_id = $1 AND LOWER(u.username) = LOWER($2)
|
|
`, serverID, uname).Scan(&uid)
|
|
if err == nil {
|
|
mentionedUsers[uid] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Role mentions
|
|
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
|
if len(roleMatches) > 0 {
|
|
for _, match := range roleMatches {
|
|
if len(match) > 1 {
|
|
roleID := match[1]
|
|
rows, err := m.db.QueryContext(ctx,
|
|
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
|
roleID, authorID,
|
|
)
|
|
if err != nil {
|
|
m.logger.Error("failed to query role members", "error", err, "role_id", roleID)
|
|
continue
|
|
}
|
|
for rows.Next() {
|
|
var userID string
|
|
if err := rows.Scan(&userID); err != nil {
|
|
continue
|
|
}
|
|
mentionedUsers[userID] = true
|
|
}
|
|
rows.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
delete(mentionedUsers, authorID)
|
|
|
|
for userID := range mentionedUsers {
|
|
var status string
|
|
err := m.db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
|
).Scan(&status)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if status == "dnd" {
|
|
continue
|
|
}
|
|
|
|
var level string
|
|
err = m.db.QueryRowContext(ctx,
|
|
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
|
userID, channelID,
|
|
).Scan(&level)
|
|
if err == nil && level == "none" {
|
|
continue
|
|
}
|
|
|
|
go m.push.SendPush(ctx, userID, payload)
|
|
}
|
|
}
|