feat: add stickers

This commit is contained in:
Krispire Admin
2026-08-30 02:18:53 +05:30
parent cee2d3c874
commit fecb9bc045
15 changed files with 752 additions and 6 deletions
+45 -1
View File
@@ -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
+150
View File
@@ -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")
}
}
}
+140
View File
@@ -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")
}
}
+22
View File
@@ -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)
}