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) } }