feat: add im relationship flows

This commit is contained in:
richjohnrambo
2026-08-12 11:31:16 +08:00
parent 5bd35f97a6
commit d02041659e
11 changed files with 1100 additions and 12 deletions
+279
View File
@@ -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
}