feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s

This commit is contained in:
mrleo1nid committed 2026-10-23 10:45:40 +03:00
1 parent 0a05bfe2cd
commit 11a16cd023
59 files changed
+5022 -542

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Client: &http.Client{}}, libStore, dto, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Social: social.NewHandler(pool),
Social: social.NewHandler(pool, pb),
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
Genius: genius.NewHandler(geniusSvc),
Log: log,
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -122,7 +122,7 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
if route == "POST /api/v1/admin/users/import" {
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
}
if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
if (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch) && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
@@ -0,0 +1,26 @@
package playback
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// ReadSocialPlaylist is a projection port for social after live activity authorization.
// DTO signing and reactions still use the requesting viewer's principal from ctx.
func (h *Handler) ReadSocialPlaylist(ctx context.Context, id uuid.UUID) (gen.Playlist, error) {
var owner uuid.UUID
e := h.store.pool.QueryRow(ctx, `SELECT user_id FROM playlists WHERE id=$1`, id).Scan(&owner)
if errors.Is(e, pgx.ErrNoRows) {
return gen.Playlist{}, apperr.NotFound("playlist.not_found", "playlist not found")
}
if e != nil {
return gen.Playlist{}, e
}
p, e := h.readPlaylist(ctx, owner, id, false)
p.ShareId = nil
return p, e
}
+210
View File
@@ -0,0 +1,210 @@
package social
import (
"context"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
func validateActivity(a gen.ListeningActivityInput) error {
if a.DeviceId == uuid.Nil || a.Sequence < 1 {
return invalid()
}
if a.PositionMs != nil && (*a.PositionMs < 0 || *a.PositionMs > 86400000) {
return invalid()
}
if a.Context != "other" && a.Context != "wave" && a.Context != "playlist" {
return invalid()
}
if a.Playing && (a.TrackId == nil || *a.TrackId == uuid.Nil) {
return invalid()
}
if a.Context == "playlist" && (a.PlaylistId == nil || *a.PlaylistId == uuid.Nil) {
return invalid()
}
if a.Context != "playlist" && a.PlaylistId != nil {
return invalid()
}
return nil
}
func (h *Handler) SetFriendsRecommendations(ctx context.Context, r gen.SetFriendsRecommendationsRequestObject) (gen.SetFriendsRecommendationsResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
_, e = h.store.pool.Exec(ctx, `UPDATE users SET friends_recommendations=$2 WHERE id=$1`, id, r.Body.Enabled)
return gen.SetFriendsRecommendations204Response{}, e
}
func (h *Handler) PublishListeningActivity(ctx context.Context, r gen.PublishListeningActivityRequestObject) (gen.PublishListeningActivityResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
if e = validateActivity(*r.Body); e != nil {
return nil, e
}
if e = h.store.publishActivity(ctx, id, *r.Body); e != nil {
return nil, e
}
return gen.PublishListeningActivity204Response{}, nil
}
func (h *Handler) SetActivityVisibility(ctx context.Context, r gen.SetActivityVisibilityRequestObject) (gen.SetActivityVisibilityResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
v := r.Body.Visibility
if v != "nobody" && v != "friends" && v != "everyone" {
return nil, invalid()
}
tx, e := h.store.pool.Begin(ctx)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if _, e = tx.Exec(ctx, `UPDATE users SET activity_visibility=$2 WHERE id=$1`, id, v); e != nil {
return nil, e
}
if v == "nobody" {
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1`, id); e != nil {
return nil, e
}
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return gen.SetActivityVisibility204Response{}, nil
}
func (h *Handler) GetActivityPlaylist(ctx context.Context, r gen.GetActivityPlaylistRequestObject) (gen.GetActivityPlaylistResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
activity, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
if e != nil {
return nil, e
}
a := activity[r.UserId]
if a == nil || a.PlaylistId == nil {
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
p, e := h.playlists.ReadSocialPlaylist(ctx, *a.PlaylistId)
if e != nil {
return nil, e
}
// Recheck after projecting the playlist: a concurrent privacy change must close the view.
current, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
if e != nil {
return nil, e
}
now := current[r.UserId]
if now == nil || now.PlaylistId == nil || *now.PlaylistId != *a.PlaylistId {
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
return gen.GetActivityPlaylist200JSONResponse(p), nil
}
func (s *Store) publishActivity(ctx context.Context, id uuid.UUID, a gen.ListeningActivityInput) error {
tx, e := s.pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
var visibility string
if e = tx.QueryRow(ctx, `SELECT activity_visibility FROM users WHERE id=$1 FOR UPDATE`, id).Scan(&visibility); e != nil {
return e
}
if visibility == "nobody" {
return tx.Commit(ctx)
}
var track, playlist *uuid.UUID
kind := "other"
if a.Playing {
var exists bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, a.TrackId).Scan(&exists); e != nil {
return e
}
if !exists {
return invalid()
}
track = a.TrackId
kind = string(a.Context)
if kind == "playlist" {
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM playlists WHERE id=$2 AND (user_id=$1 OR share_id IS NOT NULL))`, id, a.PlaylistId).Scan(&exists); e != nil {
return e
}
if exists {
playlist = a.PlaylistId
} else {
kind = "other"
}
}
}
position := 0
if a.PositionMs != nil {
position = *a.PositionMs
}
_, e = tx.Exec(ctx, `INSERT INTO listening_activity(user_id,device_id,sequence,track_id,context,playlist_id,position_ms) VALUES($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(user_id,device_id) DO UPDATE SET sequence=EXCLUDED.sequence,track_id=EXCLUDED.track_id,context=EXCLUDED.context,playlist_id=EXCLUDED.playlist_id,position_ms=EXCLUDED.position_ms,updated_at=now()
WHERE EXCLUDED.sequence>listening_activity.sequence`, id, a.DeviceId, a.Sequence, track, kind, playlist, position)
if e != nil {
return e
}
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1 AND updated_at<now()-interval '1 day'`, id); e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) activities(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]*gen.ListeningActivity, error) {
out := map[uuid.UUID]*gen.ListeningActivity{}
if len(ids) == 0 {
return out, nil
}
rows, e := s.pool.Query(ctx, `SELECT u.id,t.id,t.title,
COALESCE((SELECT string_agg(ar.name,', ' ORDER BY ta.position) FROM track_artists ta JOIN artists ar ON ar.id=ta.artist_id WHERE ta.track_id=t.id AND ta.role='main'),''),
CASE WHEN a.context='playlist' AND p.id IS NULL THEN 'other' ELSE a.context END,p.id,p.name
FROM users u JOIN LATERAL(SELECT * FROM listening_activity la WHERE la.user_id=u.id AND la.track_id IS NOT NULL AND la.updated_at>now()-interval '90 seconds' ORDER BY la.updated_at DESC,la.device_id LIMIT 1) a ON true
JOIN tracks t ON t.id=a.track_id
LEFT JOIN playlists p ON p.id=a.playlist_id AND (p.user_id=u.id OR p.share_id IS NOT NULL)
WHERE u.id=ANY($2::uuid[]) AND NOT u.is_blocked
AND (u.id=$1 OR u.activity_visibility='everyone' OR (u.activity_visibility='friends' AND EXISTS(SELECT 1 FROM friendships f WHERE f.low_id=LEAST($1::uuid,u.id) AND f.high_id=GREATEST($1::uuid,u.id) AND f.accepted)))
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=u.id) OR (b.user_id=u.id AND b.peer_id=$1))`, viewer, ids)
if e != nil {
return nil, e
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var a gen.ListeningActivity
if e = rows.Scan(&id, &a.TrackId, &a.TrackTitle, &a.Artist, &a.Context, &a.PlaylistId, &a.PlaylistName); e != nil {
return nil, e
}
out[id] = &a
}
return out, rows.Err()
}
func (s *Store) attachActivity(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error {
ids := make([]uuid.UUID, 0, len(people))
for _, p := range people {
ids = append(ids, p.Id)
}
activities, e := s.activities(ctx, viewer, ids)
if e != nil {
return e
}
for i := range people {
people[i].Activity = activities[people[i].Id]
}
return nil
}
+193
View File
@@ -0,0 +1,193 @@
package social
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
"strings"
"unicode/utf8"
)
func validEmoji(s string) bool {
switch s {
case "❤️", "👍", "🔥", "😂", "🎵", "👏":
return true
}
return false
}
func (h *Handler) GetChatMeta(ctx context.Context, r gen.GetChatMetaRequestObject) (gen.GetChatMetaResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
return nil, e
}
var typing bool
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM chat_typing WHERE user_id=$1 AND peer_id=$2 AND expires_at>now())`, r.PeerId, id).Scan(&typing)
return gen.GetChatMeta200JSONResponse{Typing: typing}, e
}
func (h *Handler) SetChatTyping(ctx context.Context, r gen.SetChatTypingRequestObject) (gen.SetChatTypingResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
return nil, e
}
if r.Body.Typing {
_, e = tx.Exec(ctx, `INSERT INTO chat_typing(user_id,peer_id,expires_at) VALUES($1,$2,now()+interval '6 seconds') ON CONFLICT(user_id,peer_id) DO UPDATE SET expires_at=EXCLUDED.expires_at`, id, r.PeerId)
} else {
_, e = tx.Exec(ctx, `DELETE FROM chat_typing WHERE user_id=$1 AND peer_id=$2`, id, r.PeerId)
}
if e != nil {
return nil, e
}
return gen.SetChatTyping204Response{}, tx.Commit(ctx)
}
func (h *Handler) UpdateChatMessage(ctx context.Context, r gen.UpdateChatMessageRequestObject) (gen.UpdateChatMessageResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
e = h.store.updateMessage(ctx, id, r.PeerId, r.MessageId, *r.Body)
return gen.UpdateChatMessage204Response{}, e
}
func (s *Store) updateMessage(ctx context.Context, id, peer, message uuid.UUID, a gen.UpdateChatMessageJSONRequestBody) error {
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return e
}
defer tx.Rollback(ctx)
if e = allowed(ctx, tx, id, peer); e != nil {
return e
}
var sender uuid.UUID
var kind string
var deleted bool
e = tx.QueryRow(ctx, `SELECT sender_id,attachment_kind,deleted_at IS NOT NULL FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) FOR UPDATE`, message, id, peer).Scan(&sender, &kind, &deleted)
if errors.Is(e, pgx.ErrNoRows) {
return apperr.NotFound("social.message_conflict", "message unavailable")
}
if e != nil {
return e
}
if a.Action != "react" && sender != id {
return denied()
}
if deleted {
if a.Action == "delete" {
return tx.Commit(ctx)
}
return apperr.Conflict("social.message_conflict", "message deleted")
}
switch a.Action {
case "edit":
if a.Text == nil || utf8.RuneCountInString(*a.Text) > 4000 || (kind == "none" && strings.TrimSpace(*a.Text) == "") {
return invalid()
}
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text=$2,edited_at=now() WHERE id=$1`, message, strings.TrimSpace(*a.Text))
case "delete":
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text='[deleted]',attachment_kind='none',attachment_id=NULL,deleted_at=now() WHERE id=$1`, message)
if e == nil {
_, e = tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1`, message)
}
if e == nil {
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE message_seq=(SELECT seq FROM direct_messages WHERE id=$1)`, message)
}
case "react":
if a.Emoji == nil || !validEmoji(string(*a.Emoji)) {
return invalid()
}
result, err := tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3`, message, id, *a.Emoji)
e = err
if e == nil && result.RowsAffected() == 0 {
_, e = tx.Exec(ctx, `INSERT INTO message_reactions(message_id,user_id,emoji) VALUES($1,$2,$3)`, message, id, *a.Emoji)
}
default:
return invalid()
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
func enrichMessages(ctx context.Context, tx pgx.Tx, viewer uuid.UUID, messages []gen.DirectMessage) error {
if len(messages) == 0 {
return nil
}
ids := make([]uuid.UUID, 0, len(messages))
replies := []uuid.UUID{}
positions := map[uuid.UUID]int{}
for i, m := range messages {
ids = append(ids, m.Id)
positions[m.Id] = i
if m.ReplyTo != nil {
replies = append(replies, *m.ReplyTo)
}
empty := []gen.MessageReaction{}
messages[i].Reactions = &empty
}
rows, e := tx.Query(ctx, `SELECT message_id,emoji,count(*),bool_or(user_id=$2) FROM message_reactions WHERE message_id=ANY($1::uuid[]) GROUP BY message_id,emoji ORDER BY emoji`, ids, viewer)
if e != nil {
return e
}
for rows.Next() {
var id uuid.UUID
var r gen.MessageReaction
if e = rows.Scan(&id, &r.Emoji, &r.Count, &r.Mine); e != nil {
rows.Close()
return e
}
i := positions[id]
*messages[i].Reactions = append(*messages[i].Reactions, r)
}
e = rows.Err()
rows.Close()
if e != nil {
return e
}
if len(replies) == 0 {
return nil
}
rows, e = tx.Query(ctx, `SELECT id,CASE WHEN deleted_at IS NULL THEN left(text,160) ELSE '' END FROM direct_messages WHERE id=ANY($1::uuid[])`, replies)
if e != nil {
return e
}
defer rows.Close()
previews := map[uuid.UUID]string{}
for rows.Next() {
var id uuid.UUID
var text string
if e = rows.Scan(&id, &text); e != nil {
return e
}
previews[id] = text
}
for i, m := range messages {
if m.ReplyTo != nil {
p := previews[*m.ReplyTo]
messages[i].ReplyPreview = &p
}
}
return rows.Err()
}
@@ -0,0 +1,153 @@
//go:build integration
package social
import (
"context"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"os"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/platform/db"
"testing"
)
func TestActivityRoomsAndChatPrivacy(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, e := db.Connect(ctx, url)
require.NoError(t, e)
defer pool.Close()
require.NoError(t, db.Migrate(ctx, pool))
s := &Store{pool: pool}
h := &Handler{store: s}
owner, friend, other := uuid.New(), uuid.New(), uuid.New()
users := []uuid.UUID{owner, friend, other}
for _, id := range users {
_, e = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash) VALUES($1,$2,'x')`, id, id.String())
require.NoError(t, e)
}
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, users)
artist, album, track, playlist, device := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
_, e = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
require.NoError(t, e)
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
_, e = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album',$2,$3)`, album, album.String(), artist)
require.NoError(t, e)
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
_, e = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'Track',$2,$3,180000)`, track, track.String(), album)
require.NoError(t, e)
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=$1`, track)
_, e = pool.Exec(ctx, `INSERT INTO playlists(id,user_id,name) VALUES($1,$2,'Private playlist')`, playlist, owner)
require.NoError(t, e)
activity := gen.ListeningActivityInput{DeviceId: device, Sequence: 1, Playing: true, TrackId: &track, Context: "playlist", PlaylistId: &playlist}
require.NoError(t, s.publishActivity(ctx, owner, activity))
var count int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM listening_activity WHERE user_id=$1`, owner).Scan(&count))
require.Zero(t, count)
_, e = pool.Exec(ctx, `UPDATE users SET activity_visibility='friends' WHERE id=$1`, owner)
require.NoError(t, e)
require.NoError(t, s.publishActivity(ctx, owner, activity))
hidden, e := s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "request", TargetId: friend}))
require.NoError(t, s.action(ctx, friend, gen.SocialAction{Action: "accept", TargetId: owner}))
visible, e := s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Equal(t, &playlist, visible[owner].PlaylistId)
hidden, e = s.activities(ctx, other, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
activity.Sequence = 3
activity.Playing = false
activity.Context = "other"
activity.PlaylistId = nil
activity.TrackId = nil
require.NoError(t, s.publishActivity(ctx, owner, activity))
activity.Sequence = 2
activity.Playing = true
activity.Context = "playlist"
activity.PlaylistId = &playlist
activity.TrackId = &track
require.NoError(t, s.publishActivity(ctx, owner, activity))
hidden, e = s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
activity.Sequence = 4
require.NoError(t, s.publishActivity(ctx, owner, activity))
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
require.NoError(t, s.listeningAction(ctx, friend, join))
state, e := s.listeningState(ctx, friend)
require.NoError(t, e)
require.NotNil(t, state.Room)
require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2)
hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend})
input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000}
_, e = h.PublishListeningState(badctx, gen.PublishListeningStateRequestObject{Body: &input})
require.Error(t, e)
_, e = h.PublishListeningState(hostctx, gen.PublishListeningStateRequestObject{Body: &input})
require.NoError(t, e)
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "end", TargetId: state.Room.Id}))
state, e = s.listeningState(ctx, friend)
require.NoError(t, e)
require.Nil(t, state.Room)
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone',listening_auto_accept='nobody' WHERE id=$1`, other)
require.NoError(t, e)
invite := gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}
require.NoError(t, s.listeningAction(ctx, owner, invite))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Len(t, state.Requests, 1)
require.Nil(t, state.Room)
require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Equal(t, owner, state.Room.OwnerId)
_, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
require.NoError(t, e)
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Nil(t, state.Room)
// Chat: ownership, replies, reaction toggles, content removal and conversation search.
message, e := s.send(ctx, owner, friend, gen.SendDirectMessage{Id: uuid.New(), Text: "First message", AttachmentKind: "none"})
require.NoError(t, e)
reply, e := s.send(ctx, friend, owner, gen.SendDirectMessage{Id: uuid.New(), Text: "Reply", AttachmentKind: "none", ReplyTo: &message.Id})
require.NoError(t, e)
edited := "Edited message"
require.Error(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited}))
require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited}))
reaction := gen.UpdateChatMessageJSONBodyEmoji("🔥")
require.NoError(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "react", Emoji: &reaction}))
history, e := s.messages(ctx, owner, friend, nil)
require.NoError(t, e)
require.Len(t, *history[0].Reactions, 1)
require.Equal(t, "Edited message", *history[1].ReplyPreview)
matches, e := s.messagePage(ctx, friend, owner, nil, "Edited")
require.NoError(t, e)
require.Len(t, matches, 1)
require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
history, e = s.messages(ctx, owner, friend, nil)
require.NoError(t, e)
require.True(t, *history[0].Deleted)
require.Empty(t, history[0].Text)
require.Empty(t, *history[0].Reactions)
require.Equal(t, reply.ReplyTo, history[1].ReplyTo)
require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "block", TargetId: friend}))
hidden, e = s.activities(ctx, friend, []uuid.UUID{owner})
require.NoError(t, e)
require.Empty(t, hidden)
_, e = h.GetChatMeta(badctx, gen.GetChatMetaRequestObject{PeerId: owner})
require.Error(t, e)
}
+22 -3
View File
@@ -13,9 +13,18 @@ import (
"pipu/api/internal/platform/apperr"
)
type Handler struct{ store *Store }
type ActivityPlaylistReader interface {
ReadSocialPlaylist(context.Context, uuid.UUID) (gen.Playlist, error)
}
func NewHandler(pool *pgxpool.Pool) *Handler { return &Handler{store: &Store{pool: pool}} }
type Handler struct {
store *Store
playlists ActivityPlaylistReader
}
func NewHandler(pool *pgxpool.Pool, playlists ActivityPlaylistReader) *Handler {
return &Handler{store: &Store{pool: pool}, playlists: playlists}
}
func userID(ctx context.Context) (uuid.UUID, error) {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
@@ -71,10 +80,20 @@ func (h *Handler) GetDirectMessages(ctx context.Context, r gen.GetDirectMessages
if r.PeerId == uuid.Nil || r.PeerId == id || (r.Params.Before != nil && *r.Params.Before < 1) {
return nil, invalid()
}
v, e := h.store.messages(ctx, id, r.PeerId, r.Params.Before)
q := ""
if r.Params.Q != nil {
q = strings.TrimSpace(*r.Params.Q)
}
if utf8.RuneCountInString(q) > 100 {
return nil, invalid()
}
v, e := h.store.messagePage(ctx, id, r.PeerId, r.Params.Before, q)
return gen.GetDirectMessages200JSONResponse(v), e
}
func validateMessage(m gen.SendDirectMessage) error {
if m.ReplyTo != nil && *m.ReplyTo == uuid.Nil {
return invalid()
}
if m.Id == uuid.Nil || utf8.RuneCountInString(m.Text) > 4000 {
return invalid()
}
+345
View File
@@ -0,0 +1,345 @@
package social
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
"time"
)
func audienceAllows(policy string, friends bool) bool {
return policy == "everyone" || (policy == "friends" && friends)
}
func validAudience(v gen.ActivityVisibility) bool {
return v == "nobody" || v == "friends" || v == "everyone"
}
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil || !validAudience(r.Body.Requests) || !validAudience(r.Body.AutoAccept) {
return nil, invalid()
}
_, e = h.store.pool.Exec(ctx, `UPDATE users SET listening_requests=$2,listening_auto_accept=$3,listening_autoplay=$4 WHERE id=$1`, id, r.Body.Requests, r.Body.AutoAccept, r.Body.Autoplay)
return gen.SetListeningPreferences204Response{}, e
}
func (h *Handler) GetListeningState(ctx context.Context, _ gen.GetListeningStateRequestObject) (gen.GetListeningStateResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
v, e := h.store.listeningState(ctx, id)
return gen.GetListeningState200JSONResponse(v), e
}
func (h *Handler) ListeningAction(ctx context.Context, r gen.ListeningActionRequestObject) (gen.ListeningActionResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil || r.Body.Id == uuid.Nil || r.Body.TargetId == uuid.Nil {
return nil, invalid()
}
e = h.store.listeningAction(ctx, id, *r.Body)
return gen.ListeningAction204Response{}, e
}
func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListeningStateRequestObject) (gen.PublishListeningStateResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
a := r.Body
if a.DeviceId == uuid.Nil || a.RoomId == uuid.Nil || a.Sequence < 1 || a.PositionMs < 0 || a.PositionMs > 86400000 || (a.Playing && a.TrackId == nil) {
return nil, invalid()
}
result, e := h.store.pool.Exec(ctx, `UPDATE listening_rooms SET track_id=$4,position_ms=$5,playing=$6,sequence=$7,updated_at=now()
WHERE id=$1 AND owner_id=$2 AND device_id=$3 AND sequence<$7 AND updated_at>now()-interval '30 seconds'
AND ($4::uuid IS NULL OR EXISTS(SELECT 1 FROM tracks WHERE id=$4))`, a.RoomId, id, a.DeviceId, a.TrackId, a.PositionMs, a.Playing, a.Sequence)
if e != nil {
return nil, e
}
if result.RowsAffected() == 0 {
var own bool
e = h.store.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_rooms WHERE id=$1 AND owner_id=$2 AND device_id=$3 AND updated_at>now()-interval '30 seconds')`, a.RoomId, id, a.DeviceId).Scan(&own)
if e != nil {
return nil, e
}
if !own {
return nil, denied()
}
}
return gen.PublishListeningState204Response{}, nil
}
func (s *Store) cleanListening(ctx context.Context) error {
_, e := s.pool.Exec(ctx, `DELETE FROM listening_rooms WHERE updated_at<now()-interval '30 seconds'`)
if e != nil {
return e
}
_, e = s.pool.Exec(ctx, `UPDATE listening_requests SET status='cancelled' WHERE status='pending' AND expires_at<=now()`)
if e != nil {
return e
}
_, e = s.pool.Exec(ctx, `DELETE FROM listening_members m USING listening_rooms r,users u WHERE m.room_id=r.id AND u.id=r.owner_id AND m.user_id<>r.owner_id
AND (m.last_seen<now()-interval '60 seconds' OR u.is_blocked OR EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=r.owner_id AND b.peer_id=m.user_id) OR (b.user_id=m.user_id AND b.peer_id=r.owner_id)))`)
return e
}
func (s *Store) listeningState(ctx context.Context, id uuid.UUID) (gen.ListeningState, error) {
out := gen.ListeningState{Requests: []gen.ListeningRequest{}, ServerTime: time.Now().UTC()}
if e := s.cleanListening(ctx); e != nil {
return out, e
}
if e := s.pool.QueryRow(ctx, `SELECT listening_requests,listening_auto_accept,listening_autoplay,now() FROM users WHERE id=$1`, id).Scan(&out.Preferences.Requests, &out.Preferences.AutoAccept, &out.Preferences.Autoplay, &out.ServerTime); e != nil {
return out, e
}
if _, e := s.pool.Exec(ctx, `UPDATE listening_members SET last_seen=now() WHERE user_id=$1`, id); e != nil {
return out, e
}
var room gen.ListeningRoom
var participants []byte
e := s.pool.QueryRow(ctx, `SELECT r.id,r.owner_id,COALESCE(u.display_name,u.username::text),r.device_id,r.track_id,r.position_ms,r.playing,r.updated_at,
COALESCE((SELECT json_agg(json_build_object('id',p.id,'name',COALESCE(p.display_name,p.username::text)) ORDER BY p.username) FROM listening_members lm JOIN users p ON p.id=lm.user_id WHERE lm.room_id=r.id),'[]')
FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id JOIN users u ON u.id=r.owner_id WHERE m.user_id=$1`, id).Scan(&room.Id, &room.OwnerId, &room.OwnerName, &room.DeviceId, &room.TrackId, &room.PositionMs, &room.Playing, &room.UpdatedAt, &participants)
if e == nil {
if e = json.Unmarshal(participants, &room.Participants); e != nil {
return out, e
}
out.Room = &room
} else if !errors.Is(e, pgx.ErrNoRows) {
return out, e
}
rows, e := s.pool.Query(ctx, `SELECT q.id,q.sender_id,COALESCE(u.display_name,u.username::text),q.recipient_id,q.owner_id,q.kind,q.expires_at
FROM listening_requests q JOIN users u ON u.id=q.sender_id WHERE q.status='pending' AND q.expires_at>now() AND (q.sender_id=$1 OR q.recipient_id=$1)
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=q.sender_id AND b.peer_id=q.recipient_id) OR (b.user_id=q.recipient_id AND b.peer_id=q.sender_id))
ORDER BY q.created_at LIMIT 50`, id)
if e != nil {
return out, e
}
defer rows.Close()
for rows.Next() {
var q gen.ListeningRequest
if e = rows.Scan(&q.Id, &q.SenderId, &q.SenderName, &q.RecipientId, &q.OwnerId, &q.Kind, &q.ExpiresAt); e != nil {
return out, e
}
out.Requests = append(out.Requests, q)
}
return out, rows.Err()
}
func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID) (bool, bool, error) {
var policy, automatic string
var friends, blocked bool
e := tx.QueryRow(ctx, `SELECT listening_requests,listening_auto_accept,
EXISTS(SELECT 1 FROM friendships WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid) AND accepted),
is_blocked OR EXISTS(SELECT 1 FROM users WHERE id=$2 AND is_blocked) OR EXISTS(SELECT 1 FROM social_blocks WHERE (user_id=$1 AND peer_id=$2) OR (user_id=$2 AND peer_id=$1))
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked)
if errors.Is(e, pgx.ErrNoRows) {
return false, false, denied()
}
if e != nil {
return false, false, e
}
return !blocked && audienceAllows(policy, friends), !blocked && audienceAllows(automatic, friends), nil
}
func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipient, sender uuid.UUID) error {
allowed, _, e := listeningPolicy(ctx, tx, recipient, sender)
if e != nil {
return e
}
if !allowed {
return denied()
}
result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room)
if e != nil {
return e
}
if result.RowsAffected() == 0 {
return apperr.Conflict("social.listening_busy", "already in another room")
}
_, e = tx.Exec(ctx, `UPDATE listening_requests SET status='accepted' WHERE id=$1`, request)
if e != nil {
return e
}
return notify(ctx, tx, sender, recipient, "listening_accepted", nil)
}
func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.ListeningAction) error {
if e := s.cleanListening(ctx); e != nil {
return e
}
tx, e := s.pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
switch a.Action {
case "leave", "end":
var owner uuid.UUID
e = tx.QueryRow(ctx, `SELECT owner_id FROM listening_rooms WHERE id=$1 FOR UPDATE`, a.TargetId).Scan(&owner)
if errors.Is(e, pgx.ErrNoRows) {
return nil
}
if e != nil {
return e
}
if a.Action == "end" && owner != id {
return denied()
}
if owner == id {
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
} else {
_, e = tx.Exec(ctx, `DELETE FROM listening_members WHERE room_id=$1 AND user_id=$2`, a.TargetId, id)
}
case "accept", "decline", "cancel":
var room, guest, recipient, sender, owner uuid.UUID
var status string
e = tx.QueryRow(ctx, `SELECT room_id,guest_id,recipient_id,sender_id,owner_id,status FROM listening_requests WHERE id=$1 AND expires_at>now()`, a.TargetId).Scan(&room, &guest, &recipient, &sender, &owner, &status)
if errors.Is(e, pgx.ErrNoRows) {
return apperr.NotFound("social.listening_expired", "request expired")
}
if e != nil {
return e
}
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
return denied()
}
if status != "pending" {
return nil
}
// Lock both accounts in UUID order before testing admission and membership.
if _, e = tx.Exec(ctx, `SELECT id FROM users WHERE id=ANY($1::uuid[]) ORDER BY id FOR UPDATE`, []uuid.UUID{owner, guest}); e != nil {
return e
}
if e = tx.QueryRow(ctx, `SELECT status FROM listening_requests WHERE id=$1 AND expires_at>now() FOR UPDATE`, a.TargetId).Scan(&status); e != nil {
return e
}
if status != "pending" {
return tx.Commit(ctx)
}
if a.Action == "accept" {
e = admitListening(ctx, tx, a.TargetId, room, guest, recipient, sender)
} else {
next := "declined"
if a.Action == "cancel" {
next = "cancelled"
}
_, e = tx.Exec(ctx, `UPDATE listening_requests SET status=$2 WHERE id=$1`, a.TargetId, next)
}
case "join", "invite":
if id == a.TargetId {
return invalid()
}
owner, guest, recipient := a.TargetId, id, a.TargetId
if a.Action == "invite" {
owner, guest, recipient = id, a.TargetId, a.TargetId
}
if _, e = tx.Exec(ctx, `SELECT id FROM users WHERE id=ANY($1::uuid[]) ORDER BY id FOR UPDATE`, []uuid.UUID{owner, guest}); e != nil {
return e
}
permitted, automatic, err := listeningPolicy(ctx, tx, recipient, id)
if err != nil {
return err
}
if !permitted {
return denied()
}
var repeated bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil {
return e
}
if repeated {
return tx.Commit(ctx)
}
var busy bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id WHERE (m.user_id=$1 OR m.user_id=$2) AND r.owner_id<>$1)`, owner, guest).Scan(&busy); e != nil {
return e
}
if busy {
return apperr.Conflict("social.listening_busy", "already in another room")
}
var recent int
if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil {
return e
}
if recent >= 10 {
return apperr.TooManyRequests("social.rate_limited", 60)
}
var room uuid.UUID
e = tx.QueryRow(ctx, `SELECT id FROM listening_rooms WHERE owner_id=$1`, owner).Scan(&room)
if errors.Is(e, pgx.ErrNoRows) {
var device, track uuid.UUID
position := 0
playing := true
if a.Action == "invite" {
if a.DeviceId == nil || *a.DeviceId == uuid.Nil || a.TrackId == nil || *a.TrackId == uuid.Nil {
return invalid()
}
device, track = *a.DeviceId, *a.TrackId
if a.PositionMs != nil {
position = *a.PositionMs
}
if position < 0 || position > 86400000 {
return invalid()
}
if a.Playing != nil {
playing = *a.Playing
}
} else {
visible, err := s.activities(ctx, id, []uuid.UUID{owner})
if err != nil {
return err
}
if visible[owner] == nil {
return apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
e = tx.QueryRow(ctx, `SELECT device_id,track_id,position_ms+LEAST(90000,GREATEST(0,(EXTRACT(EPOCH FROM(now()-updated_at))*1000)::int)) FROM listening_activity WHERE user_id=$1 AND track_id IS NOT NULL AND updated_at>now()-interval '90 seconds' ORDER BY updated_at DESC,device_id LIMIT 1`, owner).Scan(&device, &track, &position)
if e != nil {
return e
}
}
var exists bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, track).Scan(&exists); e != nil {
return e
}
if !exists {
return invalid()
}
room = uuid.New()
_, e = tx.Exec(ctx, `INSERT INTO listening_rooms(id,owner_id,device_id,track_id,position_ms,playing) VALUES($1,$2,$3,$4,$5,$6)`, room, owner, device, track, position, playing)
if e != nil {
return e
}
_, e = tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2)`, owner, room)
}
if e != nil {
return e
}
result, err := tx.Exec(ctx, `INSERT INTO listening_requests(id,room_id,owner_id,guest_id,sender_id,recipient_id,kind) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`, a.Id, room, owner, guest, id, recipient, a.Action)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return apperr.Conflict("social.listening_pending", "request already pending")
}
if automatic {
e = admitListening(ctx, tx, a.Id, room, guest, recipient, id)
} else {
kind := "listening_request"
if a.Action == "invite" {
kind = "listening_invite"
}
e = notify(ctx, tx, recipient, id, kind, nil)
}
default:
return invalid()
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
@@ -38,3 +38,14 @@ func TestHandlersRequireIdentityAndRejectSelf(t *testing.T) {
_, e = h.SearchPeople(ctx, gen.SearchPeopleRequestObject{Params: gen.SearchPeopleParams{Q: " "}})
require.Error(t, e)
}
func TestListeningAudienceAndReactionValidation(t *testing.T) {
require.False(t, audienceAllows("nobody", true))
require.False(t, audienceAllows("friends", false))
require.True(t, audienceAllows("friends", true))
require.True(t, audienceAllows("everyone", false))
require.False(t, audienceAllows("unexpected", true))
require.True(t, validEmoji("❤️"))
require.False(t, validEmoji("<script>"))
require.Error(t, validateActivity(gen.ListeningActivityInput{DeviceId: uuid.New(), Sequence: 1, Playing: true, Context: "wave"}))
}
+37 -8
View File
@@ -73,7 +73,11 @@ func (s *Store) people(ctx context.Context, id uuid.UUID, q string) ([]gen.Socia
if e != nil {
return nil, e
}
return scanPeople(rows)
people, e := scanPeople(rows)
if e != nil {
return nil, e
}
return people, s.attachActivity(ctx, id, people)
}
func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error) {
out := gen.SocialInbox{People: []gen.SocialPerson{}, Notifications: []gen.SocialNotification{}}
@@ -85,6 +89,12 @@ func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error
if e != nil {
return out, e
}
if e = s.attachActivity(ctx, id, out.People); e != nil {
return out, e
}
if e = s.pool.QueryRow(ctx, `SELECT activity_visibility,friends_recommendations FROM users WHERE id=$1`, id).Scan(&out.ActivityVisibility, &out.FriendsRecommendations); e != nil {
return out, e
}
rows, e = s.pool.Query(ctx, `SELECT n.id,n.actor_id,COALESCE(u.display_name,u.username::text),n.kind,n.created_at,n.read_at IS NOT NULL
FROM social_notifications n JOIN users u ON u.id=n.actor_id WHERE n.user_id=$1 AND NOT u.is_blocked
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=n.actor_id) OR (b.peer_id=$1 AND b.user_id=n.actor_id))
@@ -215,14 +225,19 @@ func (s *Store) action(ctx context.Context, id uuid.UUID, a gen.SocialAction) er
return tx.Commit(ctx)
}
const messageColumns = `id,seq,sender_id,text,attachment_kind,attachment_id,created_at,read_at IS NOT NULL`
const messageColumns = `id,seq,sender_id,CASE WHEN deleted_at IS NULL THEN text ELSE '' END,attachment_kind,attachment_id,created_at,read_at IS NOT NULL,reply_to,edited_at,deleted_at IS NOT NULL`
func scanMessage(row pgx.Row) (gen.DirectMessage, error) {
var m gen.DirectMessage
e := row.Scan(&m.Id, &m.Seq, &m.SenderId, &m.Text, &m.AttachmentKind, &m.AttachmentId, &m.CreatedAt, &m.Read)
var deleted bool
e := row.Scan(&m.Id, &m.Seq, &m.SenderId, &m.Text, &m.AttachmentKind, &m.AttachmentId, &m.CreatedAt, &m.Read, &m.ReplyTo, &m.EditedAt, &deleted)
m.Deleted = &deleted
return m, e
}
func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64) ([]gen.DirectMessage, error) {
return s.messagePage(ctx, id, peer, before, "")
}
func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int64, q string) ([]gen.DirectMessage, error) {
tx, e := pairTx(ctx, s.pool, id, peer)
if e != nil {
return nil, e
@@ -231,7 +246,7 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
if e = allowed(ctx, tx, id, peer); e != nil {
return nil, e
}
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) ORDER BY seq DESC LIMIT 50`, id, peer, before)
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) ORDER BY seq DESC LIMIT 50`, id, peer, before, q)
if e != nil {
return nil, e
}
@@ -250,7 +265,8 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
rows.Close()
return out, enrichMessages(ctx, tx, id, out)
}
func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectMessage) (gen.DirectMessage, error) {
var zero gen.DirectMessage
@@ -274,10 +290,13 @@ func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectM
if e != nil {
return zero, e
}
if m.SenderId != id || recipient != peer || m.Text != strings.TrimSpace(in.Text) || string(m.AttachmentKind) != string(in.AttachmentKind) || !sameAttachment {
sameReply := (m.ReplyTo == nil && in.ReplyTo == nil) || (m.ReplyTo != nil && in.ReplyTo != nil && *m.ReplyTo == *in.ReplyTo)
if m.SenderId != id || recipient != peer || m.Text != strings.TrimSpace(in.Text) || string(m.AttachmentKind) != string(in.AttachmentKind) || !sameAttachment || !sameReply {
return zero, apperr.Conflict("social.message_conflict", "message ID already used")
}
return m, nil
out := []gen.DirectMessage{m}
e = enrichMessages(ctx, tx, id, out)
return out[0], e
}
if !errors.Is(e, pgx.ErrNoRows) {
return zero, e
@@ -303,7 +322,17 @@ func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectM
return zero, apperr.NotFound("social.attachment_unavailable", "music is unavailable")
}
}
m, e = scanMessage(tx.QueryRow(ctx, `INSERT INTO direct_messages(id,sender_id,recipient_id,text,attachment_kind,attachment_id) VALUES($1,$2,$3,$4,$5,$6) RETURNING `+messageColumns, in.Id, id, peer, strings.TrimSpace(in.Text), in.AttachmentKind, in.AttachmentId))
if in.ReplyTo != nil {
var valid bool
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) AND deleted_at IS NULL)`, in.ReplyTo, id, peer).Scan(&valid)
if e != nil {
return zero, e
}
if !valid {
return zero, apperr.Conflict("social.message_conflict", "reply unavailable")
}
}
m, e = scanMessage(tx.QueryRow(ctx, `INSERT INTO direct_messages(id,sender_id,recipient_id,text,attachment_kind,attachment_id,reply_to) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING `+messageColumns, in.Id, id, peer, strings.TrimSpace(in.Text), in.AttachmentKind, in.AttachmentId, in.ReplyTo))
if e != nil {
return zero, e
}
+48
View File
@@ -0,0 +1,48 @@
package wave
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Only currently visible activity is a social signal. A hidden profile never contributes
// its listening history, and self feedback/recent plays retain precedence.
func (h *Handler) friendCandidates(ctx context.Context, user uuid.UUID, exclude []string) ([]string, error) {
rows, e := h.pool.Query(ctx, `SELECT a.track_id::text FROM listening_activity a
JOIN users friend ON friend.id=a.user_id AND NOT friend.is_blocked AND friend.activity_visibility IN ('friends','everyone')
JOIN users own ON own.id=$1 AND own.friends_recommendations
JOIN friendships f ON f.low_id=LEAST($1::uuid,a.user_id) AND f.high_id=GREATEST($1::uuid,a.user_id) AND f.accepted
JOIN tracks t ON t.id=a.track_id JOIN media_files mf ON mf.id=t.primary_file_id AND mf.missing_since IS NULL
JOIN library_sources src ON src.id=mf.source_id AND src.status='mounted'
WHERE a.updated_at>now()-interval '90 seconds' AND NOT(a.track_id::text=ANY($2::text[]))
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=a.user_id) OR (b.user_id=a.user_id AND b.peer_id=$1))
AND NOT EXISTS(SELECT 1 FROM track_reactions r WHERE r.user_id=$1 AND r.track_id=a.track_id AND r.reaction='dislike')
AND NOT EXISTS(SELECT 1 FROM play_events e WHERE e.user_id=$1 AND e.track_id=a.track_id AND e.type='play_start' AND e.occurred_at>now()-interval '3 hours')
GROUP BY a.track_id ORDER BY count(DISTINCT a.user_id) DESC,max(a.updated_at) DESC LIMIT 30`, user, exclude)
if e != nil {
return nil, e
}
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowTo[string])
}
func blendFriendPick(picks []Pick, ids []string) []Pick {
if len(ids) == 0 || len(picks) < 3 {
return picks
}
for _, id := range ids {
exists := false
for _, p := range picks {
if p.ID == id {
exists = true
break
}
}
if !exists {
out := append([]Pick(nil), picks...)
out[len(out)-1] = Pick{ID: id, Source: "friends", Score: 0.1}
return out
}
}
return picks
}
@@ -0,0 +1,16 @@
package wave
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestFriendMixRemainsMinorityAndDoesNotDuplicate(t *testing.T) {
original := []Pick{{ID: "one"}, {ID: "two"}, {ID: "three"}, {ID: "four"}, {ID: "five"}}
mixed := blendFriendPick(original, []string{"one", "friend"})
require.Len(t, mixed, 5)
require.Equal(t, "friends", mixed[4].Source)
require.Equal(t, "friend", mixed[4].ID)
require.Equal(t, "five", original[4].ID)
require.Equal(t, original, blendFriendPick(original, []string{"one"}))
}
+17 -8
View File
@@ -67,14 +67,15 @@ type Feedback struct {
// BatchRequest — запрос к рекомендателю.
type BatchRequest struct {
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Activity *string `json:"activity"`
Mood *string `json:"mood"`
Seed *Seed `json:"seed,omitempty"`
ExcludeIDs []string `json:"exclude_ids"`
Feedback []Feedback `json:"feedback"`
N int `json:"n"`
FriendTrackIDs []string `json:"friend_track_ids,omitempty"`
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
Activity *string `json:"activity"`
Mood *string `json:"mood"`
Seed *Seed `json:"seed,omitempty"`
ExcludeIDs []string `json:"exclude_ids"`
Feedback []Feedback `json:"feedback"`
N int `json:"n"`
}
// Seed — от чего запущена волна.
@@ -295,6 +296,11 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
UserID: s.UserID.String(), SessionID: s.ID.String(), Activity: s.Activity, Mood: s.Mood,
ExcludeIDs: served, Feedback: fb, N: BatchSize,
}
friendIDs, friendErr := h.friendCandidates(ctx, s.UserID, served)
if friendErr != nil {
h.log.WarnContext(ctx, "friend music unavailable", "err", friendErr)
}
req.FriendTrackIDs = friendIDs
if s.SeedType != nil && s.SeedID != nil {
req.Seed = &Seed{Type: *s.SeedType, ID: s.SeedID.String()}
}
@@ -311,6 +317,9 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
if picks, err = h.fallback(ctx, s, served); err != nil {
return gen.WaveBatch{}, err
}
if s.SeedType == nil && len(served)%10 >= 5 {
picks = blendFriendPick(picks, friendIDs)
}
}
ids := make([]uuid.UUID, 0, len(picks))
@@ -0,0 +1,21 @@
-- +goose Up
ALTER TABLE users ADD COLUMN activity_visibility text NOT NULL DEFAULT 'nobody'
CHECK(activity_visibility IN ('nobody','friends','everyone'));
ALTER TABLE users ADD COLUMN friends_recommendations boolean NOT NULL DEFAULT true;
CREATE TABLE listening_activity (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id uuid NOT NULL,
sequence bigint NOT NULL CHECK(sequence>0),
position_ms integer NOT NULL DEFAULT 0 CHECK(position_ms>=0),
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
context text NOT NULL CHECK(context IN ('other','wave','playlist')),
playlist_id uuid REFERENCES playlists(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(user_id,device_id)
);
CREATE INDEX listening_activity_recent_idx ON listening_activity(user_id,updated_at DESC) WHERE track_id IS NOT NULL;
-- +goose Down
DROP TABLE listening_activity;
ALTER TABLE users DROP COLUMN activity_visibility;
ALTER TABLE users DROP COLUMN friends_recommendations;
@@ -0,0 +1,46 @@
-- +goose Up
ALTER TABLE users ADD COLUMN listening_requests text NOT NULL DEFAULT 'nobody' CHECK(listening_requests IN ('nobody','friends','everyone')),
ADD COLUMN listening_auto_accept text NOT NULL DEFAULT 'nobody' CHECK(listening_auto_accept IN ('nobody','friends','everyone')),
ADD COLUMN listening_autoplay boolean NOT NULL DEFAULT true;
CREATE TABLE listening_rooms (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
device_id uuid NOT NULL,
sequence bigint NOT NULL DEFAULT 0,
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
position_ms integer NOT NULL DEFAULT 0 CHECK(position_ms>=0),
playing boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE listening_members (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
room_id uuid NOT NULL REFERENCES listening_rooms(id) ON DELETE CASCADE,
last_seen timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX listening_members_room_idx ON listening_members(room_id);
CREATE TABLE listening_requests (
id uuid PRIMARY KEY,
room_id uuid NOT NULL REFERENCES listening_rooms(id) ON DELETE CASCADE,
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guest_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipient_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK(kind IN ('join','invite')),
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined','cancelled')),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT now()+interval '2 minutes',
CHECK(owner_id<>guest_id)
);
CREATE UNIQUE INDEX listening_requests_pending_idx ON listening_requests(owner_id,guest_id) WHERE status='pending';
CREATE INDEX listening_requests_recipient_idx ON listening_requests(recipient_id,expires_at) WHERE status='pending';
ALTER TABLE social_notifications DROP CONSTRAINT social_notifications_kind_check;
ALTER TABLE social_notifications ADD CHECK(kind IN ('friend_request','friend_accepted','message','music','listening_request','listening_invite','listening_accepted'));
-- +goose Down
DELETE FROM social_notifications WHERE kind LIKE 'listening_%';
ALTER TABLE social_notifications DROP CONSTRAINT social_notifications_kind_check;
ALTER TABLE social_notifications ADD CHECK(kind IN ('friend_request','friend_accepted','message','music'));
DROP TABLE listening_requests;
DROP TABLE listening_members;
DROP TABLE listening_rooms;
ALTER TABLE users DROP COLUMN listening_requests, DROP COLUMN listening_auto_accept, DROP COLUMN listening_autoplay;
@@ -0,0 +1,20 @@
-- +goose Up
ALTER TABLE direct_messages ADD COLUMN reply_to uuid REFERENCES direct_messages(id) ON DELETE SET NULL,
ADD COLUMN edited_at timestamptz, ADD COLUMN deleted_at timestamptz;
CREATE TABLE message_reactions (
message_id uuid NOT NULL REFERENCES direct_messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji text NOT NULL CHECK(emoji IN ('❤️','👍','🔥','😂','🎵','👏')),
PRIMARY KEY(message_id,user_id,emoji)
);
CREATE TABLE chat_typing (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
PRIMARY KEY(user_id,peer_id), CHECK(user_id<>peer_id)
);
-- +goose Down
DROP TABLE chat_typing;
DROP TABLE message_reactions;
ALTER TABLE direct_messages DROP COLUMN reply_to, DROP COLUMN edited_at, DROP COLUMN deleted_at;