feat: chat notification

This commit is contained in:
Krispire Admin
2026-08-30 11:13:33 +05:30
parent a2d22ed829
commit ac92bc2cf3
4 changed files with 95 additions and 14 deletions
+2 -1
View File
@@ -266,7 +266,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
pushSender, notifPrefsRepo, userRepo, rtClient, r2, s.redis, logger)
pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL,
rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
+89 -9
View File
@@ -1,12 +1,14 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/url"
"path"
"strings"
@@ -14,6 +16,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"github.com/prabhupadasays/spsays-bff/pkg/unfurl"
"go.uber.org/zap"
@@ -69,14 +72,18 @@ type ChatService struct {
posts *repository.PostRepo
// push, prefs and users deliver out-of-band message alerts. All optional:
// nil means no push, never a failed send.
push *PushSender
prefs *repository.NotificationPrefsRepo
users *repository.UserRepo
rt *realtime.SoketiClient
r2 *repository.R2
links *unfurl.Fetcher
cache *repository.Redis
logger *zap.Logger
push *PushSender
prefs *repository.NotificationPrefsRepo
users *repository.UserRepo
// mail nudges someone who has never used this thread and isn't connected.
// Optional: nil disables first-contact email and nothing else.
mail mailer.Mailer
frontendURL string
rt *realtime.SoketiClient
r2 *repository.R2
links *unfurl.Fetcher
cache *repository.Redis
logger *zap.Logger
}
func NewChatService(
@@ -86,6 +93,8 @@ func NewChatService(
push *PushSender,
prefs *repository.NotificationPrefsRepo,
users *repository.UserRepo,
mail mailer.Mailer,
frontendURL string,
rt *realtime.SoketiClient,
r2 *repository.R2,
cache *repository.Redis,
@@ -93,7 +102,8 @@ func NewChatService(
) *ChatService {
return &ChatService{
chat: chat, blocks: blocks, posts: posts,
push: push, prefs: prefs, users: users, rt: rt, r2: r2,
push: push, prefs: prefs, users: users, mail: mail,
frontendURL: frontendURL, rt: rt, r2: r2,
links: unfurl.New(), cache: cache, logger: logger,
}
}
@@ -1926,6 +1936,14 @@ func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversa
if c.MutedUntil != nil && c.MutedUntil.After(now) {
continue
}
// First contact: push only reaches a device that has already opted
// in, which someone who has never used chat almost certainly hasn't.
// Email is the one channel that reaches them — and only the FIRST
// message qualifies, since after that the thread exists and push
// carries it.
if msg.Seq == 1 && !s.online(ctx, c.UserID) {
s.emailFirstContact(ctx, c.UserID, senderID, conv)
}
s.pushTo(ctx, conv, c.UserID, title, body)
}
}
@@ -1962,6 +1980,68 @@ func (s *ChatService) pushTo(ctx context.Context, conv *model.Conversation, user
})
}
// emailFirstContact tells someone a conversation has been opened with them,
// when nothing else would reach them.
//
// Deliberately says nothing about the message itself: an email subject line is
// the least private surface in the product, and "you have a message" is enough
// to bring someone back.
func (s *ChatService) emailFirstContact(ctx context.Context, recipientID, senderID string, conv *model.Conversation) {
if s.mail == nil || s.users == nil {
return
}
// The same preference as chat push, on its email channel — so turning
// Messages off silences both rather than leaving one leaking through.
if s.prefs != nil {
if off, err := s.prefs.DisabledSet(ctx, recipientID); err == nil &&
prefOff(off, NotifChatMessage, repository.ChannelEmail) {
return
}
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil || recipient == nil ||
recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// OAuth-only accounts may have no address on file.
return
}
sender, err := s.users.FindByID(ctx, senderID)
if err != nil || sender == nil {
return
}
var buf bytes.Buffer
if err := firstContactEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"SenderName": actorDisplay(sender),
"SenderHandle": sender.Handle,
"ChatURL": s.frontendURL + "/chats/" + conv.ID,
}); err != nil {
s.logger.Warn("chat: first-contact email render failed", zap.Error(err))
return
}
subject := actorDisplay(sender) + " sent you a message — Prabhupāda Says"
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
s.logger.Warn("chat: first-contact email failed",
zap.String("recipient", recipientID), zap.Error(err))
}
}
// Styled to match the follow-request email so the two read as one product.
var firstContactEmailTpl = template.Must(template.New("firstContact").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.SenderName}}</strong> (@{{.SenderHandle}}) has sent you a message.
Reply to them on Prabhupāda Says.</p>
<p style="margin:1.6em 0">
<a href="{{.ChatURL}}" style="background:#9a6b2f;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;display:inline-block">Open the conversation</a>
</p>
<p style="font-size:14px;color:#7a6a55">You are receiving this because someone started a conversation with you.
You can turn these off under Settings → Notifications.</p>
</div>`))
// pushCopy builds the lock-screen text.
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
sender := "Someone"
+2 -2
View File
@@ -44,7 +44,7 @@ func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *reposit
// no-ops — these tests are about the rules, and none of those services are
// reachable from a test run.
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg),
nil, nil, nil, nil, nil, nil, zap.NewNop()), chat, pg
nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop()), chat, pg
}
// chatUserWithHandle returns both identifiers: ids for authoring, handles for
@@ -1928,7 +1928,7 @@ func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) {
pg := &repository.Postgres{Pool: pool}
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
repository.NewPostRepo(pg), nil, nil, nil, nil, nil, nil, zap.NewNop())
repository.NewPostRepo(pg), nil, nil, nil, nil, "", nil, nil, nil, zap.NewNop())
// measure returns the statement count for reading a thread carrying
// `shares` distinct shared posts.
+2 -2
View File
@@ -65,8 +65,8 @@ var PrefCategories = map[string][]string{
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
"community": {NotifDiscipleJoined},
// Push only: in-app delivery for chat is the inbox itself, and there is
// no such thing as an emailed chat message.
// Push, plus email for FIRST contact only — see ChatService.emailFirstContact.
// In-app delivery for chat is the inbox itself, so it isn't offered here.
"messages": {NotifChatMessage},
}