feat: chat notification
This commit is contained in:
@@ -266,7 +266,8 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
||||||
chatRepo := repository.NewChatRepo(s.pg)
|
chatRepo := repository.NewChatRepo(s.pg)
|
||||||
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
|
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)
|
s.chatH = handler.NewChatHandler(chatSvc)
|
||||||
|
|
||||||
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
|
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"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/realtime"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/unfurl"
|
"github.com/prabhupadasays/spsays-bff/pkg/unfurl"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -69,14 +72,18 @@ type ChatService struct {
|
|||||||
posts *repository.PostRepo
|
posts *repository.PostRepo
|
||||||
// push, prefs and users deliver out-of-band message alerts. All optional:
|
// push, prefs and users deliver out-of-band message alerts. All optional:
|
||||||
// nil means no push, never a failed send.
|
// nil means no push, never a failed send.
|
||||||
push *PushSender
|
push *PushSender
|
||||||
prefs *repository.NotificationPrefsRepo
|
prefs *repository.NotificationPrefsRepo
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
rt *realtime.SoketiClient
|
// mail nudges someone who has never used this thread and isn't connected.
|
||||||
r2 *repository.R2
|
// Optional: nil disables first-contact email and nothing else.
|
||||||
links *unfurl.Fetcher
|
mail mailer.Mailer
|
||||||
cache *repository.Redis
|
frontendURL string
|
||||||
logger *zap.Logger
|
rt *realtime.SoketiClient
|
||||||
|
r2 *repository.R2
|
||||||
|
links *unfurl.Fetcher
|
||||||
|
cache *repository.Redis
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChatService(
|
func NewChatService(
|
||||||
@@ -86,6 +93,8 @@ func NewChatService(
|
|||||||
push *PushSender,
|
push *PushSender,
|
||||||
prefs *repository.NotificationPrefsRepo,
|
prefs *repository.NotificationPrefsRepo,
|
||||||
users *repository.UserRepo,
|
users *repository.UserRepo,
|
||||||
|
mail mailer.Mailer,
|
||||||
|
frontendURL string,
|
||||||
rt *realtime.SoketiClient,
|
rt *realtime.SoketiClient,
|
||||||
r2 *repository.R2,
|
r2 *repository.R2,
|
||||||
cache *repository.Redis,
|
cache *repository.Redis,
|
||||||
@@ -93,7 +102,8 @@ func NewChatService(
|
|||||||
) *ChatService {
|
) *ChatService {
|
||||||
return &ChatService{
|
return &ChatService{
|
||||||
chat: chat, blocks: blocks, posts: posts,
|
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,
|
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) {
|
if c.MutedUntil != nil && c.MutedUntil.After(now) {
|
||||||
continue
|
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)
|
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.
|
// pushCopy builds the lock-screen text.
|
||||||
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
|
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
|
||||||
sender := "Someone"
|
sender := "Someone"
|
||||||
|
|||||||
@@ -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
|
// no-ops — these tests are about the rules, and none of those services are
|
||||||
// reachable from a test run.
|
// reachable from a test run.
|
||||||
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg),
|
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
|
// chatUserWithHandle returns both identifiers: ids for authoring, handles for
|
||||||
@@ -1928,7 +1928,7 @@ func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) {
|
|||||||
|
|
||||||
pg := &repository.Postgres{Pool: pool}
|
pg := &repository.Postgres{Pool: pool}
|
||||||
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
|
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
|
// measure returns the statement count for reading a thread carrying
|
||||||
// `shares` distinct shared posts.
|
// `shares` distinct shared posts.
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ var PrefCategories = map[string][]string{
|
|||||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||||
"community": {NotifDiscipleJoined},
|
"community": {NotifDiscipleJoined},
|
||||||
// Push only: in-app delivery for chat is the inbox itself, and there is
|
// Push, plus email for FIRST contact only — see ChatService.emailFirstContact.
|
||||||
// no such thing as an emailed chat message.
|
// In-app delivery for chat is the inbox itself, so it isn't offered here.
|
||||||
"messages": {NotifChatMessage},
|
"messages": {NotifChatMessage},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user