Files
pipu-music/services/api/internal/social/chat.go
T
mrleo1nid 11a16cd023
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
feat(social): add listening activity, shared rooms and modern chat
2026-10-23 10:45:40 +03:00

194 lines
5.8 KiB
Go

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()
}