feat: add chat
This commit is contained in:
@@ -55,6 +55,22 @@ func NotificationsRule() ChannelRule {
|
||||
}
|
||||
}
|
||||
|
||||
// ConversationRule allows a caller to subscribe to a thread's channel only
|
||||
// while they are a participant. isParticipant is injected rather than taking a
|
||||
// repository dependency here, so the authorization check stays the service's
|
||||
// single source of truth.
|
||||
func ConversationRule(isParticipant func(ctx context.Context, userID, conversationID string) (bool, error)) ChannelRule {
|
||||
return ChannelRule{
|
||||
Prefix: "private-conversation.",
|
||||
Authorize: func(ctx context.Context, userID, suffix string) (bool, error) {
|
||||
if suffix == "" {
|
||||
return false, nil
|
||||
}
|
||||
return isParticipant(ctx, userID, suffix)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type broadcastingAuthResp struct {
|
||||
Auth string `json:"auth"`
|
||||
ChannelData string `json:"channel_data,omitempty"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -77,6 +78,44 @@ func TestBroadcastingAuth_Authorization(t *testing.T) {
|
||||
|
||||
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts
|
||||
// with the victim's id but is a different channel.
|
||||
// ConversationRule delegates to the chat service's membership check, so the
|
||||
// realtime channel and the REST endpoints can never disagree about who is in a
|
||||
// thread.
|
||||
func TestConversationRule(t *testing.T) {
|
||||
const member, outsider = "member-1", "outsider-1"
|
||||
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
|
||||
if conversationID == "boom" {
|
||||
return false, errors.New("database down")
|
||||
}
|
||||
return userID == member && conversationID == "conv-1", nil
|
||||
})
|
||||
|
||||
if rule.Prefix != "private-conversation." {
|
||||
t.Errorf("prefix = %q", rule.Prefix)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
user string
|
||||
suffix string
|
||||
wantOK bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"participant", member, "conv-1", true, false},
|
||||
{"outsider", outsider, "conv-1", false, false},
|
||||
{"participant of another thread", member, "conv-2", false, false},
|
||||
{"empty conversation id", member, "", false, false},
|
||||
{"lookup failure is not a denial", member, "boom", false, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
|
||||
if ok != tc.wantOK || (err != nil) != tc.wantErr {
|
||||
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
|
||||
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// ChatHandler exposes the chat inbox, threads and messages.
|
||||
//
|
||||
// Every route is session-authenticated and every read re-checks membership in
|
||||
// the service, so a conversation id is not a capability: knowing one gets you
|
||||
// nothing if you were never in the thread (or have left it).
|
||||
type ChatHandler struct {
|
||||
svc *service.ChatService
|
||||
}
|
||||
|
||||
func NewChatHandler(svc *service.ChatService) *ChatHandler { return &ChatHandler{svc: svc} }
|
||||
|
||||
// writeChatError maps service errors onto status codes. Not-a-participant is a
|
||||
// 404, not a 403: confirming a thread exists to someone outside it leaks that
|
||||
// two people are talking.
|
||||
func writeChatError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrNotParticipant), errors.Is(err, repository.ErrConversationGone):
|
||||
response.Error(w, http.StatusNotFound, "conversation not found")
|
||||
case errors.Is(err, service.ErrAvatarUnset):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrChatBadAvatarKey):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, repository.ErrMessageNotHere):
|
||||
response.Error(w, http.StatusNotFound, "message not found")
|
||||
case errors.Is(err, repository.ErrAttachmentGone):
|
||||
response.Error(w, http.StatusNotFound, "attachment not found")
|
||||
case errors.Is(err, service.ErrChatMediaOff):
|
||||
response.Error(w, http.StatusServiceUnavailable, err.Error())
|
||||
case errors.Is(err, service.ErrChatBadMedia):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrChatNotAdmin), errors.Is(err, service.ErrChatOwnerImmutable),
|
||||
errors.Is(err, service.ErrChatAdminsOnly):
|
||||
response.Error(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, service.ErrChatNotGroup), errors.Is(err, service.ErrChatBadRole),
|
||||
errors.Is(err, service.ErrChatBadPolicy):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrChatNotSender):
|
||||
response.Error(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, service.ErrMessageDeleted), errors.Is(err, service.ErrChatBadReport):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrChatNotAccepting):
|
||||
response.Error(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, service.ErrChatBadWhoCanMessage), errors.Is(err, service.ErrChatBadAccountAge),
|
||||
errors.Is(err, service.ErrChatGenderUnset):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrChatBlocked):
|
||||
response.Error(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, service.ErrChatReadOnly):
|
||||
response.Error(w, http.StatusConflict, "Kautilya is not available yet")
|
||||
case errors.Is(err, service.ErrChatEmptyBody), errors.Is(err, service.ErrChatBodyTooLong),
|
||||
errors.Is(err, service.ErrChatBadMuteDuration),
|
||||
errors.Is(err, repository.ErrReplyTargetNotHere):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "chat request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox — GET /chat/conversations?tab=chats|kautilya
|
||||
func (h *ChatHandler) Inbox(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
tab := r.URL.Query().Get("tab")
|
||||
items, err := h.svc.Inbox(r.Context(), userID, tab, atoiOr(r.URL.Query().Get("limit"), 0))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
type openDMReq struct {
|
||||
// Handle, not id: profiles are addressed by handle everywhere else, and
|
||||
// the public profile payload carries no user id.
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
|
||||
// OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing
|
||||
// thread when there is one, so the client can call it every time it opens a
|
||||
// profile without checking first.
|
||||
func (h *ChatHandler) OpenDM(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
var req openDMReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" {
|
||||
response.Error(w, http.StatusBadRequest, "handle is required")
|
||||
return
|
||||
}
|
||||
conv, err := h.svc.OpenDMByHandle(r.Context(), userID, req.Handle)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, conv)
|
||||
}
|
||||
|
||||
type createGroupReq struct {
|
||||
Title string `json:"title"`
|
||||
MemberHandles []string `json:"member_handles"`
|
||||
}
|
||||
|
||||
// CreateGroup — POST /chat/conversations/group
|
||||
func (h *ChatHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
var req createGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
conv, err := h.svc.CreateGroup(r.Context(), userID, req.Title, req.MemberHandles)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, conv)
|
||||
}
|
||||
|
||||
// OpenAIThread — POST /chat/conversations/kautilya
|
||||
func (h *ChatHandler) OpenAIThread(w http.ResponseWriter, r *http.Request) {
|
||||
conv, err := h.svc.OpenAIThread(r.Context(), middleware.UserID(r.Context()))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, conv)
|
||||
}
|
||||
|
||||
// Detail — GET /chat/conversations/{id}
|
||||
func (h *ChatHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||
detail, err := h.svc.Detail(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// Messages — GET /chat/conversations/{id}/messages?before_seq=&limit=
|
||||
//
|
||||
// before_seq is an exclusive upper bound rather than an offset, so paging stays
|
||||
// stable while new messages keep arriving at the top.
|
||||
func (h *ChatHandler) Messages(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
convID := chi.URLParam(r, "id")
|
||||
msgs, err := h.svc.Messages(r.Context(), userID, convID,
|
||||
int64(atoiOr(r.URL.Query().Get("before_seq"), 0)),
|
||||
atoiOr(r.URL.Query().Get("limit"), 0))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, msgs)
|
||||
}
|
||||
|
||||
type mediaReq struct {
|
||||
Key string `json:"key"`
|
||||
Mime string `json:"mime"`
|
||||
SizeBytes *int64 `json:"size_bytes"`
|
||||
Width *int `json:"width"`
|
||||
Height *int `json:"height"`
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
Blurhash *string `json:"blurhash"`
|
||||
FileName *string `json:"file_name"`
|
||||
}
|
||||
|
||||
type sendReq struct {
|
||||
Body string `json:"body"`
|
||||
ClientMsgID *string `json:"client_msg_id"`
|
||||
// preview_url is the link the sender chose to keep a card for; omitted
|
||||
// when they dismissed it. Only the URL — never the card's contents.
|
||||
PreviewURL *string `json:"preview_url"`
|
||||
Attachments []mediaReq `json:"attachments"`
|
||||
ReplyToID *string `json:"reply_to_id"`
|
||||
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
|
||||
// its own "can the caller see the source?" check in a later phase, and
|
||||
// accepting an unvalidated message reference in the meantime would let a
|
||||
// client point at any message in the system.
|
||||
}
|
||||
|
||||
// Send — POST /chat/conversations/{id}/messages
|
||||
func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
convID := chi.URLParam(r, "id")
|
||||
var req sendReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
media := make([]service.MediaAttachment, 0, len(req.Attachments))
|
||||
for _, a := range req.Attachments {
|
||||
media = append(media, service.MediaAttachment{
|
||||
Key: a.Key, Mime: a.Mime, SizeBytes: a.SizeBytes, Width: a.Width,
|
||||
Height: a.Height, DurationMs: a.DurationMs, Blurhash: a.Blurhash,
|
||||
FileName: a.FileName,
|
||||
})
|
||||
}
|
||||
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
|
||||
Body: req.Body,
|
||||
ClientMsgID: req.ClientMsgID,
|
||||
ReplyToID: req.ReplyToID,
|
||||
PreviewURL: req.PreviewURL,
|
||||
Attachments: media,
|
||||
})
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
// Settings — GET /chat/settings
|
||||
func (h *ChatHandler) Settings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := h.svc.Settings(r.Context(), middleware.UserID(r.Context()))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
type chatSettingsReq struct {
|
||||
ReadReceipts *bool `json:"read_receipts"`
|
||||
WhoCanMessage *string `json:"who_can_message"`
|
||||
MinAccountAgeMonths *int `json:"min_account_age_months"`
|
||||
TrainKautilya *bool `json:"train_kautilya"`
|
||||
}
|
||||
|
||||
// UpdateSettings — PATCH /chat/settings
|
||||
//
|
||||
// Fields are optional: anything omitted keeps its current value, so a toggle
|
||||
// doesn't have to send the whole object back.
|
||||
func (h *ChatHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
current, err := h.svc.Settings(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req chatSettingsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.ReadReceipts != nil {
|
||||
current.ReadReceipts = *req.ReadReceipts
|
||||
}
|
||||
if req.WhoCanMessage != nil {
|
||||
current.WhoCanMessage = *req.WhoCanMessage
|
||||
}
|
||||
if req.MinAccountAgeMonths != nil {
|
||||
current.MinAccountAgeMonths = *req.MinAccountAgeMonths
|
||||
}
|
||||
if req.TrainKautilya != nil {
|
||||
current.TrainKautilya = *req.TrainKautilya
|
||||
}
|
||||
|
||||
saved, err := h.svc.SaveSettings(r.Context(), userID, current)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, saved)
|
||||
}
|
||||
|
||||
type linkPreviewReq struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// LinkPreview — POST /chat/link-preview
|
||||
//
|
||||
// The composer calls this as a link is typed so the sender can see the card
|
||||
// and dismiss it before sending. Only the URL travels in either direction's
|
||||
// trust boundary: the BFF fetches the metadata itself.
|
||||
func (h *ChatHandler) LinkPreview(w http.ResponseWriter, r *http.Request) {
|
||||
var req linkPreviewReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
preview, err := h.svc.LinkPreview(r.Context(), req.URL)
|
||||
if err != nil {
|
||||
// No card is a normal outcome, not a server fault.
|
||||
response.Error(w, http.StatusUnprocessableEntity, "no preview available")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, preview)
|
||||
}
|
||||
|
||||
type chatPresignReq struct {
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// PresignAttachment — POST /chat/conversations/{id}/attachments/presign
|
||||
func (h *ChatHandler) PresignAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatPresignReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
upload, key, err := h.svc.PresignAttachment(r.Context(),
|
||||
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
// The key comes back so the client can name it when it sends the message;
|
||||
// there is no public URL, by design.
|
||||
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
|
||||
}
|
||||
|
||||
// Attachment — GET /chat/attachments/{attachmentID}
|
||||
//
|
||||
// A stable URL that redirects to a short-lived signed one. Membership is
|
||||
// re-checked on every fetch, so access revoked after a link was shared stops
|
||||
// working — which a signed URL baked into the message payload could not do.
|
||||
func (h *ChatHandler) Attachment(w http.ResponseWriter, r *http.Request) {
|
||||
url, err := h.svc.AttachmentURL(r.Context(),
|
||||
middleware.UserID(r.Context()), chi.URLParam(r, "attachmentID"))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
// Private and short-lived: never let a shared cache hold onto it.
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
// PresignGroupAvatar — POST /chat/conversations/{id}/avatar/presign
|
||||
func (h *ChatHandler) PresignGroupAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatPresignReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
upload, key, err := h.svc.PresignGroupAvatar(r.Context(),
|
||||
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
|
||||
}
|
||||
|
||||
// GroupAvatar — GET /chat/conversations/{id}/avatar
|
||||
//
|
||||
// Stable URL that redirects to a short-lived signed one, re-checking
|
||||
// membership each time, exactly as message attachments do.
|
||||
func (h *ChatHandler) GroupAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
url, err := h.svc.GroupAvatarURL(r.Context(),
|
||||
middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
// Gallery — GET /chat/conversations/{id}/gallery?tab=media|links&before=
|
||||
func (h *ChatHandler) Gallery(w http.ResponseWriter, r *http.Request) {
|
||||
var before *time.Time
|
||||
if raw := r.URL.Query().Get("before"); raw != "" {
|
||||
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
before = &t
|
||||
}
|
||||
}
|
||||
items, err := h.svc.Gallery(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), r.URL.Query().Get("tab"), before,
|
||||
atoiOr(r.URL.Query().Get("limit"), 0))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// Starred — GET /chat/conversations/{id}/starred
|
||||
func (h *ChatHandler) Starred(w http.ResponseWriter, r *http.Request) {
|
||||
msgs, err := h.svc.Starred(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), atoiOr(r.URL.Query().Get("limit"), 0))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, msgs)
|
||||
}
|
||||
|
||||
// Star / Unstar — POST|DELETE /chat/conversations/{id}/messages/{messageID}/star
|
||||
func (h *ChatHandler) Star(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStar(w, r, true)
|
||||
}
|
||||
|
||||
func (h *ChatHandler) Unstar(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStar(w, r, false)
|
||||
}
|
||||
|
||||
func (h *ChatHandler) setStar(w http.ResponseWriter, r *http.Request, starred bool) {
|
||||
err := h.svc.SetStar(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"), starred)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// DeleteMessage — DELETE /chat/conversations/{id}/messages/{messageID}?scope=me|everyone
|
||||
func (h *ChatHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
scope := r.URL.Query().Get("scope")
|
||||
if scope == "" {
|
||||
scope = service.DeleteForMe // the safer default
|
||||
}
|
||||
err := h.svc.DeleteMessage(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "messageID"), scope)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type forwardReq struct {
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
// Forward — POST /chat/conversations/{id}/forward
|
||||
//
|
||||
// The path names the DESTINATION; the body names the message being copied in.
|
||||
func (h *ChatHandler) Forward(w http.ResponseWriter, r *http.Request) {
|
||||
var req forwardReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
|
||||
response.Error(w, http.StatusBadRequest, "message_id is required")
|
||||
return
|
||||
}
|
||||
msg, err := h.svc.ForwardMessage(r.Context(), middleware.UserID(r.Context()),
|
||||
req.MessageID, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
type chatReportReq struct {
|
||||
Category string `json:"category"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ReportMessage — POST /chat/conversations/{id}/messages/{messageID}/report
|
||||
func (h *ChatHandler) ReportMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatReportReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
err := h.svc.ReportMessage(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "messageID"), req.Category, req.Reason)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type membersReq struct {
|
||||
// Handles, not ids: search results and follow lists identify people by
|
||||
// handle, and it matches /u/{handle}/block elsewhere in the API.
|
||||
MemberHandles []string `json:"member_handles"`
|
||||
}
|
||||
|
||||
// AddMembers — POST /chat/conversations/{id}/members
|
||||
func (h *ChatHandler) AddMembers(w http.ResponseWriter, r *http.Request) {
|
||||
var req membersReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.AddMembers(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), req.MemberHandles); err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// RemoveMember — DELETE /chat/conversations/{id}/members/{handle}
|
||||
//
|
||||
// Passing your own handle is how you leave.
|
||||
func (h *ChatHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.RemoveMember(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), chi.URLParam(r, "handle")); err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type roleReq struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// SetMemberRole — PATCH /chat/conversations/{id}/members/{userID}
|
||||
func (h *ChatHandler) SetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req roleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetMemberRole(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), chi.URLParam(r, "handle"), req.Role); err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type updateGroupReq struct {
|
||||
Title *string `json:"title"`
|
||||
SendPolicy *string `json:"send_policy"`
|
||||
// AvatarKey comes from PresignGroupAvatar; the service checks it belongs
|
||||
// to this group before storing it.
|
||||
AvatarKey *string `json:"avatar_key"`
|
||||
}
|
||||
|
||||
// UpdateGroup — PATCH /chat/conversations/{id}
|
||||
func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req updateGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateGroup(r.Context(), middleware.UserID(r.Context()),
|
||||
chi.URLParam(r, "id"), req.Title, req.SendPolicy, req.AvatarKey); err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type receiptReq struct {
|
||||
Seq int64 `json:"seq"`
|
||||
}
|
||||
|
||||
// MarkRead — POST /chat/conversations/{id}/read
|
||||
func (h *ChatHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
h.receipt(w, r, h.svc.MarkRead)
|
||||
}
|
||||
|
||||
// MarkDelivered — POST /chat/conversations/{id}/delivered
|
||||
func (h *ChatHandler) MarkDelivered(w http.ResponseWriter, r *http.Request) {
|
||||
h.receipt(w, r, h.svc.MarkDelivered)
|
||||
}
|
||||
|
||||
func (h *ChatHandler) receipt(w http.ResponseWriter, r *http.Request, apply func(ctx context.Context, userID, convID string, seq int64) error) {
|
||||
var req receiptReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := apply(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Seq); err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type muteReq struct {
|
||||
// Duration is one of 8h, 1w, always, off.
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SetMute — POST /chat/conversations/{id}/mute
|
||||
//
|
||||
// Muting silences alerts for the caller only; the thread keeps receiving
|
||||
// messages and its unread count keeps climbing.
|
||||
func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
|
||||
var req muteReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
until, err := h.svc.SetMute(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Duration)
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
|
||||
}
|
||||
|
||||
// Receipts — GET /chat/conversations/{id}/receipts
|
||||
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
|
||||
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, rc)
|
||||
}
|
||||
|
||||
func atoiOr(s string, fallback int) int {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user