|
|
|
@@ -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"
|
|
|
|
|