feat: add stickers
This commit is contained in:
@@ -418,6 +418,9 @@ type SendRequest struct {
|
||||
PreviewURL *string
|
||||
// Entity is something else in the app — a post — shared as a card.
|
||||
Entity *EntityAttachment
|
||||
// StickerKey sends a piece of reward artwork. Ownership is checked
|
||||
// server-side; the key alone proves nothing.
|
||||
StickerKey *string
|
||||
// Quote is a passage shared from the library, rendered as a quote the
|
||||
// reader can tap to return to the exact phrase.
|
||||
Quote *QuoteAttachment
|
||||
@@ -458,7 +461,8 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
||||
body := strings.TrimSpace(req.Body)
|
||||
// A photo or a shared passage with no caption is an ordinary message; only
|
||||
// a message with nothing at all in it is empty.
|
||||
if body == "" && len(req.Attachments) == 0 && req.Quote == nil && req.Entity == nil {
|
||||
if body == "" && len(req.Attachments) == 0 && req.Quote == nil &&
|
||||
req.Entity == nil && req.StickerKey == nil {
|
||||
return nil, ErrChatEmptyBody
|
||||
}
|
||||
if len([]rune(body)) > maxBodyRunes {
|
||||
@@ -493,6 +497,12 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
||||
return s.hydrateSent(ctx, userID, conversationID, msg), nil
|
||||
}
|
||||
|
||||
if req.StickerKey != nil {
|
||||
if err := s.attachSticker(ctx, conversationID, userID, msg, *req.StickerKey); err != nil {
|
||||
s.logger.Warn("chat: sticker refused",
|
||||
zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil {
|
||||
s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
@@ -1624,6 +1634,40 @@ func (s *ChatService) Message(ctx context.Context, userID, conversationID, messa
|
||||
return s.withAttachments(ctx, userID, msg)
|
||||
}
|
||||
|
||||
// Stickers lists what the caller can send: sādhana rewards they earned, plus
|
||||
// stickers they saved from a chat.
|
||||
func (s *ChatService) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) {
|
||||
return s.chat.Stickers(ctx, userID)
|
||||
}
|
||||
|
||||
// SaveSticker keeps one someone sent, after checking it really is on that
|
||||
// message and the caller is in the thread.
|
||||
func (s *ChatService) SaveSticker(ctx context.Context, userID, messageID, key string) error {
|
||||
return s.chat.SaveSticker(ctx, userID, key, messageID)
|
||||
}
|
||||
|
||||
// attachSticker stores the artwork on a message.
|
||||
//
|
||||
// Ownership is checked here, not trusted from the client: the key comes over
|
||||
// the wire, and without this anyone could send any sticker in the catalog by
|
||||
// guessing its key. The image and name are denormalized because the catalog
|
||||
// is static art — there is nothing viewer-dependent to re-resolve on read.
|
||||
func (s *ChatService) attachSticker(ctx context.Context, conversationID, userID string, msg *model.Message, key string) error {
|
||||
sticker, err := s.chat.Sticker(ctx, userID, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entityType := model.EntitySticker
|
||||
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
|
||||
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
|
||||
Kind: model.AttachmentSticker, EntityType: &entityType,
|
||||
EntityID: &sticker.Key, ImageURL: &sticker.ImageURL, Title: &sticker.Name,
|
||||
}); err != nil {
|
||||
s.logger.Warn("chat: attach sticker failed", zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Typing broadcasts that someone is composing.
|
||||
//
|
||||
// Never persisted and never pushed: a typing indicator is only meaningful to
|
||||
|
||||
@@ -2459,3 +2459,153 @@ func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) {
|
||||
t.Error("a retried reply lost its quote")
|
||||
}
|
||||
}
|
||||
|
||||
// grantSticker gives a user a reward the way sādhana does, returning its key.
|
||||
func grantSticker(t *testing.T, pg *repository.Postgres, userID, key string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var rewardID string
|
||||
if err := pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO sadhna_rewards (key, name, image_url)
|
||||
VALUES ($1, $2, 'https://example.invalid/art.png')
|
||||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`, key, "Sticker "+key).Scan(&rewardID); err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(ctx, `DELETE FROM sadhna_rewards WHERE id = $1`, rewardID)
|
||||
})
|
||||
if userID != "" {
|
||||
// milestone is unique per user; a large random-ish one avoids clashing
|
||||
// with any real streak rows.
|
||||
if _, err := pg.Pool.Exec(ctx, `
|
||||
INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
userID, rewardID, 900000+len(key)); err != nil {
|
||||
t.Fatalf("grant reward: %v", err)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func stickerAttachment(t *testing.T, msgs []model.Message) *model.Attachment {
|
||||
t.Helper()
|
||||
for i := range msgs {
|
||||
for j := range msgs[i].Attachments {
|
||||
if msgs[i].Attachments[j].Kind == model.AttachmentSticker {
|
||||
return &msgs[i].Attachments[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A sticker key is not a capability: sending one you never earned must fail,
|
||||
// or the whole catalog is available to anyone who can guess a key.
|
||||
func TestStickers_OnlySendWhatYouOwn(t *testing.T) {
|
||||
svc, _, pg := testChatService(t)
|
||||
ctx := context.Background()
|
||||
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
|
||||
conv, err := svc.OpenDM(ctx, a, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupConv(t, pg, conv.ID)
|
||||
|
||||
mine := grantSticker(t, pg, a, "sticker_owned_"+a[:8])
|
||||
// Exists in the catalog, granted to nobody.
|
||||
unearned := grantSticker(t, pg, "", "sticker_unearned_"+a[:8])
|
||||
|
||||
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &mine}); err != nil {
|
||||
t.Fatalf("sending an earned sticker: %v", err)
|
||||
}
|
||||
msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10)
|
||||
att := stickerAttachment(t, msgs)
|
||||
if att == nil {
|
||||
t.Fatal("an earned sticker did not attach")
|
||||
}
|
||||
if att.ImageURL == nil || *att.ImageURL == "" || att.EntityID == nil || *att.EntityID != mine {
|
||||
t.Errorf("attachment = %+v, want the artwork and its key", att)
|
||||
}
|
||||
|
||||
// Not earned, not saved: the message sends, but carries no sticker.
|
||||
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
|
||||
Body: "trying", StickerKey: &unearned,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10)
|
||||
for i := range msgs {
|
||||
for j := range msgs[i].Attachments {
|
||||
if att := msgs[i].Attachments[j]; att.Kind == model.AttachmentSticker &&
|
||||
att.EntityID != nil && *att.EntityID == unearned {
|
||||
t.Error("a sticker the sender never earned was attached")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saving is how a sticker spreads — but only from a message actually sent to
|
||||
// you, in a thread you are actually in.
|
||||
func TestStickers_SaveFromChat(t *testing.T) {
|
||||
svc, _, pg := testChatService(t)
|
||||
ctx := context.Background()
|
||||
a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
|
||||
conv, err := svc.OpenDM(ctx, a, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupConv(t, pg, conv.ID)
|
||||
|
||||
key := grantSticker(t, pg, a, "sticker_shared_"+a[:8])
|
||||
sent, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// B has not earned it, so it isn't in their drawer yet.
|
||||
before, _ := svc.Stickers(ctx, b)
|
||||
for _, s := range before {
|
||||
if s.Key == key {
|
||||
t.Fatal("B has the sticker before saving it")
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
|
||||
t.Fatalf("saving a sticker sent to you: %v", err)
|
||||
}
|
||||
after, _ := svc.Stickers(ctx, b)
|
||||
var found *model.Sticker
|
||||
for i := range after {
|
||||
if after[i].Key == key {
|
||||
found = &after[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("saved sticker is not in B's drawer")
|
||||
}
|
||||
if !found.Saved {
|
||||
t.Error("a sticker from a chat should be marked as saved, not earned")
|
||||
}
|
||||
// B can now send it themselves.
|
||||
if _, err := svc.Send(ctx, b, conv.ID, SendRequest{StickerKey: &key}); err != nil {
|
||||
t.Errorf("B should be able to send a saved sticker: %v", err)
|
||||
}
|
||||
// Saving twice is harmless.
|
||||
if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil {
|
||||
t.Errorf("second save should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
// Someone outside the thread cannot save from it, even knowing both ids.
|
||||
if err := svc.SaveSticker(ctx, outsider, sent.ID, key); !errors.Is(err, repository.ErrStickerNotOwned) {
|
||||
t.Errorf("outsider save: err = %v, want ErrStickerNotOwned", err)
|
||||
}
|
||||
|
||||
// The sender's own drawer still reports it as earned, not saved.
|
||||
mine, _ := svc.Stickers(ctx, a)
|
||||
for _, s := range mine {
|
||||
if s.Key == key && s.Saved {
|
||||
t.Error("an earned sticker is being reported as saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func testRewardRepo(t *testing.T) (*repository.RewardRepo, *repository.Postgres) {
|
||||
t.Helper()
|
||||
uri := os.Getenv("POSTGRES_URI")
|
||||
if uri == "" {
|
||||
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
|
||||
uri = os.Getenv("POSTGRES_URI")
|
||||
}
|
||||
if uri == "" {
|
||||
t.Skip("POSTGRES_URI not set")
|
||||
}
|
||||
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Skipf("postgres unreachable: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
return repository.NewRewardRepo(pg), pg
|
||||
}
|
||||
|
||||
func giftUser(t *testing.T, pg *repository.Postgres) string {
|
||||
t.Helper()
|
||||
id := uuid.NewString()
|
||||
suffix := id[:8]
|
||||
if _, err := pg.Pool.Exec(context.Background(), `
|
||||
INSERT INTO users (id, email, password_hash, name, handle)
|
||||
VALUES ($1, $2, 'x', 'Gift User', $3)`,
|
||||
id, "gift-"+suffix+"@example.invalid", "gift_"+suffix); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
func giftReward(t *testing.T, pg *repository.Postgres) string {
|
||||
t.Helper()
|
||||
key := "gift_" + uuid.NewString()[:8]
|
||||
if _, err := pg.Pool.Exec(context.Background(), `
|
||||
INSERT INTO sadhna_rewards (key, name, image_url)
|
||||
VALUES ($1, 'Occasional Gift', 'https://example.invalid/g.png')`, key); err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM sadhna_rewards WHERE key = $1`, key)
|
||||
})
|
||||
return key
|
||||
}
|
||||
|
||||
// A gift arrives wrapped, opens once, and lands in the vault — and re-gifting
|
||||
// must not duplicate it.
|
||||
func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) {
|
||||
rewards, pg := testRewardRepo(t)
|
||||
ctx := context.Background()
|
||||
user := giftUser(t, pg)
|
||||
key := giftReward(t, pg)
|
||||
|
||||
reward, err := rewards.ByKey(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
find := func() (found bool, opened bool) {
|
||||
grants, err := rewards.GrantsByUser(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range grants {
|
||||
if g.Reward.Key == key {
|
||||
if g.Source != "gift" {
|
||||
t.Errorf("source = %q, want gift", g.Source)
|
||||
}
|
||||
if g.Milestone != nil {
|
||||
t.Errorf("milestone = %v, want nil — a gift isn't a streak day", *g.Milestone)
|
||||
}
|
||||
return true, g.Opened
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
found, opened := find()
|
||||
if !found {
|
||||
t.Fatal("the gift did not reach an existing account")
|
||||
}
|
||||
if opened {
|
||||
t.Error("a new gift should arrive wrapped")
|
||||
}
|
||||
|
||||
// It must NOT hijack the streak confetti — that stays for streak rewards.
|
||||
if pending, _ := rewards.PendingGrant(ctx, user); pending != nil {
|
||||
t.Errorf("a gift appeared as a pending streak celebration: %+v", pending)
|
||||
}
|
||||
|
||||
if err := rewards.OpenGift(ctx, user, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, opened = find(); !opened {
|
||||
t.Error("opening the gift did not un-blur it")
|
||||
}
|
||||
// Opening twice is harmless.
|
||||
if err := rewards.OpenGift(ctx, user, key); err != nil {
|
||||
t.Errorf("second open should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
// Re-gifting tops up newcomers without duplicating anyone's tile.
|
||||
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, _ := rewards.GrantsByUser(ctx, user)
|
||||
n := 0
|
||||
for _, g := range grants {
|
||||
if g.Reward.Key == key {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("holdings of the gift = %d, want 1", n)
|
||||
}
|
||||
// ...and it must not silently re-wrap what was already opened.
|
||||
if _, opened = find(); !opened {
|
||||
t.Error("re-gifting re-wrapped an already opened gift")
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,28 @@ func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.Sad
|
||||
|
||||
// AckRewards clears the user's pending celebration(s) after the client has
|
||||
// shown the confetti.
|
||||
// OpenGift un-blurs one gift after the devotee has seen its celebration.
|
||||
//
|
||||
// Per gift, not per user: someone with two waiting should open them one at a
|
||||
// time rather than have the first dismissal clear both.
|
||||
func (s *SadhnaService) OpenGift(ctx context.Context, userID, rewardKey string) error {
|
||||
return s.rewards.OpenGift(ctx, userID, rewardKey)
|
||||
}
|
||||
|
||||
// GiftToEveryone hands a reward to every account. Returns how many it reached.
|
||||
func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (int64, error) {
|
||||
reward, err := s.rewards.ByKey(ctx, rewardKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
|
||||
}
|
||||
|
||||
// GiftableRewards is the catalog an admin picks from.
|
||||
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
|
||||
return s.rewards.ListGiftable(ctx)
|
||||
}
|
||||
|
||||
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
|
||||
return s.rewards.MarkCelebrated(ctx, userID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user