feat: add im relationship flows
This commit is contained in:
@@ -127,6 +127,13 @@ type imMessageRevisionRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type imFriendRequestCreateRequest struct {
|
||||
TargetUserID string `json:"target_user_id"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
SourceTopic string `json:"source_topic,omitempty"`
|
||||
}
|
||||
|
||||
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -983,6 +990,163 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited"))
|
||||
}
|
||||
|
||||
func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
topic := conversationContextTopicFromPath(req.URL.Path)
|
||||
peer := peerFromP2PTopic(topic, principal.Uid)
|
||||
if topic == "" || peer.IsZero() {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||
return
|
||||
}
|
||||
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
||||
}
|
||||
|
||||
func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body imFriendRequestCreateRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
target := types.ParseUserId(body.TargetUserID)
|
||||
message := strings.TrimSpace(body.Message)
|
||||
if target.IsZero() || target == principal.Uid || len([]rune(message)) > 50 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
|
||||
if idempotencyKey == "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
requestID := "frq_" + store.Store.GetUidString()
|
||||
created, err := store.Relations.CreateFriendRequest(principal.TenantID, &types.FriendRequest{
|
||||
TenantID: principal.TenantID,
|
||||
RequestID: requestID,
|
||||
ApplicantID: principal.Uid,
|
||||
TargetID: target,
|
||||
Message: message,
|
||||
Source: strings.TrimSpace(body.Source),
|
||||
SourceTopic: strings.TrimSpace(body.SourceTopic),
|
||||
Status: types.FriendRequestPending,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, friendRequestPayload(created))
|
||||
}
|
||||
|
||||
func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, action := friendRequestActionFromPath(req.URL.Path)
|
||||
status := ""
|
||||
switch action {
|
||||
case "accept":
|
||||
status = types.FriendRequestAccepted
|
||||
case "reject":
|
||||
status = types.FriendRequestRejected
|
||||
default:
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "friend request action not found", nil)
|
||||
return
|
||||
}
|
||||
updated, err := store.Relations.ResolveFriendRequest(principal.TenantID, requestID, principal.Uid, status, time.Now().UTC().Round(time.Millisecond))
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, friendRequestPayload(updated))
|
||||
}
|
||||
|
||||
func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) {
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
peer := userIDFromTail(req.URL.Path, "/v1/im/blocks/")
|
||||
if peer.IsZero() || peer == principal.Uid {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||
return
|
||||
}
|
||||
switch req.Method {
|
||||
case http.MethodPut:
|
||||
if _, err := store.Relations.BlockUser(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
case http.MethodDelete:
|
||||
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
||||
}
|
||||
|
||||
func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodDelete {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
peer := userIDFromTail(req.URL.Path, "/v1/im/friends/")
|
||||
if peer.IsZero() || peer == principal.Uid {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||
return
|
||||
}
|
||||
if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
||||
}
|
||||
|
||||
func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -1413,6 +1577,68 @@ func messageMutationPayload(msg *types.Message, status string) map[string]any {
|
||||
return data
|
||||
}
|
||||
|
||||
func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any {
|
||||
data := map[string]any{
|
||||
"topic": ctx.Topic,
|
||||
"peer_user_id": ctx.PeerID.UserId(),
|
||||
"relationship": ctx.Relationship,
|
||||
"can_send": ctx.CanSend,
|
||||
"capabilities": map[string]any{
|
||||
"can_send_text": ctx.CanSend,
|
||||
"can_send_media": ctx.CanSend,
|
||||
"can_send_card": ctx.CanSend,
|
||||
"can_call": ctx.CanSend,
|
||||
"can_add_friend": ctx.Relationship == types.RelationshipNone,
|
||||
"can_handle_request": ctx.Relationship == types.RelationshipPendingInbound,
|
||||
},
|
||||
}
|
||||
if ctx.RequestID != "" {
|
||||
data["request_id"] = ctx.RequestID
|
||||
}
|
||||
if ctx.Peer != nil {
|
||||
data["peer"] = map[string]any{
|
||||
"user_id": ctx.PeerID.UserId(),
|
||||
"public": ctx.Peer.Public,
|
||||
"trusted": ctx.Peer.Trusted,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func friendRequestPayload(req *types.FriendRequest) map[string]any {
|
||||
data := map[string]any{
|
||||
"request_id": req.RequestID,
|
||||
"applicant_id": req.ApplicantID.UserId(),
|
||||
"target_user_id": req.TargetID.UserId(),
|
||||
"message": req.Message,
|
||||
"source": req.Source,
|
||||
"status": req.Status,
|
||||
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
||||
"topic": req.ApplicantID.P2PName(req.TargetID),
|
||||
}
|
||||
if req.SourceTopic != "" {
|
||||
data["source_topic"] = req.SourceTopic
|
||||
}
|
||||
if req.HandledAt != nil {
|
||||
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, types.ErrNotFound):
|
||||
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonMalformedRequest, "resource not found", nil)
|
||||
case errors.Is(err, types.ErrPermissionDenied):
|
||||
writeIMError(wrt, req, http.StatusForbidden, 20005, imReasonPermissionDenied, "permission denied", nil)
|
||||
case errors.Is(err, types.ErrMalformed):
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 20000, imReasonMalformedRequest, "malformed request", nil)
|
||||
default:
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func validIMRealName(name, idType, idNumber string) bool {
|
||||
if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" {
|
||||
return false
|
||||
@@ -1690,3 +1916,56 @@ func messageActionPath(path, action string) (string, int, bool) {
|
||||
}
|
||||
return topic, seq, true
|
||||
}
|
||||
|
||||
func conversationContextTopicFromPath(path string) string {
|
||||
const prefix = "/v1/im/conversations/"
|
||||
const suffix = "/context"
|
||||
if !strings.Contains(path, prefix) || !strings.HasSuffix(path, suffix) {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(path, prefix)
|
||||
return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
|
||||
}
|
||||
|
||||
func friendRequestActionFromPath(path string) (string, string) {
|
||||
const prefix = "/v1/im/friend-requests/"
|
||||
idx := strings.Index(path, prefix)
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path[idx:], prefix)
|
||||
requestID, action, ok := strings.Cut(rest, "/")
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(requestID), strings.TrimSpace(action)
|
||||
}
|
||||
|
||||
func userIDFromTail(path, prefix string) types.Uid {
|
||||
idx := strings.Index(path, prefix)
|
||||
if idx < 0 {
|
||||
return types.ZeroUid
|
||||
}
|
||||
return types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(path[idx:], prefix)))
|
||||
}
|
||||
|
||||
func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
|
||||
if types.GetTopicCat(topic) != types.TopicCatP2P || uid.IsZero() {
|
||||
return types.ZeroUid
|
||||
}
|
||||
prefix := "p2p"
|
||||
body := strings.TrimPrefix(topic, prefix)
|
||||
uidLen := len(uid.String())
|
||||
if len(body) < 2*uidLen {
|
||||
return types.ZeroUid
|
||||
}
|
||||
left := types.ParseUid(body[:uidLen])
|
||||
right := types.ParseUid(body[uidLen:])
|
||||
if left == uid {
|
||||
return right
|
||||
}
|
||||
if right == uid {
|
||||
return left
|
||||
}
|
||||
return types.ZeroUid
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user