Files
im_server/server/im_http.go
T

11253 lines
387 KiB
Go

package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"mime"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/push"
realnamealiyun "github.com/tinode/chat/server/realname/aliyun"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
)
const (
imReasonAPIKeyRequired = "API_KEY_REQUIRED"
imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
imReasonMalformedRequest = "MALFORMED_REQUEST"
imReasonTenantUnavailable = "TENANT_UNAVAILABLE"
imReasonVerificationRate = "VERIFICATION_RATE_LIMITED"
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
imReasonCodeInvalid = "CODE_INVALID"
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
imReasonAuthRequired = "AUTH_REQUIRED"
imReasonRealNameRequired = "REALNAME_REQUIRED"
imReasonActionToken = "ACTION_TOKEN_INVALID"
imReasonPermissionDenied = "PERMISSION_DENIED"
imReasonForbidden = "FORBIDDEN"
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
imReasonIdempotencyService = "IDEMPOTENCY_UNAVAILABLE"
imReasonInternal = "INTERNAL_ERROR"
imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
imReasonFileUnavailable = "FILE_UNAVAILABLE"
imReasonRateLimited = "RATE_LIMITED"
imReasonResourceNotFound = "RESOURCE_NOT_FOUND"
imReasonGroupDissolved = "GROUP_DISSOLVED"
imReasonValidationFailed = "VALIDATION_FAILED"
imReasonContentRejected = "CONTENT_REJECTED"
imReasonReviewUnavailable = "CONTENT_REVIEW_UNAVAILABLE"
)
const (
imDefaultTenantCode = "default"
registrationTypeEnterprise = 0
registrationTypePersonal = 1
)
const (
imHTTPIdempotencyTTL = 24 * time.Hour
imHTTPIdempotencyNamespace = "http_idempotency"
)
type imHTTPIdempotencyReplay struct {
BodyHash string `json:"body_hash"`
ResponseStatus int `json:"response_status"`
ResponseBody []byte `json:"response_body"`
}
var errIMHTTPIdempotencyConflict = errors.New("http idempotency conflict")
var (
beginIMHTTPIdempotency = redisBeginIMHTTPIdempotency
completeIMHTTPIdempotency = redisCompleteIMHTTPIdempotency
abortIMHTTPIdempotency = redisAbortIMHTTPIdempotency
)
func imHTTPIdempotencyID(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
return strconv.FormatInt(int64(tenantID), 10) + ":" + uid.String() + ":" + method + ":" + route + ":" + key
}
func redisBeginIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
claimed, sameValue, enabled, err := store.ClaimShortStateValue(imHTTPIdempotencyNamespace, id, bodyHash, imHTTPIdempotencyTTL)
if err != nil || !enabled || claimed {
return nil, claimed, enabled, err
}
if !sameValue {
return nil, false, true, errIMHTTPIdempotencyConflict
}
raw, found, enabled, err := store.GetShortStateValue(imHTTPIdempotencyNamespace, id+":response")
if err != nil || !enabled || !found {
return nil, false, enabled, err
}
var replay imHTTPIdempotencyReplay
if err = json.Unmarshal([]byte(raw), &replay); err != nil {
_, _ = store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
return nil, false, true, err
}
if replay.BodyHash != bodyHash {
return nil, false, true, nil
}
return &replay, false, true, nil
}
func redisCompleteIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
payload, err := json.Marshal(imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: body})
if err != nil {
return err
}
enabled, err := store.SetShortStateValue(imHTTPIdempotencyNamespace, id+":response", string(payload), imHTTPIdempotencyTTL)
if !enabled {
return errors.New("http idempotency redis unavailable")
}
return err
}
func redisAbortIMHTTPIdempotency(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
id := imHTTPIdempotencyID(tenantID, uid, method, route, key)
if _, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id); err != nil {
return err
}
_, err := store.DeleteShortState(imHTTPIdempotencyNamespace, id+":response")
return err
}
func reviewIMHTTPText(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, scene, topic, text string) bool {
text = strings.TrimSpace(text)
if text == "" {
return true
}
tenant, err := store.Tenants.GetByID(principal.TenantID)
if err != nil || tenant == nil {
logs.Warn.Printf("content moderation skipped because tenant code is unavailable: tenant=%d scene=%s err=%v", principal.TenantID, scene, err)
return true
}
result, err := moderation.Review(req.Context(), moderation.Request{TenantID: principal.TenantID, TenantCode: tenant.Code, Scene: scene, ActorID: principal.Uid, Topic: topic, Text: text, TraceID: req.Header.Get("X-Request-ID")})
if err == nil {
return true
}
reason, message := imReasonContentRejected, "内容不符合平台规范,请修改后重试"
status := http.StatusUnprocessableEntity
if errors.Is(err, moderation.ErrUnavailable) {
reason, message, status = imReasonReviewUnavailable, "内容审核暂不可用,请稍后重试", http.StatusServiceUnavailable
}
if result != nil && strings.TrimSpace(result.BlockMessage) != "" {
message = result.BlockMessage
}
writeIMError(wrt, req, status, 30060, reason, message, nil)
return false
}
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imSensitiveChallengeRateLimitEnabled = true
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
imQRCodeDefaultExpiresInSeconds = 86400
imQRCodeMaxExpiresInSeconds = 7 * 86400
imPublicUploadMaxBytes = 10 << 20
groupMaxAdminCount = 10
reportPreviewMaxRunes = 120
)
type imHTTPResponse struct {
Code int `json:"code"`
Data any `json:"data,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Details any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
ServerTime string `json:"server_time"`
}
type imHTTPContextKey int
const imHTTPProtocolValidated imHTTPContextKey = iota
func imReliableHTTPEventsEnabled(req *http.Request) bool {
enabled, _ := req.Context().Value(imHTTPProtocolValidated).(bool)
return enabled
}
func withIMHTTPProtocol(prefix string, next http.Handler) http.Handler {
return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
if !strings.HasPrefix(req.URL.Path, prefix) {
next.ServeHTTP(wrt, req)
return
}
requestID := strings.TrimSpace(req.Header.Get("X-Request-ID"))
if requestID == "" {
if token, err := randomIMToken(18); err == nil {
requestID = "req_" + token
} else {
requestID = fmt.Sprintf("req_%d", time.Now().UTC().UnixNano())
}
req.Header.Set("X-Request-ID", requestID)
}
wrt.Header().Set("X-Request-ID", requestID)
wrt.Header().Set("X-IM-Schema", "1")
if valid, _ := checkAPIKey(strings.TrimSpace(getAPIKey(req))); !valid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
if schema := strings.TrimSpace(req.Header.Get("X-IM-Schema")); schema != "1" {
writeIMError(wrt, req, http.StatusBadRequest, 30006, imReasonSchemaUnsupported, "unsupported IM schema", map[string]any{"supported": []int{1}})
return
}
req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true))
serveIMHTTPIdempotent(wrt, req, next)
})
}
type imBufferedResponseWriter struct {
header http.Header
status int
wroteHeader bool
body bytes.Buffer
}
func newIMBufferedResponseWriter() *imBufferedResponseWriter {
return &imBufferedResponseWriter{header: make(http.Header), status: http.StatusOK}
}
func (w *imBufferedResponseWriter) Header() http.Header { return w.header }
func (w *imBufferedResponseWriter) WriteHeader(status int) {
if w.wroteHeader {
return
}
w.status = status
w.wroteHeader = true
}
func (w *imBufferedResponseWriter) Write(data []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(data)
}
func (w *imBufferedResponseWriter) flushTo(wrt http.ResponseWriter) {
for key, values := range w.header {
for _, value := range values {
wrt.Header().Add(key, value)
}
}
wrt.WriteHeader(w.status)
_, _ = wrt.Write(w.body.Bytes())
}
func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http.Handler) {
if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodPatch && req.Method != http.MethodDelete {
next.ServeHTTP(wrt, req)
return
}
if req.Method == http.MethodPost && strings.TrimSpace(req.URL.Path) == "/v1/im/tenants/resolve" {
next.ServeHTTP(wrt, req)
return
}
if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") {
next.ServeHTTP(wrt, req)
return
}
principal, err := resolveIMHTTPBearer(req)
if err != nil {
next.ServeHTTP(wrt, req)
return
}
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if key == "" || len(key) > 128 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20+1))
if err != nil || len(body) > 4<<20 {
writeIMError(wrt, req, http.StatusRequestEntityTooLarge, 30000, imReasonMalformedRequest, "request body too large", nil)
return
}
req.Body = io.NopCloser(bytes.NewReader(body))
hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...)
bodyHash := sha256.Sum256(hashInput)
bodyHashText := fmt.Sprintf("%x", bodyHash[:])
existing, acquired, enabled, err := beginIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText)
if errors.Is(err, errIMHTTPIdempotencyConflict) {
writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil)
return
}
if err != nil {
logs.Warn.Printf("im http redis idempotency begin failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, err)
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
return
}
if !enabled {
logs.Warn.Printf("im http redis idempotency unavailable request_id=%s tenant=%d user=%s method=%s route=%s",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path)
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
return
}
if !acquired {
if existing == nil {
writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil)
return
}
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.Header().Set("X-Idempotent-Replay", "true")
wrt.WriteHeader(existing.ResponseStatus)
_, _ = wrt.Write(existing.ResponseBody)
return
}
buffered := newIMBufferedResponseWriter()
next.ServeHTTP(buffered, req)
if buffered.status >= http.StatusInternalServerError {
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
logs.Warn.Printf("im http redis idempotency abort failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
}
buffered.flushTo(wrt)
return
}
if err = completeIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key, bodyHashText, buffered.status, append([]byte(nil), buffered.body.Bytes()...)); err != nil {
logs.Warn.Printf("im http redis idempotency complete failed request_id=%s tenant=%d user=%s method=%s route=%s status=%d err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, buffered.status, err)
if abortErr := abortIMHTTPIdempotency(principal.TenantID, principal.Uid, req.Method, req.URL.Path, key); abortErr != nil {
logs.Warn.Printf("im http redis idempotency abort after complete failed request_id=%s tenant=%d user=%s method=%s route=%s err=%v",
req.Header.Get("X-Request-ID"), principal.TenantID, principal.Uid.UserId(), req.Method, req.URL.Path, abortErr)
}
writeIMError(wrt, req, http.StatusServiceUnavailable, 30050, imReasonIdempotencyService, "idempotency service unavailable", nil)
return
}
buffered.flushTo(wrt)
}
type imTenantResolveRequest struct {
TenantCode string `json:"tenant_code"`
Platform string `json:"platform,omitempty"`
AppVersion string `json:"app_version,omitempty"`
}
type imTenantApplicationRequest struct {
CreditCode string `json:"credit_code"`
EnterpriseEmail string `json:"enterprise_email,omitempty"`
LicenseFileID string `json:"license_file_id,omitempty"`
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
}
type imTenantEntryFeedbackRequest struct {
Description string `json:"description"`
Contact string `json:"contact,omitempty"`
TenantCodeHint string `json:"tenant_code_hint,omitempty"`
ScreenshotFileIDs []string `json:"screenshot_file_ids,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
}
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
RegistrationType *int `json:"registration_type,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
}
type imVerificationVerifyRequest struct {
Code string `json:"code"`
}
type imPasswordResetConfirmRequest struct {
ResetToken string `json:"reset_token"`
NewPassword string `json:"new_password"`
}
type imPrincipal struct {
TenantID types.TenantID
Uid types.Uid
}
type imSecurityChallengeRequest struct {
Scene string `json:"scene"`
Channel string `json:"channel"`
}
type imURLCheckRequest struct {
URL string `json:"url"`
Source string `json:"source,omitempty"`
}
type imRealNameSubmissionRequest struct {
Name string `json:"name"`
IDType string `json:"id_type"`
IDNumber string `json:"id_number"`
}
type imRealNameAppealRequest struct {
Reason string `json:"reason"`
Attachments []string `json:"attachments,omitempty"`
}
type imIndependentPasswordRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
ActionToken string `json:"action_token,omitempty"`
}
type imIndependentPasswordVerifyRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
Action string `json:"action,omitempty"`
}
type imYouthModeEnableRequest struct {
PolicyVersion int `json:"policy_version"`
ActionToken string `json:"action_token"`
}
type imDevicePushTokenRequest struct {
Provider string `json:"provider"`
Token string `json:"token"`
AppVersion string `json:"app_version,omitempty"`
Platform string `json:"platform,omitempty"`
SDKVersion string `json:"sdk_version,omitempty"`
PushEnv string `json:"push_env,omitempty"`
NotificationPermission string `json:"notification_permission,omitempty"`
}
type imAuthLogoutRequest struct {
DeviceID string `json:"device_id,omitempty"`
}
type imP2PConversationRequest struct {
PeerUserID string `json:"peer_user_id"`
}
type imConversationSettingsRequest struct {
Pinned *bool `json:"pinned,omitempty"`
Muted *bool `json:"muted,omitempty"`
MuteNotifications *bool `json:"mute_notifications,omitempty"`
Remark *string `json:"remark,omitempty"`
MessageRetention *string `json:"message_retention,omitempty"`
Version int `json:"version"`
}
type imTopicDraftRequest struct {
Content string `json:"content"`
Entities any `json:"entities,omitempty"`
Version int `json:"version"`
}
type imRiskChallengeConfirmRequest struct {
Decision string `json:"decision"`
}
type imAbuseReportRequest struct {
Target types.AbuseTarget `json:"target"`
ReasonCode string `json:"reason_code"`
Description string `json:"description,omitempty"`
EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"`
}
type imFeedbackRequest struct {
Category string `json:"category"`
Content string `json:"content"`
Contact string `json:"contact,omitempty"`
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
IncludeDiagnostics bool `json:"include_diagnostics,omitempty"`
}
type imSystemNotificationReadRequest struct {
ReadAt string `json:"read_at,omitempty"`
}
type imMessageRecallRequest struct {
Version int `json:"version"`
Reason string `json:"reason,omitempty"`
}
type imMessageBatchRecallRequest struct {
Items []imMessageRecallItem `json:"items"`
Reason string `json:"reason,omitempty"`
}
type imMessageRecallItem struct {
Seq int64 `json:"seq"`
Version int `json:"version,omitempty"`
Reason string `json:"reason,omitempty"`
}
type imMessageRevisionRequest struct {
Version int `json:"version"`
Content any `json:"content"`
ClientMid string `json:"client_mid,omitempty"`
Head types.KVMap `json:"head,omitempty"`
Reason string `json:"reason,omitempty"`
}
type imFavoritesBatchCreateRequest struct {
Messages []struct {
Topic string `json:"topic"`
Seq imForwardSeq `json:"seq"`
} `json:"messages"`
}
type imFavoritesBatchDeleteRequest struct {
FavoriteIDs []string `json:"favorite_ids"`
}
type imFavoritesForwardRequest struct {
FavoriteIDs []string `json:"favorite_ids"`
TargetTopics []string `json:"target_topics"`
Comment string `json:"comment,omitempty"`
}
type imMessagesForwardRequest struct {
Mode string `json:"mode"`
Sources []struct {
Topic string `json:"topic"`
Seq imForwardSeq `json:"seq"`
} `json:"sources"`
TargetTopics []string `json:"target_topics"`
Comment string `json:"comment,omitempty"`
}
type imForwardSeq int64
func (s *imForwardSeq) UnmarshalJSON(data []byte) error {
var number int64
if err := json.Unmarshal(data, &number); err == nil {
*s = imForwardSeq(number)
return nil
}
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
parsed, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return err
}
*s = imForwardSeq(parsed)
return nil
}
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"`
}
type imQRCodeCreateRequest struct {
Purpose string `json:"purpose"`
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
}
type imQRCodeResolveRequest struct {
Token string `json:"token"`
Purpose string `json:"purpose,omitempty"`
}
type imQRCodeClaims struct {
Version int `json:"v"`
TenantID types.TenantID `json:"tid"`
UserID string `json:"uid"`
Topic string `json:"topic,omitempty"`
Purpose string `json:"pur"`
Expires int64 `json:"exp"`
Nonce string `json:"non"`
}
type imGroupQRCodeCreateRequest struct {
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
}
type imFriendUpdateRequest struct {
Remark string `json:"remark"`
}
type imBlockBatchDeleteRequest struct {
UserIDs []string `json:"user_ids"`
}
type imGroupCreateRequest struct {
Name string `json:"name"`
MemberUserIDs []string `json:"member_user_ids,omitempty"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
Description string `json:"description,omitempty"`
}
type imGroupPatchRequest struct {
Name *string `json:"name,omitempty"`
AvatarFileID *string `json:"avatar_file_id,omitempty"`
Description *string `json:"description,omitempty"`
Version int `json:"version,omitempty"`
}
type imGroupMemberSettingsRequest struct {
Pinned *bool `json:"pinned,omitempty"`
MuteNotifications *bool `json:"mute_notifications,omitempty"`
Version int `json:"version,omitempty"`
}
type imGroupMemberProfileUpdateRequest struct {
Nickname string `json:"nickname"`
Version int `json:"version,omitempty"`
}
type imGroupInvitationCreateRequest struct {
UserIDs []string `json:"user_ids"`
Message string `json:"message,omitempty"`
}
type imGroupJoinRequestCreateRequest struct {
Message string `json:"message,omitempty"`
QRToken string `json:"qr_token,omitempty"`
Source string `json:"source,omitempty"`
}
type imGroupJoinRequestActionRequest struct {
Version int `json:"version,omitempty"`
}
type imGroupFileCreateRequest struct {
FileID string `json:"file_id"`
FileName string `json:"file_name,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
FileType string `json:"file_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
SourceTopic string `json:"source_topic,omitempty"`
SourceSeq int64 `json:"source_seq,omitempty"`
}
type imGroupAnnouncementPutRequest struct {
Content string `json:"content"`
Version int `json:"version,omitempty"`
}
type imGroupGovernancePatchRequest struct {
JoinApproval *bool `json:"join_approval,omitempty"`
MuteAll *bool `json:"mute_all,omitempty"`
MemberUploadFiles *bool `json:"member_upload_files,omitempty"`
MemberPrivateChat *bool `json:"member_private_chat,omitempty"`
MemberInvite *bool `json:"member_invite,omitempty"`
Version int `json:"version,omitempty"`
}
type imGroupAdminsBatchRequest struct {
UserIDs []string `json:"user_ids"`
Version int `json:"version,omitempty"`
}
type imGroupOwnerTransferRequest struct {
TargetUserID string `json:"target_user_id"`
Version int `json:"version,omitempty"`
}
type imGroupDissolveRequest struct {
Reason string `json:"reason,omitempty"`
}
type imGroupMuteBatchRequest struct {
Members []struct {
UserID string `json:"user_id"`
ExpiresAt string `json:"expires_at,omitempty"`
} `json:"members"`
UserIDs []string `json:"user_ids,omitempty"`
Reason string `json:"reason,omitempty"`
Version int `json:"version,omitempty"`
}
type imSystemMessageActor struct {
UserID string `json:"user_id,omitempty"`
Name string `json:"name,omitempty"`
Click map[string]any `json:"click,omitempty"`
}
type imSystemMessageAction struct {
Type string `json:"type"`
Label string `json:"label,omitempty"`
TargetID string `json:"target_id,omitempty"`
MinRole string `json:"min_role,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)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imTenantResolveRequest
if req.Body != nil {
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
err := dec.Decode(&body)
if err != nil {
if errors.Is(err, io.EOF) {
err = nil
}
}
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
}
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(body.TenantCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
"field": "tenant_code",
})
return
}
if store.Tenants == nil {
logs.Err.Println("im tenant resolve: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
endpoints := imPublicEndpoints(req)
writeIMSuccess(wrt, req, map[string]any{
"tenant": map[string]any{
"code": tenant.Code,
"name": tenant.Name,
"tenant_desc": tenant.TenantDesc,
"branding": map[string]any{},
},
"endpoints": endpoints,
"auth_methods": []string{"password", "sms"},
"password_policy": map[string]any{
"min_length": 8,
"max_length": 64,
},
"app_update": map[string]any{"type": "none"},
})
}
func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if store.Store == nil || store.Store.GetMediaHandler() == nil {
writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil)
return
}
uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader)
if !ok {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil)
return
}
req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes)
if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil)
return
}
purpose := strings.TrimSpace(req.FormValue("purpose"))
if !isAllowedPublicUploadPurpose(purpose) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
return
}
file, header, err := req.FormFile("file")
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"})
return
}
defer file.Close()
mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil)
return
}
if !isAllowedPublicUploadMime(purpose, mimeType) {
writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType})
return
}
token, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
publicFile := &media.PublicFileDef{
ID: "file_" + token,
Purpose: purpose,
MimeType: mimeType,
CreatedAt: now,
}
location, size, err := uploader.UploadPublic(publicFile, file)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if location != "" {
publicFile.Location = location
}
upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{
FileID: publicFile.ID,
Purpose: purpose,
MimeType: mimeType,
Size: size,
ETag: publicFile.ETag,
Location: publicFile.Location,
ExpiresAt: now.Add(24 * time.Hour),
CreatedAt: now,
UploadedAt: now,
})
if err != nil {
_ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location})
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, publicUploadPayload(upload))
}
func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
var body imTenantApplicationRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode))
email := strings.TrimSpace(body.EnterpriseEmail)
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
if !isValidCreditCode(creditCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"})
return
}
if email != "" && !isValidIMEmail(email) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"})
return
}
token, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{
ApplicationID: "entapp_" + token,
CreditCode: creditCode,
EnterpriseEmail: email,
LicenseFileID: strings.TrimSpace(body.LicenseFileID),
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
ClientContext: body.ClientContext,
IdempotencyKey: idempotencyKey,
Status: types.TenantApplicationStatusReceived,
EstimatedReviewHour: 24,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, tenantApplicationPayload(app))
}
func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
var body imTenantEntryFeedbackRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
description := strings.TrimSpace(body.Description)
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return
}
if len([]rune(description)) < 10 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"})
return
}
if len([]rune(description)) > 1000 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"})
return
}
if len(body.ScreenshotFileIDs) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"})
return
}
token, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{
FeedbackID: "entryfb_" + token,
Description: description,
Contact: strings.TrimSpace(body.Contact),
TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9),
ClientContext: body.ClientContext,
IdempotencyKey: idempotencyKey,
Status: types.TenantEntryFeedbackReceived,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback))
}
func imPublicEndpoints(req *http.Request) map[string]any {
base := imPublicHTTPBase(req)
httpBase := strings.TrimRight(base, "/")
wsBase := base
if strings.HasPrefix(wsBase, "https://") {
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
} else if strings.HasPrefix(wsBase, "http://") {
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
}
wsEndpoint := strings.TrimRight(wsBase, "/") + "/v0/channels"
if apiKey := strings.TrimSpace(getAPIKey(req)); apiKey != "" {
wsEndpoint += "?apikey=" + url.QueryEscape(apiKey)
}
return map[string]any{
"websocket": wsEndpoint,
"api": httpBase + "/v1/im",
"file": httpBase + "/v0/file",
"legal": map[string]any{
"user_terms": httpBase + "/legal/user-terms",
"privacy_policy": httpBase + "/legal/privacy-policy",
},
}
}
func imPublicHTTPBase(req *http.Request) string {
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
parsed, _ := url.Parse(configured)
basePath := ""
if parsed != nil {
basePath = strings.TrimRight(parsed.Path, "/")
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
}
}
if basePath == "" && req != nil && req.URL != nil {
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
}
}
scheme := "http"
if req != nil && req.TLS != nil {
scheme = "https"
}
if req != nil {
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
scheme = forwarded
}
}
host := ""
if req != nil {
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
if host == "" {
host = strings.TrimSpace(req.Host)
}
}
if host == "" && parsed != nil {
host = parsed.Host
}
if host == "" {
return configured
}
return strings.TrimRight(scheme+"://"+host+basePath, "/")
}
func isPublicEndpointHost(host string) bool {
host = strings.TrimSpace(host)
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
}
func firstForwardedHeader(value string) string {
if idx := strings.Index(value, ","); idx >= 0 {
value = value[:idx]
}
return strings.TrimSpace(strings.ToLower(value))
}
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imVerificationCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
scene := strings.TrimSpace(body.Scene)
if !isAllowedVerificationScene(scene) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"})
return
}
phone, err := normalizeIMPhone(body.Phone, body.Country)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
return
}
var tenant *types.Tenant
var ok bool
if scene == "register" {
tenant, ok = resolveIMHTTPRegistrationTenant(wrt, req, body)
} else {
tenant, ok = resolveIMHTTPTenant(wrt, req, body.TenantCode)
}
if !ok {
return
}
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
retryAfter := int(time.Until(active.ResendAfter).Seconds())
if retryAfter < 1 {
retryAfter = 1
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": retryAfter,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code := "123456"
deliveryEnabled := scene != "register" && registrationSMSEnabled(now)
vc := &types.VerificationCode{
TenantID: tenant.ID,
ChallengeID: challengeID,
Scene: scene,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
CodeHash: hashIMSecret(tenant.ID, scene, challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if scene == "reset_password" {
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
} else {
vc.TargetUser = uid
}
}
if err := store.Verifications.Create(tenant.ID, vc); err != nil {
logs.Warn.Println("im verification create: store failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if deliveryEnabled {
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
logs.Warn.Println("im verification delivery failed; keeping fixed test code active:", tenant.Code, scene, vc.PhoneMask, err)
}
} else {
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
}
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := verificationChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
code := strings.TrimSpace(body.Code)
inviteCandidate := isValidIMInviteCode(code)
if len(code) != imVerificationCodeLength && !inviteCandidate {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
logs.Warn.Println("im verification verify: lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenant.ID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if inviteCandidate {
if vc.Scene != "register" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
if err := validateRegistrationInvite(tenant.ID, code, now); err != nil {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"verification_mode": "invite_code",
"invite_accepted": true,
})
return
}
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"verification_token": token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := passwordResetChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password")
if !ok {
return
}
if vc.TargetUser.IsZero() {
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
return
}
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imPasswordResetConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".")
if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.TargetUser.IsZero() {
_ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond))
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
basicAuth := store.Store.GetLogicalAuthHandler("basic")
if basicAuth == nil || !basicAuth.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{
TenantID: tenant.ID,
Uid: vc.TargetUser,
AuthLevel: authLevel,
}, []byte(":"+body.NewPassword)); err != nil {
if err == types.ErrPolicy {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if store.Security == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil {
logs.Warn.Println("im password reset confirm: bump auth version failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: vc.TargetUser.String(),
Method: "tel",
Value: account.Phone,
Done: true,
}); credErr != nil {
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
}
} else if getErr != nil {
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(vc.TargetUser, "")
}
publishRealtimeEvictUser(tenant.ID, vc.TargetUser, "password_reset", "")
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func serveIMSecurityURLCheck(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 imURLCheckRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
decision, riskLevel, reason := classifyIMURLRisk(strings.TrimSpace(body.URL))
payload := map[string]any{
"url": strings.TrimSpace(body.URL),
"source": strings.TrimSpace(body.Source),
"decision": decision,
"risk_level": riskLevel,
"reason": reason,
}
if decision != "allow" {
challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano()))
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute)
if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{
ChallengeID: challengeID,
UserID: principal.Uid,
Action: "open_url",
Level: riskLevel,
NoticeCode: "url_" + riskLevel,
NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)},
State: types.RiskChallengeStatePending,
ExpiresAt: expiresAt,
}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload["risk_challenge_id"] = challengeID
payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMRiskChallengeConfirm(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
}
challengeID := riskChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"})
return
}
var body imRiskChallengeConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
decision := strings.ToLower(strings.TrimSpace(body.Decision))
var tokenID string
var token string
var expiresAt time.Time
var err error
if decision == "continue" {
token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tokenID, _, _ = strings.Cut(token, ".")
}
challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond))
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "risk challenge not found", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
payload := riskChallengePayload(challenge)
if decision == "continue" {
payload["risk_action_token"] = token
payload["token_action"] = "risk_continue"
payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano)
}
if imReliableHTTPEventsEnabled(req) {
eventType := "security.risk." + decision
eventID := stableIMEventID("risk", challengeID, decision, challenge.State)
if err = createIMSystemNotification(principal.TenantID, principal.Uid,
stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision,
types.KVMap{"risk_challenge_id": challengeID}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "user", principal.Uid.UserId(),
principal.Uid, eventType, map[string]any{"risk_challenge_id": challengeID, "decision": decision}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, payload)
}
func serveIMAbuseReasons(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
}
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
if !isAllowedIMAbuseTargetType(targetType) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_type"})
return
}
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil {
logs.Warn.Println("im abuse reasons: using defaults after store error", principal.TenantID, targetType, err)
reasons = defaultIMAbuseReasons(targetType)
}
if len(reasons) == 0 {
reasons = defaultIMAbuseReasons(targetType)
}
items := make([]map[string]any, 0, len(reasons))
for _, reason := range reasons {
items = append(items, abuseReasonPayload(reason))
}
writeIMSuccess(wrt, req, map[string]any{"items": items})
}
func serveIMAbuseReports(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 imAbuseReportRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
description := strings.TrimSpace(body.Description)
if len([]rune(description)) > 500 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil || len(reasons) == 0 {
reasons = defaultIMAbuseReasons(targetType)
}
reasonKnown := false
for _, reason := range reasons {
if strings.EqualFold(strings.TrimSpace(reason.ReasonCode), strings.TrimSpace(body.ReasonCode)) {
reasonKnown = true
if reason.RequiresDescription && description == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "description", "reason_code": reason.ReasonCode})
return
}
break
}
}
if !reasonKnown {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason_code"})
return
}
if len(body.EvidenceFileIDs) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"})
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{
ReportID: "ar_" + token,
ReporterID: principal.Uid,
TargetType: targetType,
TargetUserID: targetUser,
TargetTopic: targetTopic,
TargetSeq: targetSeq,
ReasonCode: strings.TrimSpace(body.ReasonCode),
Description: description,
EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9),
Snapshot: snapshot,
}, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, abuseReportPayload(report))
}
// imContactUsDictType is the admin dictionary type backing the mobile
// "contact us" entries.
const imContactUsDictType = "im_contact_us"
func serveIMContactUs(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if _, ok := authenticateIMHTTPBearer(wrt, req); !ok {
return
}
items, err := store.DictionaryConfig.Get(imContactUsDictType)
if err != nil {
if errors.Is(err, types.ErrUnsupported) || errors.Is(err, types.ErrNotFound) {
items = nil
} else {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
label := strings.TrimSpace(item.Label)
value := strings.TrimSpace(item.Value)
if label == "" || value == "" {
continue
}
payload = append(payload, map[string]any{"label": label, "value": value})
}
writeIMSuccess(wrt, req, map[string]any{"items": payload})
}
func serveIMFeedbackConfig(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
}
config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, feedbackConfigPayload(config))
}
func serveIMFeedback(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 imFeedbackRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
content := strings.TrimSpace(body.Content)
if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{
FeedbackID: "fb_" + token,
UserID: principal.Uid,
Category: strings.TrimSpace(body.Category),
Content: content,
Contact: strings.TrimSpace(body.Contact),
AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5),
ClientContext: body.ClientContext,
IncludeDiagnostics: body.IncludeDiagnostics,
}, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
}
func serveIMSecurityChallenges(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 imSecurityChallengeRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
action := strings.TrimSpace(body.Scene)
if !validIMSecurityChallengeScene(action) || strings.TrimSpace(body.Channel) != "sms" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
phone, err := currentUserPhone(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil)
return
}
createSensitiveChallenge(wrt, req, principal, action, phone)
}
func serveIMSecurityChallengeVerify(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
}
challengeID := securityChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action")
if !ok {
return
}
if vc.TargetUser != principal.Uid {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID))
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"action_token": token,
"action": vc.Action,
"expires_at": expiresAt.Format(time.RFC3339Nano),
})
}
func serveIMRealName(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
}
rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true})
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, realNamePayload(rv))
}
func serveIMRealNameSubmissions(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
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
var body imRealNameSubmissionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
name := strings.TrimSpace(body.Name)
idType := strings.TrimSpace(body.IDType)
idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber))
if !validIMRealName(name, idType, idNumber) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
current, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
if err == nil && (current.Status == "pending" || current.Status == "verified") {
writeIMSuccess(wrt, req, realNamePayload(current))
return
}
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
status, reviewReason, verifiedAt := initialRealNameSubmissionState(req, principal, name, idType, idNumber)
rv := &types.RealNameVerification{
TenantID: principal.TenantID,
UserID: principal.Uid,
SubmissionID: submissionID,
Status: status,
NameCipher: protectIMSensitive(principal.TenantID, name),
IDType: idType,
IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber),
IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"),
MaskedName: maskIMName(name),
MaskedIDNumber: maskIMIDNumber(idNumber),
ReviewReason: reviewReason,
VerifiedAt: verifiedAt,
}
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
if errors.Is(err, types.ErrDuplicate) {
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false})
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
response := map[string]any{"submission_id": submissionID, "status": status}
if status == "manual_review" {
response["status"] = "pending"
response["review_status"] = "manual_review"
}
writeIMSuccess(wrt, req, response)
}
func serveIMRealNameAppeals(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 imRealNameAppealRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
reason := strings.TrimSpace(body.Reason)
if reason == "" || len([]rune(reason)) > 500 || len(body.Attachments) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"})
return
}
attachments := make([]string, 0, len(body.Attachments))
for _, raw := range body.Attachments {
fileID := strings.TrimSpace(raw)
if fileID == "" {
continue
}
attachments = append(attachments, fileID)
}
encoded, err := json.Marshal(attachments)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
appeal, err := store.Security.CreateRealNameAppeal(principal.TenantID, &types.RealNameAppeal{
TenantID: principal.TenantID,
UserID: principal.Uid,
AppealID: "rna_" + token,
Reason: reason,
Attachments: attachments,
AttachmentsJSON: encoded,
Status: "pending",
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, realNameAppealPayload(appeal))
}
func serveIMBootstrap(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
}
status := "unverified"
if rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid); err == nil {
status = rv.Status
} else if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
audioCallEnabled, videoCallEnabled := imCallFeatureFlags()
writeIMSuccess(wrt, req, map[string]any{
"features": map[string]any{
"create_group": true,
"user_search": true,
"scan_qr": true,
"system_notifications": true,
"message_recall": true,
"message_edit": true,
"audio_call": audioCallEnabled,
"video_call": videoCallEnabled,
},
"permissions": map[string]any{
"create_group": true,
"user_search": true,
"scan_qr": true,
"audio_call": audioCallEnabled,
"video_call": videoCallEnabled,
},
"entry_badges": entryBadges,
"entries": entries,
"limits": map[string]any{
"max_file_size": globals.maxFileUploadSize,
"max_text_length": globals.maxMessageSize,
"max_forward_targets": 9,
},
"system_topic": map[string]any{
"topic": "sys",
"type": "system",
},
"message_policy": map[string]any{
"recall_window_seconds": 120,
"edit_window_seconds": 300,
"supported_actions": []string{"copy", "quote", "delete", "recall", "edit"},
},
"security": map[string]any{
"realname": map[string]any{
"status": status,
"required": false,
"allow_skip": true,
"remind_after": "24h",
},
},
})
}
func serveIMMe(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
}
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
account, err := currentUserAccountPayload(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
profilePayload := userProfilePayload10(profile)
applyDefaultUserAvatar(profilePayload, principal.Uid.UserId(), profile.Gender)
writeIMSuccess(wrt, req, map[string]any{
"user_id": principal.Uid.UserId(),
"username": account["username"],
"profile": profilePayload,
"profile_version": profile.Version,
"realname": map[string]any{"status": "unknown"},
"youth_mode": map[string]any{"enabled": false},
})
}
func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
profile, err := store.Profiles.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload, err := currentUserProfilePayload(principal.TenantID, principal.Uid, profile)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, payload)
case http.MethodPatch:
var body struct {
AvatarFileID *string `json:"avatar_file_id"`
Nickname *string `json:"nickname"`
BirthDate *string `json:"birth_date"`
Signature *string `json:"signature"`
Gender *string `json:"gender"`
Region *string `json:"region"`
Email *string `json:"email"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.BirthDate != nil && strings.TrimSpace(*body.BirthDate) != "" {
if date, err := parseIMDate(*body.BirthDate); err != nil || date.After(time.Now().UTC()) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "birth_date"})
return
}
}
if body.Nickname != nil && !reviewIMHTTPText(wrt, req, principal, moderation.SceneUserNickname, "", *body.Nickname) {
return
}
if body.Signature != nil && !reviewIMHTTPText(wrt, req, principal, moderation.SceneUserSignature, "", *body.Signature) {
return
}
if body.AvatarFileID != nil {
if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" {
if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
}
}
profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
AvatarFileID: body.AvatarFileID,
Nickname: body.Nickname,
BirthDate: body.BirthDate,
Signature: body.Signature,
Gender: body.Gender,
Region: body.Region,
Email: body.Email,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload, err := currentUserProfilePayload(principal.TenantID, principal.Uid, profile)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, payload)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
settings, err := store.Profiles.GetPrivacy(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
case http.MethodPatch:
var body struct {
AddMeMethods map[string]bool `json:"add_me_methods"`
ProfileVisibility *string `json:"profile_visibility"`
LastSeenVisibility *string `json:"last_seen_visibility"`
ReadReceipts *bool `json:"read_receipts"`
FriendRequestVerification *bool `json:"friend_request_verification"`
GroupInviteVerification *bool `json:"group_invite_verification"`
Version int `json:"version"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{
AddMeMethods: body.AddMeMethods,
ProfileVisibility: body.ProfileVisibility,
LastSeenVisibility: body.LastSeenVisibility,
ReadReceipts: body.ReadReceipts,
FriendRequestVerification: body.FriendRequestVerification,
GroupInviteVerification: body.GroupInviteVerification,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, userPrivacyPayload(settings))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMAppReleaseLatest(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
platform := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("platform")))
channel := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("channel")))
currentVersion := strings.TrimSpace(req.URL.Query().Get("current_version"))
if channel == "" {
channel = "stable"
}
if platform != "ios" && platform != "android" && platform != "web" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "platform"})
return
}
currentParts, err := parseNumericAppVersion(currentVersion)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "current_version"})
return
}
release, err := store.AppReleases.Latest(platform, channel)
if err != nil {
if platform == "web" {
logs.Warn.Println("im app release latest: using web fallback", err)
release = &types.AppRelease{
Platform: platform,
Channel: channel,
LatestVersion: currentVersion,
MinSupportedVersion: currentVersion,
UpdateType: "none",
ReleaseNotes: "当前已是最新版本。",
PublishedAt: time.Now().UTC(),
}
} else {
writeIMRelationError(wrt, req, err)
return
}
}
latestParts, latestErr := parseNumericAppVersion(release.LatestVersion)
minimumParts, minimumErr := parseNumericAppVersion(release.MinSupportedVersion)
if latestErr != nil || minimumErr != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 50000, imReasonInternal, "invalid app release configuration", nil)
return
}
hasUpdate := compareNumericAppVersion(currentParts, latestParts) < 0
updateType := "none"
if hasUpdate {
updateType = strings.ToLower(strings.TrimSpace(release.UpdateType))
if compareNumericAppVersion(currentParts, minimumParts) < 0 {
updateType = "force"
}
if updateType != "force" && updateType != "recommended" {
updateType = "none"
}
}
writeIMSuccess(wrt, req, appReleasePayload(release, currentVersion, hasUpdate, updateType))
}
func serveIMYouthMode(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
}
settings, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, youthModePayload(settings))
}
func serveIMYouthModeEnable(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 imYouthModeEnableRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.PolicyVersion <= 0 || strings.TrimSpace(body.ActionToken) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "enable_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, true, body.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, youthModePayload(settings))
}
func serveIMYouthModeDisable(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
}
actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token"))
if actionToken == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"})
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, "close_youth_mode", "youth_mode", time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
current, err := store.Security.GetYouthMode(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
settings, err := store.Security.SetYouthMode(principal.TenantID, principal.Uid, false, current.PolicyVersion, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, youthModePayload(settings))
}
func serveIMDevicePushToken(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPut {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
deviceID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/v1/im/devices/"))
deviceID = strings.TrimSuffix(deviceID, "/push-token")
if deviceID == "" || strings.Contains(deviceID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
return
}
var body imDevicePushTokenRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
token, err := store.DevicePushTokens.Upsert(principal.TenantID, &types.DevicePushToken{
UserID: principal.Uid,
DeviceID: deviceID,
Provider: body.Provider,
Token: body.Token,
AppVersion: body.AppVersion,
Platform: body.Platform,
SDKVersion: body.SDKVersion,
PushEnv: body.PushEnv,
NotificationPermission: body.NotificationPermission,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, devicePushTokenPayload(token))
}
func serveIMConversationSearch(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
}
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
start := 0
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
start = parsed
}
items := make([]map[string]any, 0, limit)
nextIndex := start
for i := start; i < len(subs); i++ {
if query != "" && !conversationMatches(subs[i], query) {
continue
}
items = append(items, conversationPayload(subs[i]))
nextIndex = i + 1
if len(items) == limit {
break
}
}
nextCursor := ""
if nextIndex < len(subs) {
nextCursor = strconv.Itoa(nextIndex)
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveIMP2PConversation(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 imP2PConversationRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID))
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"})
return
}
topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer)
if err != nil {
writeIMRelationError(wrt, req, err)
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 serveIMForwardTargets(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
}
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("query")))
if query == "" {
query = strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
}
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
start := 0
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
start = parsed
}
items := make([]map[string]any, 0, limit)
if start == 0 && forwardTargetMatches("slf", "文件助手", query) {
items = append(items, map[string]any{
"topic": "slf",
"type": "file_assistant",
"display_name": "文件助手",
"avatar_file_id": "",
"can_receive_forward": true,
})
}
nextIndex := start
for i := start; i < len(subs); i++ {
target := forwardTargetPayload(subs[i])
if target == nil {
nextIndex = i + 1
continue
}
if query != "" && !forwardTargetMatches(target["topic"].(string), target["display_name"].(string), query) && !conversationMatches(subs[i], query) {
continue
}
items = append(items, target)
nextIndex = i + 1
if len(items) == limit {
break
}
}
nextCursor := ""
if nextIndex < len(subs) {
nextCursor = strconv.Itoa(nextIndex)
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
}
func serveIMSystemNotifications(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
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": systemNotificationPayloads(items), "next_cursor": nextCursor})
}
func serveIMSystemNotificationRead(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
}
eventID := systemNotificationIDFromPath(req.URL.Path)
if eventID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"})
return
}
if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
var body imSystemNotificationReadRequest
if req.Body != nil && req.ContentLength != 0 {
if !decodeIMJSONBody(wrt, req, &body) {
return
}
}
readAt := time.Now().UTC().Round(time.Millisecond)
if body.ReadAt != "" {
parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"})
return
}
readAt = parsed.UTC().Round(time.Millisecond)
}
notification, err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := systemNotificationPayload(*notification)
payload["status"] = "ok"
writeIMSuccess(wrt, req, payload)
}
func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
serveIMFavoriteList(wrt, req)
case http.MethodPost:
serveIMFavoriteBatchCreate(wrt, req)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
itemPayload, ok := favoritePayloadForPrincipal(principal, item)
if ok {
payload = append(payload, itemPayload)
}
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imFavoritesBatchCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.Messages) == 0 || len(body.Messages) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"})
return
}
now := time.Now().UTC().Round(time.Millisecond)
created, existing := 0, 0
failed := make([]map[string]any, 0)
for _, src := range body.Messages {
sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic)
seq := int64(src.Seq)
if !sourceOK || seq <= 0 {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest})
continue
}
if err := requireTopicReader(principal, sourceTopic); err != nil {
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq)
if err != nil {
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
continue
}
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq)
fav := &types.FavoriteItem{
TenantID: principal.TenantID,
FavoriteID: "fav_" + store.Store.GetUidString(),
UserID: principal.Uid,
ItemType: imMessageType(msg),
SourceTopic: sourceTopic,
SourceSeq: seq,
Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: messageAvailability(*msg),
IdempotencyKey: idempotencyKey,
CollectedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
item, err := store.Favorites.Create(principal.TenantID, fav)
if err != nil {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": relationErrorReason(err)})
continue
}
if item.FavoriteID == fav.FavoriteID {
created++
} else {
existing++
}
}
writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed})
}
func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) {
const prefix = "/v1/im/favorites/"
idx := strings.Index(req.URL.Path, prefix)
if idx < 0 {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil)
return
}
tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/")
switch {
case tail == "batch":
serveIMFavoriteBatchCreate(wrt, req)
case tail == "batch-delete":
serveIMFavoriteBatchDelete(wrt, req)
case tail == "forward":
serveIMFavoriteForward(wrt, req)
default:
serveIMFavoriteDetail(wrt, req, tail)
}
}
func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload, ok := favoritePayloadForPrincipal(principal, *item)
if !ok {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
writeIMSuccess(wrt, req, payload)
case http.MethodDelete:
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFavoriteBatchDelete(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 imFavoritesBatchDeleteRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
results := make([]map[string]any, 0, len(body.FavoriteIDs))
for _, favoriteID := range body.FavoriteIDs {
favoriteID = strings.TrimSpace(favoriteID)
item := map[string]any{"favorite_id": favoriteID}
if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
item["ok"] = true
}
results = append(results, item)
}
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMFavoriteForward(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 imFavoritesForwardRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
comment := strings.TrimSpace(body.Comment)
for _, target := range body.TargetTopics {
rawTarget := strings.TrimSpace(target)
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
if target == "" || !targetOK {
results = append(results, map[string]any{"target_topic": rawTarget, "status": "failed", "reason": imReasonMalformedRequest})
continue
}
if err := requireTopicWriter(principal, target); err != nil {
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)})
continue
}
var commentResult map[string]any
commentSaved := false
ensureComment := func() map[string]any {
if commentSaved || comment == "" {
return commentResult
}
commentSaved = true
commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite-comment", target))
return commentResult
}
for _, favoriteID := range body.FavoriteIDs {
favoriteID = strings.TrimSpace(favoriteID)
item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID)
if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite", target, favoriteID)
head, content := favoriteForwardMessageHeadContent(item, clientMid)
msg := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: head,
Content: content,
}
if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
commentResult = ensureComment()
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
if err != nil {
if saved != nil && saved.Pending {
result := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
result["favorite_id"] = favoriteID
result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
}
if msg.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
result := forwardResultUnknownPayload(target, msg.SeqId, relationErrorReason(err))
result["favorite_id"] = favoriteID
result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
}
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
if saved != nil && saved.Pending {
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}
result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
}
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId, "client_mid": clientMid}
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
}
}
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMMessageForward(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 imMessagesForwardRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
mode := strings.TrimSpace(body.Mode)
if mode == "" {
mode = "separate"
}
if mode != "separate" && mode != "bundle" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "mode"})
return
}
if len(body.Sources) == 0 || len(body.Sources) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
sourceReadable := make(map[string]error)
targetWritable := make(map[string]error)
items := make([]map[string]any, 0, len(body.TargetTopics)*len(body.Sources))
failed := make([]map[string]any, 0)
for _, rawTarget := range body.TargetTopics {
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
if target == "" {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), strings.TrimSpace(rawTarget), imReasonMalformedRequest))
}
continue
}
if !targetOK {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), target, imReasonMalformedRequest))
}
continue
}
targetErr, ok := targetWritable[target]
if !ok {
targetErr = requireTopicWriter(principal, target)
targetWritable[target] = targetErr
}
if targetErr != nil {
for _, source := range body.Sources {
failed = append(failed, forwardFailurePayload(source.Topic, int64(source.Seq), target, relationErrorReason(targetErr)))
}
continue
}
comment := strings.TrimSpace(body.Comment)
var commentResult map[string]any
commentSaved := false
ensureComment := func() map[string]any {
if commentSaved || comment == "" {
return commentResult
}
commentSaved = true
commentResult = saveForwardCommentMessage(req.Context(), principal, target, comment, forwardClientMid(req.Header.Get("Idempotency-Key"), "message-comment", target))
return commentResult
}
if mode == "bundle" {
bundle, sourceFailures := buildForwardBundle(principal, body.Sources, target, sourceReadable, "")
if len(sourceFailures) > 0 {
failed = append(failed, sourceFailures...)
continue
}
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "bundle", target, len(body.Sources), body.Comment)
forwarded := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: forwardBundleMessageHead(clientMid),
Content: bundle,
}
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
continue
}
commentResult = ensureComment()
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
if err != nil {
if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
item := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
item["sources"] = bundle["source_refs"]
item["client_mid"] = clientMid
item["mode"] = "bundle"
item["bundle_id"] = bundle["bundle_id"]
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
if forwarded.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
item := forwardResultUnknownPayload(target, forwarded.SeqId, relationErrorReason(err))
item["sources"] = bundle["source_refs"]
item["client_mid"] = clientMid
item["mode"] = "bundle"
item["bundle_id"] = bundle["bundle_id"]
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
failed = append(failed, forwardFailurePayload("", 0, target, relationErrorReason(err)))
continue
}
if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{
"sources": bundle["source_refs"],
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
"status": "pending",
"reason": "MESSAGE_PERSIST_PENDING",
"mode": "bundle",
"bundle_id": bundle["bundle_id"],
}
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
forwarded.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{
"sources": bundle["source_refs"],
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
"status": "sent",
"mode": "bundle",
"bundle_id": bundle["bundle_id"],
}
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
for _, source := range body.Sources {
sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
continue
}
readErr, ok := sourceReadable[sourceTopic]
if !ok {
readErr = requireTopicReader(principal, sourceTopic)
sourceReadable[sourceTopic] = readErr
}
if readErr != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr)))
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq))
if err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
if msg == nil {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND"))
continue
}
if messageAvailability(*msg) != "available" {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE"))
continue
}
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "separate", target, sourceTopic, int64(source.Seq))
forwarded := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: forwardedMessageHead(msg, clientMid),
Content: forwardedMessageContent(msg, ""),
}
if err := prepareHTTPForwardMessage(principal.TenantID, forwarded); err != nil {
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
commentResult = ensureComment()
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, forwarded, nil, true)
if err != nil {
if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
item := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
item["source"] = map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}
item["client_mid"] = clientMid
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
if forwarded.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
item := forwardResultUnknownPayload(target, forwarded.SeqId, relationErrorReason(err))
item["source"] = map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}
item["client_mid"] = clientMid
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
failed = append(failed, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
"status": "pending",
"reason": "MESSAGE_PERSIST_PENDING",
}
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
continue
}
forwarded.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
"target_topic": target,
"client_mid": clientMid,
"seq": saved.SeqId,
"status": "sent",
}
mergeForwardCommentResult(item, commentResult)
items = append(items, item)
}
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "failed": failed})
}
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/draft"):
serveIMTopicDraft(wrt, req)
case strings.HasSuffix(req.URL.Path, "/calls"):
serveIMTopicCalls(wrt, req)
case strings.HasSuffix(req.URL.Path, "/message-mutations"):
serveIMMessageMutations(wrt, req)
case strings.HasSuffix(req.URL.Path, "/messages/search"):
serveIMMessageSearch(wrt, req)
case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"):
serveIMMessageContext(wrt, req)
case strings.HasSuffix(req.URL.Path, "/media"):
serveIMTopicMedia(wrt, req)
case strings.HasSuffix(req.URL.Path, "/receipts"):
serveIMMessageReceipts(wrt, req)
case strings.HasSuffix(req.URL.Path, "/messages/recall"):
serveIMMessageBatchRecall(wrt, req)
case strings.HasSuffix(req.URL.Path, "/recall"):
serveIMMessageRecall(wrt, req)
case strings.HasSuffix(req.URL.Path, "/revisions"):
serveIMMessageRevision(wrt, req)
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil)
}
}
func serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) {
topic := topicFromDraftPath(req.URL.Path)
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0})
return
}
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, topicDraftPayload(draft))
case http.MethodPut:
var body imTopicDraftRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
encoded, err := json.Marshal(body.Entities)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"})
return
}
if int64(len([]byte(body.Content))) > globals.maxMessageSize {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, topicDraftPayload(draft))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMCalls(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
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, "", strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
func serveIMTopicCalls(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromCallsPath(req.URL.Path))
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Calls.List(principal.TenantID, principal.Uid, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": callRecordPayloads(items, principal.Uid), "next_cursor": nextCursor})
}
func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path))
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(msgs))
for _, msg := range msgs {
items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q"))))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
}
func serveIMMessageMutations(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessageMutationsPath(req.URL.Path))
if !ok {
return
}
sinceVersion, err := parseIMMutationSince(req.URL.Query().Get("since_version"), req.URL.Query().Get("since"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "since_version"})
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
mutations, nextCursor, hasMore, mutationVersion, err := store.Messages.Mutations(principal.TenantID, topic, principal.Uid, sinceVersion, limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(mutations))
for _, mutation := range mutations {
items = append(items, messageMutationEventPayload(mutation))
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"items": items,
"next_cursor": nextCursor,
"has_more": hasMore,
"mutation_version": mutationVersion,
"expired": false,
})
}
func enqueueIMP2PMessageMutationEvents(tenantID types.TenantID, topic string, actor types.Uid, seq int64, version int, mutationEvent string) error {
if tenantID.IsZero() || topic == "" || actor.IsZero() || seq <= 0 || version <= 0 || mutationEvent == "" {
return types.ErrMalformed
}
uid1, uid2, err := types.ParseP2P(topic)
if err != nil {
return nil
}
payload := map[string]any{
"topic": topic,
"seq": seq,
"version": version,
"event": mutationEvent,
}
for _, uid := range []types.Uid{uid1, uid2} {
if uid.IsZero() {
continue
}
eventID := stableIMEventID("message-mutation", topic, seq, version, mutationEvent, uid.UserId())
if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, "message.mutation", payload); err != nil {
return err
}
}
return nil
}
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
topic, seq, ok := messageContextPath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
return
}
principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
if !ok {
return
}
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, normalizedTopic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(msgs))
for _, msg := range msgs {
items = append(items, messageContextPayload(msg))
}
writeIMSuccess(wrt, req, map[string]any{"topic": normalizedTopic, "anchor_seq": seq, "items": items})
}
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path))
if !ok {
return
}
mediaType := strings.TrimSpace(req.URL.Query().Get("type"))
if mediaType == "" {
mediaType = "image"
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(msgs))
for _, msg := range msgs {
items = append(items, mediaMessagePayload(msg))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
}
func serveIMMessageReceipts(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, seq, ok := messageReceiptsPath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil)
return
}
if seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"})
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200)
cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor"))
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
readItems := make([]map[string]any, 0, limit)
deliveredItems := make([]map[string]any, 0, limit)
nextCursor := 0
for i, member := range members {
if i < cursor {
continue
}
if len(readItems) >= limit && len(deliveredItems) >= limit {
nextCursor = i
break
}
item := receiptMemberPayload(member)
readReceipts := false
memberID := types.ZeroUid
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
memberID = types.Uid(decoded)
} else {
memberID = types.ParseUid(member.User)
}
if !memberID.IsZero() {
if privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, memberID); privacyErr == nil {
readReceipts = privacy.ReadReceipts
}
}
if readReceipts && member.ReadSeqId >= seq && len(readItems) < limit {
readItems = append(readItems, item)
}
if member.RecvSeqId >= seq && len(deliveredItems) < limit {
deliveredItems = append(deliveredItems, item)
}
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"seq": seq,
"read": readItems,
"delivered": deliveredItems,
"next_cursor": nextCursor,
})
}
// broadcastIMMessageRecalled pushes the native {pres what:"del" delseq} frame to
// the sessions currently attached to the topic so peers learn about a recall in
// real time (the REST response alone reaches only the caller). The wire record
// carries "low" only (no "hi"): client SDKs then flush the message from their
// local cache and leave a content-less tombstone behind, which the webapp
// renders as the usual "recalled" placeholder. Routed through the hub so the
// p2p topic rewrites the frame's topic name to each recipient's own view.
func broadcastIMMessageRecalled(tenantID types.TenantID, topic string, seqs []int64, actor types.Uid) {
if globals.hub == nil || topic == "" || actor.IsZero() {
return
}
delseq := make([]MsgRange, 0, len(seqs))
for _, seq := range seqs {
if seq > 0 {
delseq = append(delseq, MsgRange{LowId: seq})
}
}
if len(delseq) == 0 {
return
}
// P2P topics are loaded under their canonical name with xoriginal cleared,
// so the presence frame must carry an empty topic name to pass the
// handlePresence xoriginal check; any other category uses the topic name.
presTopic := topic
if types.GetTopicCat(topic) == types.TopicCatP2P {
presTopic = ""
}
select {
case globals.hub.routeSrv <- &ServerComMessage{
TenantID: tenantID,
RcptTo: topic,
Pres: &MsgServerPres{
Topic: presTopic,
What: "del",
Src: actor.UserId(),
DelSeq: delseq,
FilterIn: int(types.ModeRead),
},
}:
default:
logs.Warn.Printf("im message recall: hub presence queue full topic=%s count=%d", topic, len(delseq))
}
}
func serveIMMessageRecall(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
}
topic, seq, ok := messageActionPath(req.URL.Path, "recall")
if !ok || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRecallRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if !authorized {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil)
return
}
if time.Since(msg.CreatedAt) > 120*time.Second {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil)
return
}
version := body.Version
if version <= 0 {
version = msg.Version
}
recalledAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, version, recalledAt)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
logs.Warn.Printf("im message recall: failed to enqueue business event tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
} else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.recalled"); err != nil {
logs.Warn.Printf("im message recall: failed to enqueue p2p mutation events tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
}
}
broadcastIMMessageRecalled(principal.TenantID, updated.Topic, []int64{seq}, principal.Uid)
payload := messageMutationPayload(updated, "recalled")
if err != nil {
payload["event_status"] = "enqueue_failed"
}
writeIMSuccess(wrt, req, payload)
}
func serveIMMessageBatchRecall(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
}
topic, ok := topicMessagesActionPath(req.URL.Path, "recall")
if !ok {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageBatchRecallRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.Items) == 0 || len(body.Items) > 50 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "items must contain 1-50 messages", nil)
return
}
items := make([]map[string]any, 0, len(body.Items))
recalledSeqs := make([]int64, 0, len(body.Items))
recalledAt := time.Now().UTC().Round(time.Millisecond)
for _, item := range body.Items {
if item.Seq <= 0 {
items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request"))
continue
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, item.Seq, true)
if err != nil {
items = append(items, messageMutationErrorItem(item.Seq, err))
continue
}
if !authorized {
items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required"))
continue
}
if time.Since(msg.CreatedAt) > 120*time.Second {
items = append(items, messageBatchRecallErrorItem(item.Seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired"))
continue
}
version := item.Version
if version <= 0 {
version = msg.Version
}
updated, err := store.Messages.Recall(principal.TenantID, topic, item.Seq, principal.Uid, version, recalledAt)
if err != nil {
items = append(items, messageMutationErrorItem(item.Seq, err))
continue
}
result := messageMutationPayload(updated, "recalled")
if imReliableHTTPEventsEnabled(req) {
eventID := stableIMEventID("message", topic, item.Seq, updated.Version, "recalled")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.recalled", map[string]any{"topic": topic, "seq": item.Seq, "version": updated.Version}); err != nil {
result["event_status"] = "enqueue_failed"
} else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, item.Seq, updated.Version, "message.recalled"); err != nil {
result["event_status"] = "enqueue_failed"
}
}
items = append(items, result)
recalledSeqs = append(recalledSeqs, item.Seq)
}
broadcastIMMessageRecalled(principal.TenantID, topic, recalledSeqs, principal.Uid)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items})
}
func serveIMMessageRevision(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
}
topic, seq, ok := messageActionPath(req.URL.Path, "revisions")
if !ok || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
var body imMessageRevisionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.Version <= 0 || body.Content == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"})
return
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if !authorized {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil)
return
}
if time.Since(msg.CreatedAt) > 300*time.Second {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil)
return
}
if !isIMEditableTextMessage(msg) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil)
return
}
head := body.Head
if head == nil {
head = types.KVMap{}
}
head["type"] = "text"
if body.ClientMid != "" {
head["x-im-client-mid"] = body.ClientMid
}
scene := moderation.ScenePrivateMessage
if strings.HasPrefix(topic, "grp") {
scene = moderation.SceneGroupMessage
}
if !reviewIMHTTPText(wrt, req, principal, scene, topic, moderation.ExtractText(body.Content)) {
return
}
editedAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
eventID := stableIMEventID("message", topic, seq, updated.Version, "edited")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
logs.Warn.Printf("im message edit: failed to enqueue business event tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
} else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.edited"); err != nil {
logs.Warn.Printf("im message edit: failed to enqueue p2p mutation events tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
}
}
payload := messageMutationPayload(updated, "edited")
if err != nil {
payload["event_status"] = "enqueue_failed"
}
writeIMSuccess(wrt, req, payload)
}
func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, tail := conversationResourceFromPath(req.URL.Path)
if topic == "settings" && tail == "" {
serveIMConversationSettingsBatch(wrt, req, principal)
return
}
if tail == "settings" {
serveIMConversationSettings(wrt, req, principal, topic)
return
}
if tail == "messages" {
serveIMConversationMessages(wrt, req, principal, topic)
return
}
if tail != "context" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil)
return
}
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
topic, peer, err := resolveConversationContextTopic(principal, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
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 serveIMConversationSettingsBatch(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal) {
var topics []string
switch req.Method {
case http.MethodGet:
for _, part := range strings.Split(req.URL.Query().Get("topics"), ",") {
if value := strings.TrimSpace(part); value != "" {
topics = append(topics, value)
}
}
case http.MethodPost:
var body struct {
Topics []string `json:"topics"`
}
if !decodeIMJSONBody(wrt, req, &body) {
return
}
topics = body.Topics
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
topics = cleanStringList(topics, 100)
if len(topics) == 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topics"})
return
}
items := make([]map[string]any, 0, len(topics))
for _, rawTopic := range topics {
topic := normalizeConversationSettingsTopic(rawTopic, principal.Uid)
if topic == "" {
continue
}
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items = append(items, conversationSettingsPayload(settings))
}
writeIMSuccess(wrt, req, map[string]any{"items": items})
}
func serveIMConversationMessages(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
switch req.Method {
case http.MethodGet:
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
case http.MethodDelete:
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonForbidden, "permission denied", nil)
return
}
top, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if top == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "conversation not found", nil)
return
}
delID := top.DelId + 1
if delID <= 0 {
delID = int(time.Now().Unix())
}
var ranges []types.Range
if top.SeqId > 0 {
ranges = []types.Range{{Low: 1, Hi: top.SeqId + 1}}
}
if err = store.Messages.DeleteList(principal.TenantID, topic, delID, principal.Uid, 0, ranges); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "cleared": true, "deleted_id": delID})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) {
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
switch req.Method {
case http.MethodGet:
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
case http.MethodPatch:
var body imConversationSettingsRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
muted := body.Muted
if muted == nil {
muted = body.MuteNotifications
}
patch := types.ConversationSettingsPatch{
Pinned: body.Pinned,
Muted: muted,
Remark: body.Remark,
MessageRetention: body.MessageRetention,
Version: body.Version,
}
settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, conversationSettingsPayload(settings))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
serveIMFriendRequestList(wrt, req)
case http.MethodPost:
serveIMFriendRequestCreate(wrt, req)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMFriendRequestList(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
items, nextCursor, err := store.Relations.ListFriendRequests(principal.TenantID, principal.Uid,
strings.TrimSpace(req.URL.Query().Get("direction")),
strings.TrimSpace(req.URL.Query().Get("status")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, friendRequestListItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
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
}
relation, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, target, principal.Uid.P2PName(target))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if relation == nil || relation.Relationship != types.RelationshipNone {
reason := "FRIEND_REQUEST_CONFLICT"
if relation != nil && (relation.Relationship == types.RelationshipBlockedByMe || relation.Relationship == types.RelationshipBlockedByPeer) {
reason = imReasonPermissionDenied
}
writeIMError(wrt, req, http.StatusConflict, 30006, reason, "friend request is not allowed for current relationship", nil)
return
}
source := strings.TrimSpace(body.Source)
sourceTopic := strings.TrimSpace(body.SourceTopic)
switch source {
case "search", "qr", "profile_card":
if sourceTopic != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil)
return
}
case "group":
if types.GetTopicCat(sourceTopic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "valid group source_topic is required", nil)
return
}
requesterSub, requesterErr := store.Subs.Get(principal.TenantID, sourceTopic, principal.Uid, false)
targetSub, targetErr := store.Subs.Get(principal.TenantID, sourceTopic, target, false)
if requesterErr != nil || targetErr != nil {
if requesterErr != nil {
writeIMRelationError(wrt, req, requesterErr)
} else {
writeIMRelationError(wrt, req, targetErr)
}
return
}
if requesterSub == nil || targetSub == nil ||
!(requesterSub.ModeGiven & requesterSub.ModeWant).IsJoiner() || !(targetSub.ModeGiven & targetSub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil)
return
}
settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic)
if settingsErr != nil {
writeIMRelationError(wrt, req, settingsErr)
return
}
if settings == nil || !settings.MemberPrivateChat {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil)
return
}
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil)
return
}
if decision := imFriendAddPolicyDecision(principal.TenantID, "", principal.Uid, source); !decision.Allowed {
writeIMPolicyDecision(wrt, req, decision)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !imPrivacyAllowsAdd(privacy, body.Source) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "user does not accept friend requests from this source", 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: source,
SourceTopic: 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
}
if !privacy.FriendRequestVerification {
accepted, acceptErr := store.Relations.ResolveFriendRequest(principal.TenantID, created.RequestID, target, types.FriendRequestAccepted, now)
if acceptErr != nil {
writeIMRelationError(wrt, req, acceptErr)
return
}
created = accepted
}
if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request.created"
title := "新的好友申请"
content := message
if created != nil && created.Status == types.FriendRequestAccepted {
eventType = "friend.request.accepted"
title = "已成为好友"
content = "你们已成为好友,现在可以开始聊天了。"
publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(target),
"system_notice", "friend_added", content,
[]imSystemMessageActor{
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
{UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}},
}, nil, map[string]any{"request_id": created.RequestID})
}
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID,
eventType, title, content); err != nil {
logs.Warn.Printf("im friend request create: relation event failed request_id=%s friend_request_id=%s target=%s: %v",
req.Header.Get("X-Request-ID"), created.RequestID, target.UserId(), err)
}
}
writeIMSuccess(wrt, req, friendRequestPayload(created))
}
func serveIMFriends(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
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid,
strings.TrimSpace(req.URL.Query().Get("query")),
strings.TrimSpace(req.URL.Query().Get("initial")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
if item.Friendship.PeerID == principal.Uid {
continue
}
payload = append(payload, friendListItemPayload(principal.TenantID, principal.Uid, item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMGroups(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
serveIMGroupList(wrt, req)
case http.MethodPost:
serveIMGroupCreate(wrt, req)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
category := strings.TrimSpace(req.URL.Query().Get("category"))
switch category {
case "", "owned", "managed", "joined":
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "category"})
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
query := strings.TrimSpace(req.URL.Query().Get("query"))
subs, err := store.Users.GetSubs(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, sub := range subs {
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
continue
}
item := groupListItemPayload(sub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, sub.Topic); err == nil && settings != nil {
if settings.GroupStatus == types.GroupStatusDissolved {
// Defensive check: hide dissolved groups even if their subscription cleanup lagged.
continue
}
item = mergeGroupListSettingsPayload(item, settings)
}
if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category {
continue
}
if query != "" && !groupListItemMatches(item, query) {
continue
}
if seen < cursor {
seen++
continue
}
if len(items) == limit {
nextCursor = strconv.Itoa(seen)
break
}
items = append(items, item)
seen++
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""})
}
func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imGroupCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
name := strings.TrimSpace(body.Name)
if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
description := strings.TrimSpace(body.Description)
if len([]rune(description)) > 200 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupName, "", name) {
return
}
topicName := genTopicName()
now := time.Now().UTC().Round(time.Millisecond)
public := map[string]any{"name": name, "fn": name, "version": 1}
if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" {
public["avatar_file_id"] = avatar
}
if description != "" {
public["description"] = description
}
topic := &types.Topic{
ObjHeader: types.ObjHeader{Id: topicName},
TenantID: principal.TenantID,
State: types.StateOK,
Access: types.DefaultAccess{Auth: types.ModeCPublic, Anon: types.ModeNone},
Public: public,
}
topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull)
settings := &types.GroupSettings{
Topic: topicName,
OwnerUserID: principal.Uid,
Name: name,
AvatarFileID: strings.TrimSpace(body.AvatarFileID),
Description: description,
GroupStatus: types.GroupStatusActive,
MemberUploadFiles: true,
MemberPrivateChat: true,
MemberInvite: true,
MaxAdminCount: groupMaxAdminCount,
MemberCount: 1,
Version: 1,
CreatedBy: principal.Uid,
CreatedAt: now,
UpdatedAt: now,
}
if settings.AvatarFileID != "" {
if err := linkIMGroupAvatarAttachment(principal.TenantID, topicName, settings.AvatarFileID); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
return
}
}
memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs))
seen := map[types.Uid]bool{principal.Uid: true}
for _, rawID := range body.MemberUserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
memberIDs = append(memberIDs, uid)
}
memberPrivate := groupMemberPrivate("", false, false, 1, now)
if err := store.GroupBusiness.CreateGroup(principal.TenantID, topic, principal.Uid, memberPrivate,
settings, memberIDs, memberPrivate, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topicName, principal.Uid, "group.created",
map[string]any{"topic": topicName, "member_user_ids": userIDs(memberIDs)})
created, err := store.Topics.Get(principal.TenantID, topicName)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub))
}
func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
topic, tail, ok := groupResourcePath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
return
}
if tail == "settings" {
serveIMGroupSettings(wrt, req, topic)
return
}
if tail == "member-settings" || tail == "members/me/settings" {
serveIMGroupMemberSettings(wrt, req, topic)
return
}
if tail == "members/me/profile" {
serveIMGroupMyProfile(wrt, req, topic)
return
}
if tail == "leave" {
serveIMGroupLeave(wrt, req, topic)
return
}
if tail == "qrcodes" {
serveIMGroupQRCodes(wrt, req, topic)
return
}
if tail == "invitations" {
serveIMGroupInvitations(wrt, req, topic)
return
}
if tail == "join-requests" {
serveIMGroupJoinRequests(wrt, req, topic)
return
}
if strings.HasPrefix(tail, "join-requests/") {
serveIMGroupJoinRequestAction(wrt, req, topic, tail)
return
}
if tail == "summary" {
serveIMGroupSummary(wrt, req, topic)
return
}
if tail == "announcements/latest" || tail == "announcements/current" || strings.HasPrefix(tail, "announcements/") {
serveIMGroupAnnouncements(wrt, req, topic, tail)
return
}
if tail == "files" || strings.HasPrefix(tail, "files/") {
serveIMGroupFiles(wrt, req, topic, tail)
return
}
if tail == "governance" {
serveIMGroupGovernance(wrt, req, topic)
return
}
if tail == "role-limits" {
serveIMGroupRoleLimits(wrt, req, topic)
return
}
if tail == "admins/batch" || strings.HasPrefix(tail, "admins/") {
serveIMGroupAdmins(wrt, req, topic, tail)
return
}
if tail == "owner-transfer" {
serveIMGroupOwnerTransfer(wrt, req, topic)
return
}
if tail == "mutes" || tail == "mutes/batch" || strings.HasPrefix(tail, "mutes/") {
serveIMGroupMutes(wrt, req, topic, tail)
return
}
if tail == "members" {
serveIMGroupMembers(wrt, req, topic)
return
}
if strings.HasPrefix(tail, "members/") && !strings.HasSuffix(tail, "/profile") {
memberID := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "members/")))
if memberID.IsZero() {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
return
}
serveIMGroupMember(wrt, req, topic, memberID)
return
}
if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") {
memberID, ok := groupMemberProfilePath(tail)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
return
}
serveIMGroupMemberProfile(wrt, req, topic, memberID)
return
}
if tail == "" {
serveIMGroupDetail(wrt, req, topic)
return
}
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
}
func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) {
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
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
selfAccess := types.ModeNone
if selfSub != nil {
selfAccess = selfSub.ModeGiven & selfSub.ModeWant
}
if selfSub == nil || !selfAccess.IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
if cursor != "" {
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
}
if parsed, err := parseIMNonNegativeCursor(cursor); err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
query := strings.TrimSpace(req.URL.Query().Get("query"))
mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable"))
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{
Role: roleFilter,
EligibleRole: eligibleRole,
Query: query,
Cursor: cursor,
Limit: limit,
Mentionable: mentionableOnly,
})
if err != nil {
logs.Warn.Printf("im http: list group members failed tenant=%d topic=%s uid=%s limit=%d cursor=%q query=%q role=%q mentionable=%t err=%v",
principal.TenantID, topic, principal.Uid.UserId(), limit, cursor, query, roleFilter, mentionableOnly, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, members)
hideFriendEntrypoint := !imFriendGlobalDisabledDecision(principal.TenantID, "", "group").Allowed
items := make([]map[string]any, 0, limit)
for _, member := range members {
access := groupMemberAccess(member)
item := groupBusinessMemberPayload(member, remarks[member.UserID])
if hideFriendEntrypoint && member.UserID != principal.Uid {
maskIMGroupMemberProfileEntrypoint(item)
}
if eligibleRole != "" {
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
item["eligible"] = eligible
if reason != "" {
item["ineligible_reason"] = reason
}
}
if muteEligible {
eligible, reason := groupMuteEligibility(selfAccess, access)
item["eligible"] = eligible
if reason != "" {
item["ineligible_reason"] = reason
}
}
items = append(items, item)
}
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
}
payload := map[string]any{
"topic": topic,
"items": items,
"next_cursor": nextCursor,
"sync_mode": "full",
"permissions": permissions,
}
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
if req.Method != http.MethodGet && req.Method != http.MethodDelete {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
member, err := store.GroupBusiness.GetMember(principal.TenantID, topic, memberID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
if member == nil || member.MemberStatus != types.GroupMemberStatusActive {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
if req.Method == http.MethodDelete {
if ok, reason := canRemoveIMGroupMember(selfSub, principal.Uid, *member); !ok {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": memberID.UserId(), "reason": reason})
return
}
if err := store.GroupBusiness.Leave(principal.TenantID, topic, memberID, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.left",
map[string]any{"user_id": memberID.UserId(), "removed_by": principal.Uid.UserId(), "member_sync": "single"})
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "user_id": memberID.UserId(), "status": "left"})
return
}
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
member.ModeWant = memberSub.ModeWant
member.ModeGiven = memberSub.ModeGiven
if user, userErr := store.Users.Get(principal.TenantID, memberID); userErr == nil && user != nil {
member.Public = user.Public
member.Trusted = user.Trusted
}
remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, []types.GroupMember{*member})
payload := groupBusinessMemberPayload(*member, remarks[member.UserID])
payload["topic"] = topic
payload["sync_mode"] = "single"
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
if req.Method == http.MethodGet {
if writeIMGroupDissolvedIfGone(wrt, req, principal.TenantID, topic) {
return
}
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return
}
switch req.Method {
case http.MethodGet:
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
payload := groupDetailPayload(group, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
if settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
return
}
payload = mergeGroupSettingsPayload(payload, settings)
}
writeIMSuccess(wrt, req, payload)
case http.MethodPatch:
access := selfSub.ModeGiven & selfSub.ModeWant
if !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupPatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
public := groupPublicMap(group.Public)
currentVersion := intFromAny(public["version"])
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil && settings != nil && settings.Version > 0 {
currentVersion = settings.Version
}
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if len([]rune(name)) < 1 || len([]rune(name)) > 32 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupName, topic, name) {
return
}
public["name"] = name
public["fn"] = name
}
if body.AvatarFileID != nil {
avatar := strings.TrimSpace(*body.AvatarFileID)
if avatar != "" {
if err := linkIMGroupAvatarAttachment(principal.TenantID, topic, avatar); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
return
}
}
public["avatar_file_id"] = avatar
}
if body.Description != nil {
description := strings.TrimSpace(*body.Description)
if len([]rune(description)) > 200 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
public["description"] = description
}
public["version"] = currentVersion + 1
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": public}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version}
settingsUpdated := true
if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil {
if errors.Is(err, types.ErrNotFound) {
settingsUpdated = false
logs.Warn.Printf("group[%s]: business settings missing while patching group profile; kept topic public update", topic)
} else {
if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.settings.updated",
map[string]any{"topic": topic})
updated, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupDetailPayload(updated, selfSub)
if settingsUpdated {
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
}
}
writeIMSuccess(wrt, req, payload)
case http.MethodDelete:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
if !consumeIMActionTokenHeader(wrt, req, principal, "group_dissolve") {
return
}
var body imGroupDissolveRequest
if req.Body != nil && req.Body != http.NoBody {
if !decodeIMJSONBody(wrt, req, &body) {
return
}
}
settings, err := store.GroupBusiness.Dissolve(principal.TenantID, topic, principal.Uid, strings.TrimSpace(body.Reason), time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.dissolved",
map[string]any{"topic": topic})
payload := map[string]any{"topic": topic, "deleted": true}
if settings != nil {
payload["status"] = settings.GroupStatus
payload["version"] = settings.Version
}
writeIMSuccess(wrt, req, payload)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func writeIMGroupDissolvedIfGone(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) bool {
if settings, err := store.GroupBusiness.GetSettings(tenantID, topic); err == nil && settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
return true
}
group, err := store.Topics.Get(tenantID, topic)
if err != nil || group == nil || group.State != types.StateDeleted {
return false
}
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
return true
}
func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
payload := groupSettingsPayload(group, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPatch {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
var body imGroupMemberSettingsRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
private := groupPrivateMap(selfSub.Private)
currentVersion := intFromAny(private["version"])
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
if body.Pinned != nil {
private["pinned"] = *body.Pinned
}
if body.MuteNotifications != nil {
private["mute_notifications"] = *body.MuteNotifications
}
private["version"] = currentVersion + 1
private["updated_at"] = time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}); err != nil {
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back member settings: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
map[string]any{"user_id": principal.Uid.UserId()})
selfSub.Private = private
writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub))
}
func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPut && req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
private := groupPrivateMap(selfSub.Private)
currentVersion := intFromAny(private["profile_version"])
if currentVersion <= 0 {
currentVersion = 1
}
if req.Method == http.MethodGet {
writeIMSuccess(wrt, req, map[string]any{
"nickname": stringFromAny(private["nickname"]),
"version": currentVersion,
"updated_at": stringFromAny(private["profile_updated_at"]),
})
return
}
var body imGroupMemberProfileUpdateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
nickname := strings.TrimSpace(body.Nickname)
if len([]rune(nickname)) > 32 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupNickname, topic, nickname) {
return
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
private["nickname"] = nickname
private["profile_version"] = currentVersion + 1
updatedAt := time.Now().UTC().Round(time.Millisecond)
private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano)
if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
if errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
map[string]any{"user_id": principal.Uid.UserId()})
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
return
}
if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil {
logs.Warn.Printf("group[%s]: failed to roll back member profile: %v", topic, rollbackErr)
}
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated",
map[string]any{"user_id": principal.Uid.UserId()})
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
}
func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if (selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil)
return
}
if err := store.GroupBusiness.Leave(principal.TenantID, topic, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.left",
map[string]any{"user_id": principal.Uid.UserId()})
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"})
}
func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupQRCodeCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
expiresIn := body.ExpiresInSeconds
if expiresIn <= 0 {
expiresIn = imQRCodeDefaultExpiresInSeconds
}
if expiresIn > imQRCodeMaxExpiresInSeconds {
expiresIn = imQRCodeMaxExpiresInSeconds
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
groupPayload := groupDetailPayload(group, selfSub)
writeIMSuccess(wrt, req, map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group",
"expires_at": expiresAt.Format(time.RFC3339Nano),
"group": groupPayload,
"group_snapshot": groupPayload,
})
}
func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
access := selfSub.ModeGiven & selfSub.ModeWant
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !access.IsAdmin() {
if settings == nil || !settings.MemberInvite {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member invitation is disabled", nil)
return
}
}
if !access.IsSharer() && !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupInvitationCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
now := time.Now().UTC().Round(time.Millisecond)
for _, rawID := range body.UserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
if uid.IsZero() || uid == principal.Uid {
item["status"] = "failed"
item["reason"] = imReasonMalformedRequest
results = append(results, item)
continue
}
item["user_id"] = uid.UserId()
targetSub, err := store.Subs.Get(principal.TenantID, topic, uid, false)
if err != nil && !errors.Is(err, types.ErrNotFound) {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
if targetSub != nil && targetSub.DeletedAt == nil {
item["status"] = "already_member"
results = append(results, item)
continue
}
targetRequiresApproval := settings != nil && settings.JoinApproval
if !targetRequiresApproval && store.Profiles != nil {
privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, uid)
if privacyErr != nil {
if errors.Is(privacyErr, types.ErrNotFound) || errors.Is(privacyErr, types.ErrUnsupported) {
privacyErr = nil
}
}
if privacyErr != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(privacyErr)
results = append(results, item)
continue
}
targetRequiresApproval = privacy != nil && privacy.GroupInviteVerification
}
joinRequestID := ""
invitationID := ""
if targetRequiresApproval {
invitationID = "ginv_" + store.Store.GetUidString()
joinRequestID = "gjr_" + store.Store.GetUidString()
}
if !targetRequiresApproval {
joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid,
strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")),
groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
if joined {
item["status"] = types.GroupInvitationStatusJoined
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
map[string]any{"user_id": uid.UserId(), "joined": true, "source": "direct_invitation"})
if imReliableHTTPEventsEnabled(req) {
publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, uid,
"有成员通过邀请加入了群聊。",
map[string]any{"source": "direct_invitation"})
}
} else {
item["status"] = "already_member"
}
results = append(results, item)
continue
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey != "" {
idempotencyKey += ":" + uid.UserId()
} else {
idempotencyKey = invitationID
}
inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{
InvitationID: invitationID,
Topic: topic,
InviterUserID: principal.Uid,
TargetUserID: uid,
Message: strings.TrimSpace(body.Message),
Status: types.GroupInvitationStatusPending,
Result: "",
JoinRequestID: joinRequestID,
IdempotencyKey: idempotencyKey,
ExpiresAt: now.Add(7 * 24 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
item["invitation_id"] = inv.InvitationID
item["status"] = groupInvitationDisplayStatus(inv)
item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano)
if inv.JoinRequestID != "" {
item["join_request_id"] = inv.JoinRequestID
}
publishIMGroupInvitationNotification(principal.TenantID, inv)
results = append(results, item)
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.invitations.updated",
map[string]any{"topic": topic, "items": results})
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results})
}
func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
access := selfSub.ModeGiven & selfSub.ModeWant
switch {
case tail == "files" && req.Method == http.MethodGet:
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic,
strings.TrimSpace(req.URL.Query().Get("type")),
strings.TrimSpace(req.URL.Query().Get("q")),
strings.TrimSpace(req.URL.Query().Get("cursor")),
limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"items": groupFilePayloads(items),
"next_cursor": nextCursor,
"has_more": nextCursor != "",
})
case tail == "files" && req.Method == http.MethodPost:
if !access.IsAdmin() {
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if settings == nil || !settings.MemberUploadFiles {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member file upload is disabled", nil)
return
}
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
}
var body imGroupFileCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
fileID := normalizeIMUploadedFileID(body.FileID)
if fileID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
fileName := strings.TrimSpace(body.FileName)
mimeType := strings.TrimSpace(body.MIMEType)
fileType := normalizeIMFileType(body.FileType)
if fileName == "" || len([]rune(fileName)) > 255 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_name", "max_length": 255})
return
}
if len([]rune(mimeType)) > 128 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "mime_type", "max_length": 128})
return
}
if body.FileSize < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"})
return
}
uploaded, err := store.Files.Get(principal.TenantID, fileID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if uploaded == nil || uploaded.Status != types.UploadCompleted {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonFileUnavailable, "file unavailable", map[string]any{"field": "file_id"})
return
}
if body.FileSize > 0 && uploaded.Size > 0 && body.FileSize != uploaded.Size {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "file_size"})
return
}
if globals.maxFileUploadSize > 0 && uploaded.Size > globals.maxFileUploadSize {
writeIMError(wrt, req, http.StatusBadRequest, 30046, "FILE_TOO_LARGE", "file too large", map[string]any{"field": "file_size", "max_size": globals.maxFileUploadSize})
return
}
if mimeType == "" {
mimeType = uploaded.MimeType
}
fileSize := body.FileSize
if fileSize == 0 {
fileSize = uploaded.Size
}
file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{
Topic: topic,
FileID: fileID,
SourceTopic: strings.TrimSpace(body.SourceTopic),
SourceSeq: body.SourceSeq,
UploaderID: principal.Uid,
FileName: fileName,
MIMEType: mimeType,
FileType: fileType,
FileSize: fileSize,
Status: "active",
IdempotencyKey: idempotencyKey,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileCreate, file, principal.Uid); err != nil {
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
}
writeIMSuccess(wrt, req, groupFilePayload(file))
case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete:
groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/"))
if groupFileID == "" || strings.Contains(groupFileID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
}
canManage := access.IsAdmin() || access.IsOwner()
file, err := store.GroupBusiness.DeleteFile(principal.TenantID, topic, groupFileID, principal.Uid, canManage, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if err = enqueueKafkaGroupFileLifecycle(req.Context(), principal.TenantID, kafkaFileLifecycleGroupFileDelete, file, principal.Uid); err != nil {
logs.Warn.Printf("im http: failed to enqueue group file lifecycle event topic=%s file=%s: %v", topic, file.FileID, err)
}
writeIMSuccess(wrt, req, groupFilePayload(file))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) {
invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path)
if !ok || tail != "accept" {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil)
return
}
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
}
now := time.Now().UTC().Round(time.Millisecond)
inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, inv.Topic,
"system_action", "group_member_joined", "有成员通过邀请加入了群聊。",
[]imSystemMessageActor{
{UserID: inv.InviterUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.InviterUserID.UserId()}},
{UserID: inv.TargetUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": inv.TargetUserID.UserId()}},
},
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: inv.Topic}},
map[string]any{"invitation_id": inv.InvitationID, "source": "invitation"})
}
publishIMGroupRealtimeEvent(principal.TenantID, inv.Topic, principal.Uid, "group.member.joined",
map[string]any{"user_id": principal.Uid.UserId(), "invitation_id": inv.InvitationID})
writeIMSuccess(wrt, req, groupInvitationPayload(inv))
}
func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method == http.MethodPost {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
} else {
writeIMRelationError(wrt, req, err)
}
return
}
if settings == nil || settings.GroupStatus != types.GroupStatusActive {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
if !settings.JoinApproval {
now := time.Now().UTC().Round(time.Millisecond)
joined, joinErr := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, principal.Uid,
"", strings.TrimSpace(req.Header.Get("Idempotency-Key")), groupMemberPrivate("", false, false, 1, now), now)
if joinErr != nil {
writeIMRelationError(wrt, req, joinErr)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
map[string]any{"user_id": principal.Uid.UserId(), "joined": joined, "source": "open_group"})
if joined && imReliableHTTPEventsEnabled(req) {
publishIMGroupMemberJoinedSystemNotice(principal.TenantID, topic, principal.Uid, principal.Uid,
"有成员加入了群聊。",
map[string]any{"source": "open_group"})
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "joined", "joined": joined, "source": "open_group"})
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if sub != nil && (sub.ModeGiven & sub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusConflict, 30006, "ALREADY_GROUP_MEMBER", "already a group member", nil)
return
}
var body imGroupJoinRequestCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
now := time.Now().UTC().Round(time.Millisecond)
requestID := "gjr_" + store.Store.GetUidString()
item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{
RequestID: requestID, Topic: topic, ApplicantUserID: principal.Uid,
Source: defaultString(strings.TrimSpace(body.Source), "manual"), Message: strings.TrimSpace(body.Message),
Status: types.GroupJoinRequestStatusPending, IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
Version: 1, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.join_request.created",
map[string]any{"request_id": item.RequestID, "user_id": principal.Uid.UserId()})
writeIMSuccess(wrt, req, groupJoinRequestPayload(item))
return
}
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if req.Method == http.MethodGet {
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
status := strings.TrimSpace(req.URL.Query().Get("status"))
if status == "" {
status = "pending"
}
items, nextCursor, err := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, status, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
if err != nil {
if !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
items = nil
}
items = dedupePendingGroupJoinRequests(items, status)
payloads, err := groupJoinRequestPayloadsWithUsers(principal.TenantID, items)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": payloads, "next_cursor": nextCursor})
}
}
func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
requestID, action, ok := groupJoinRequestActionPath(tail)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request route not found", nil)
return
}
var body imGroupJoinRequestActionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
now := time.Now().UTC().Round(time.Millisecond)
updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid,
action, "", body.Version, groupMemberPrivate("", false, false, 1, now), now)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if updated.Status == types.GroupJoinRequestStatusAccepted && imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_action", "group_member_joined", "有成员通过审批加入了群聊。",
[]imSystemMessageActor{
{UserID: updated.ApplicantUserID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantUserID.UserId()}},
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
},
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}},
map[string]any{"request_id": updated.RequestID, "source": "approval"})
}
if updated.Status == types.GroupJoinRequestStatusAccepted {
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.joined",
map[string]any{"request_id": updated.RequestID, "user_id": updated.ApplicantUserID.UserId(), "joined": true, "source": "approval"})
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.join_request."+updated.Status,
map[string]any{"request_id": updated.RequestID, "user_id": updated.ApplicantUserID.UserId(), "status": updated.Status})
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
}
func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) {
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
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
// 已解散的群按文档约定对客户端表现为不存在(RESOURCE_NOT_FOUND),不暴露解散状态。
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil &&
settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonResourceNotFound, "group not found", nil)
return
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
access := types.ModeNone
if selfSub != nil {
access = selfSub.ModeGiven & selfSub.ModeWant
}
if selfSub == nil || !access.IsJoiner() || !access.IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return
}
requests, _, _ := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, types.GroupJoinRequestStatusPending, "", 100)
announcement, _ := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"pending_join_request_count": len(requests),
"announcement": groupAnnouncementPayload(announcement),
"permissions": groupPermissions(access),
})
}
func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
switch {
case tail == "announcements/latest" && req.Method == http.MethodGet:
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im group announcement latest: returning empty after store error", principal.TenantID, topic, err)
ann = nil
}
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub))
case tail == "announcements/current" && req.Method == http.MethodPut:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupAnnouncementPutRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
content := strings.TrimSpace(body.Content)
if content == "" || len([]rune(content)) > 2000 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
if !reviewIMHTTPText(wrt, req, principal, moderation.SceneGroupAnnouncement, topic, content) {
return
}
now := time.Now().UTC().Round(time.Millisecond)
announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{
AnnouncementID: "ann_" + store.Store.GetUidString(),
Topic: topic,
AuthorUserID: principal.Uid,
Content: content,
Status: types.GroupAnnouncementStatusCurrent,
PublishedAt: now,
}, body.Version)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
return
}
writeIMRelationError(wrt, req, err)
return
}
if err = publishGroupAnnouncementTextMessage(req.Context(), principal.TenantID, topic, principal.Uid, announcement); err != nil {
logs.Warn.Println("im group announcement: failed to publish text message", principal.TenantID, topic, announcement.AnnouncementID, err)
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.announcement.updated",
map[string]any{"announcement_id": announcement.AnnouncementID, "topic": topic})
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, announcement, selfSub))
case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost:
announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read")
now := time.Now().UTC().Round(time.Millisecond)
if err := store.GroupBusiness.ReadAnnouncement(principal.TenantID, topic, announcementID, principal.Uid, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)})
case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet:
announcementID := strings.TrimSpace(strings.TrimPrefix(tail, "announcements/"))
if announcementID == "" || strings.Contains(announcementID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "announcement_id"})
return
}
ann, err := store.GroupBusiness.GetAnnouncement(principal.TenantID, topic, announcementID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupAnnouncementPayloadWithPermissions(topic, ann, selfSub))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func publishGroupAnnouncementTextMessage(ctx context.Context, tenantID types.TenantID, topic string, author types.Uid, ann *types.GroupAnnouncement) error {
if ann == nil || strings.TrimSpace(topic) == "" || author.IsZero() {
return types.ErrMalformed
}
content := strings.TrimSpace(ann.Content)
if content == "" {
return types.ErrMalformed
}
if store.Messages == nil || store.Topics == nil {
return types.ErrMalformed
}
topicRecord, err := store.Topics.Get(tenantID, topic)
if err != nil {
return err
}
if topicRecord == nil {
return types.ErrNotFound
}
now := ann.PublishedAt
if now.IsZero() {
now = time.Now().UTC().Round(time.Millisecond)
}
msg := &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
SeqId: topicRecord.SeqId + 1,
Topic: topic,
From: author.String(),
Head: types.KVMap{
"mime": "application/json",
"x-im-schema": 1,
"x-im-type": "text",
"x-im-client-mid": "group_announcement_" + strings.TrimSpace(ann.AnnouncementID),
},
Content: map[string]any{"text": "群公告\n" + content},
}
saved, err := saveMessageMaybeKafka(ctx, tenantID, msg, nil, true)
if err != nil {
return err
}
if saved != nil && saved.SeqId > 0 {
msg.SeqId = saved.SeqId
}
if saved == nil || !saved.Duplicate {
broadcastHTTPForwardMessage(tenantID, msg, saved == nil || saved.MarkedReadBySender)
}
return nil
}
func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
switch req.Method {
case http.MethodGet:
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
if settings != nil {
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
return
}
group, _ := store.Topics.Get(principal.TenantID, topic)
writeIMSuccess(wrt, req, groupGovernancePayload(group, selfSub))
case http.MethodPatch:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupGovernancePatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
settings, err := store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{
JoinApproval: body.JoinApproval,
MuteAll: body.MuteAll,
MemberUploadFiles: body.MemberUploadFiles,
MemberPrivateChat: body.MemberPrivateChat,
MemberInvite: body.MemberInvite,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if body.JoinApproval != nil && imReliableHTTPEventsEnabled(req) {
template := "group_invite_confirm_disabled"
text := "群聊邀请确认已关闭,成员邀请好友可直接进群。"
if *body.JoinApproval {
template = "group_invite_confirm_enabled"
text = "群聊邀请确认已启用,群成员邀请好友进群需要群主或管理员确认。"
}
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_action", template, text,
[]imSystemMessageActor{
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
},
[]imSystemMessageAction{{Type: "open_group_invite_settings", Label: "群聊邀请确认", TargetID: topic, MinRole: "admin"}},
map[string]any{"join_approval": *body.JoinApproval, "version": settings.Version})
}
if body.MemberPrivateChat != nil && imReliableHTTPEventsEnabled(req) {
template := "group_member_private_chat_disabled"
text := "群成员私信权限已关闭,群成员头像和名片不可点击查看。"
if *body.MemberPrivateChat {
template = "group_member_private_chat_enabled"
text = "群成员私信权限已开启,群成员头像和名片可点击查看。"
}
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_notice", template, text,
[]imSystemMessageActor{
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
},
nil,
map[string]any{"member_private_chat": *body.MemberPrivateChat, "version": settings.Version})
}
if body.MuteAll != nil && imReliableHTTPEventsEnabled(req) {
template := "group_mute_all_disabled"
text := "全体禁言已关闭,普通成员将能发送任何消息。"
if *body.MuteAll {
template = "group_mute_all_enabled"
text = "全体禁言已开启,普通成员将不能发送任何消息。"
}
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_notice", template, text,
[]imSystemMessageActor{
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
},
nil,
map[string]any{"mute_all": *body.MuteAll, "version": settings.Version})
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.settings.updated",
map[string]any{"topic": topic, "version": settings.Version})
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupRoleLimits(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"max_admin_count": groupMaxAdminCount, "current_admin_count": countGroupAdmins(members)})
}
func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
switch {
case tail == "admins/batch" && req.Method == http.MethodPost:
var body imGroupAdminsBatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.UserIDs) == 0 || len(body.UserIDs) > groupMaxAdminCount {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if countGroupAdmins(members)+len(body.UserIDs) > groupMaxAdminCount {
writeIMError(wrt, req, http.StatusConflict, 30006, "GROUP_ADMIN_LIMIT_EXCEEDED", "group admin limit exceeded", map[string]any{"max": groupMaxAdminCount, "current": countGroupAdmins(members), "requested": len(body.UserIDs)})
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
for _, rawID := range body.UserIDs {
uid := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
if uid.IsZero() || uid == principal.Uid {
item["ok"] = false
item["reason"] = imReasonMalformedRequest
results = append(results, item)
continue
}
item["user_id"] = uid.UserId()
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
types.GroupMemberRoleAdmin, types.ModeCPublic|types.ModeApprove, time.Now().UTC().Round(time.Millisecond)); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
item["ok"] = true
}
results = append(results, item)
}
members, err = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.role.updated",
map[string]any{"topic": topic, "results": results})
writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete:
userID := strings.TrimPrefix(tail, "admins/")
uid := types.ParseUserId(strings.TrimSpace(userID))
if uid.IsZero() || uid == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid,
types.GroupMemberRoleMember, types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.role.updated",
map[string]any{"user_id": uid.UserId(), "role": "member"})
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupOwnerTransferRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if !consumeIMActionTokenHeader(wrt, req, principal, "group_owner_transfer") {
return
}
target := types.ParseUserId(strings.TrimSpace(body.TargetUserID))
if target.IsZero() || target == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_user_id"})
return
}
targetSub, err := store.Subs.Get(principal.TenantID, topic, target, false)
if err != nil || targetSub == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
if err = store.GroupBusiness.TransferOwner(principal.TenantID, topic, principal.Uid, target,
body.Version, time.Now().UTC().Round(time.Millisecond)); err != nil {
if errors.Is(err, types.ErrInvalidResponse) {
currentVersion := 0
if settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, topic); settingsErr == nil && settings != nil {
currentVersion = settings.Version
}
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.owner.transferred",
map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId()})
writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1})
}
func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
if !ok {
return
}
isAdmin := (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
switch {
case tail == "mutes" && req.Method == http.MethodGet:
var items []types.GroupMute
nextCursor := ""
if isAdmin {
var err error
items, nextCursor, err = store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
} else {
item, err := store.GroupBusiness.GetMute(principal.TenantID, topic, principal.Uid)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
if item != nil {
items = append(items, *item)
}
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost:
if !isAdmin {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
var body imGroupMuteBatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.Members) == 0 || len(body.Members) > 100 {
for _, userID := range cleanStringList(body.UserIDs, 100) {
body.Members = append(body.Members, struct {
UserID string `json:"user_id"`
ExpiresAt string `json:"expires_at,omitempty"`
}{UserID: userID})
}
if len(body.Members) == 0 || len(body.Members) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"})
return
}
}
now := time.Now().UTC().Round(time.Millisecond)
mutes := make([]types.GroupMute, 0, len(body.Members))
for _, member := range body.Members {
uid := types.ParseUserId(strings.TrimSpace(member.UserID))
if uid.IsZero() {
continue
}
if ok, reason := canMuteIMGroupMember(principal.TenantID, topic, selfSub, principal.Uid, uid); !ok {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": uid.UserId(), "reason": reason})
return
}
var expiresAt *time.Time
if raw := strings.TrimSpace(member.ExpiresAt); raw != "" {
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
expiresAt = &parsed
}
}
mute, err := store.GroupBusiness.UpsertMute(principal.TenantID, &types.GroupMute{Topic: topic, UserID: uid, MutedBy: principal.Uid, MutedAt: now, ExpiresAt: expiresAt, Reason: strings.TrimSpace(body.Reason), Status: types.GroupMuteStatusActive})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
mutes = append(mutes, *mute)
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.mute.updated",
map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
if !isAdmin {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
if uid.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
if ok, reason := canMuteIMGroupMember(principal.TenantID, topic, selfSub, principal.Uid, uid); !ok {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", map[string]any{"user_id": uid.UserId(), "reason": reason})
return
}
if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.mute.updated",
map[string]any{"user_id": uid.UserId(), "muted": false})
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
}
func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
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
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
"field": "topic",
"expected": "group topic, e.g. grp...",
"actual": topic,
})
return
}
if memberID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
if memberID != principal.Uid {
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "group"); !decision.Allowed {
writeIMPolicyDecision(wrt, req, decision)
return
}
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
return
}
user, err := store.Users.Get(principal.TenantID, memberID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx))
}
func serveIMMeQRCodes(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 imQRCodeCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "add_friend"
}
if purpose != "add_friend" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
return
}
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed {
writeIMPolicyDecision(wrt, req, decision)
return
}
expiresIn := body.ExpiresInSeconds
if expiresIn <= 0 {
expiresIn = imQRCodeDefaultExpiresInSeconds
}
if expiresIn > imQRCodeMaxExpiresInSeconds {
expiresIn = imQRCodeMaxExpiresInSeconds
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
token, err := generateIMQRCodeToken(principal.TenantID, principal.Uid, purpose, expiresAt)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
user, err := store.Users.Get(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
data := map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
"expires_at": expiresAt.Format(time.RFC3339Nano),
}
if user != nil {
data["display_profile"] = userSummaryPayload(user, "")
}
writeIMSuccess(wrt, req, data)
}
func serveIMQRCodesResolve(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 imQRCodeResolveRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
claims, err := parseIMQRCodeToken(strings.TrimSpace(body.Token), strings.TrimSpace(body.Purpose), time.Now().UTC())
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "qr code invalid", nil)
return
}
if claims.TenantID != principal.TenantID {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
if claims.Purpose == "join_group" {
if types.GetTopicCat(claims.Topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
return
}
group, err := store.Topics.Get(principal.TenantID, claims.Topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if group == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
selfSub, _ := store.Subs.Get(principal.TenantID, claims.Topic, principal.Uid, false)
status := "can_request"
if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() {
status = "joined"
}
groupPayload := groupDetailPayload(group, selfSub)
writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
"group": groupPayload,
"group_snapshot": groupPayload,
"status": status,
"source": "qr",
})
return
}
peer := types.ParseUserId(claims.UserID)
if peer.IsZero() || peer == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"})
return
}
user, err := store.Users.Get(principal.TenantID, peer)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if user == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, peer)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed {
writeIMPolicyDecision(wrt, req, decision)
return
}
canAdd := ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "qr")
action := imRelationshipAction(ctx.Relationship)
if ctx.Relationship == types.RelationshipNone && !canAdd {
action = "none"
}
writeIMSuccess(wrt, req, map[string]any{
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
"user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: canAdd, Action: action}),
"source": "qr",
"source_topic": "",
})
}
func serveIMUserSearch(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
}
searchType := strings.TrimSpace(req.URL.Query().Get("type"))
query := strings.TrimSpace(req.URL.Query().Get("q"))
if searchType == "phone" {
if phone, err := normalizeIMPhone(query, "CN"); err == nil {
query = phone
}
}
if query == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"})
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 50)
items, nextCursor, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, query, searchType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
friendSearchAllowed := imFriendGlobalDisabledDecision(principal.TenantID, "", "search").Allowed
for _, item := range items {
data := userSearchItemPayload(item)
if !friendSearchAllowed && item.Relationship == types.RelationshipNone {
data["can_add"] = false
data["action"] = "none"
}
payload = append(payload, data)
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) {
identifier, tail, ok := userResourcePath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
return
}
switch tail {
case "profile":
serveIMUserProfile(wrt, req, identifier)
case "qrcodes":
serveIMUserQRCodes(wrt, req, identifier)
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil)
}
}
func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier string) {
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
}
userID, err := resolveIMUserIdentifier(principal, identifier)
if err != nil {
writeIMResolveUserError(wrt, req, err)
return
}
if userID.IsZero() || userID == principal.Uid {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
user, err := store.Users.Get(principal.TenantID, userID)
if err != nil {
logs.Warn.Printf("im user profile: load user failed request_id=%s viewer=%s target=%s: %v",
req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if user == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
return
}
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
if err != nil {
logs.Warn.Printf("im user profile: relationship context failed request_id=%s viewer=%s target=%s: %v",
req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
if err != nil {
logs.Warn.Printf("im user profile: privacy load failed request_id=%s viewer=%s target=%s: %v",
req.Header.Get("X-Request-ID"), principal.Uid.UserId(), userID.UserId(), err)
writeIMRelationError(wrt, req, err)
return
}
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
if ctx.Relationship == types.RelationshipBlockedByMe {
writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx))
return
}
if ctx.Relationship == types.RelationshipNone {
writeIMSuccess(wrt, req, limitedUserProfilePayload(user, ctx, privacy))
return
}
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
return
}
if ctx.Relationship == types.RelationshipNone {
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "profile"); !decision.Allowed {
writeIMPolicyDecision(wrt, req, decision)
return
}
}
writeIMSuccess(wrt, req, userProfilePayload(user, ctx))
}
func serveIMUserQRCodes(wrt http.ResponseWriter, req *http.Request, identifier string) {
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
}
userID, err := resolveIMUserIdentifier(principal, identifier)
if err != nil {
writeIMResolveUserError(wrt, req, err)
return
}
if userID.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
var body imQRCodeCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "add_friend"
}
if purpose != "add_friend" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
return
}
user, err := store.Users.Get(principal.TenantID, userID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if user == nil {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
return
}
if userID != principal.Uid {
ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) || !imPrivacyAllowsAdd(privacy, "qr") {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
if decision := imFriendGlobalDisabledDecision(principal.TenantID, "", "qr"); !decision.Allowed {
writeIMPolicyDecision(wrt, req, decision)
return
}
}
expiresIn := body.ExpiresInSeconds
if expiresIn <= 0 {
expiresIn = imQRCodeDefaultExpiresInSeconds
}
if expiresIn > imQRCodeMaxExpiresInSeconds {
expiresIn = imQRCodeMaxExpiresInSeconds
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond)
token, err := generateIMQRCodeToken(principal.TenantID, userID, purpose, expiresAt)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"qr_token": token,
"qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose),
"expires_at": expiresAt.Format(time.RFC3339Nano),
"display_profile": userSummaryPayload(user, ""),
})
}
func resolveIMUserIdentifier(principal *imPrincipal, identifier string) (types.Uid, error) {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return types.ZeroUid, types.ErrMalformed
}
if userID := types.ParseUserId(identifier); !userID.IsZero() {
return userID, nil
}
if store.Relations == nil {
return types.ZeroUid, types.ErrUnsupported
}
items, _, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, identifier, "exact_account", "", 2)
if err != nil {
return types.ZeroUid, err
}
for _, item := range items {
if item.User == nil {
continue
}
if userID := item.User.Uid(); !userID.IsZero() {
return userID, nil
}
}
return types.ZeroUid, types.ErrNotFound
}
func writeIMResolveUserError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
default:
writeIMRelationError(wrt, req, err)
}
}
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
}
if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, updated.ApplicantID.P2PName(updated.TargetID),
"system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。",
[]imSystemMessageActor{
{UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}},
{UserID: updated.TargetID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.TargetID.UserId()}},
}, nil, map[string]any{"request_id": updated.RequestID})
}
if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, updated.ApplicantID,
updated.RequestID+":"+status, eventType, "好友申请已处理", status); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
updated.RequestID+":"+status+":self", eventType, "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
writeIMSuccess(wrt, req, friendRequestPayload(updated))
}
func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) {
if strings.TrimSuffix(req.URL.Path, "/") == "/v1/im/blocks/batch-delete" {
serveIMBlocksBatchDelete(wrt, req)
return
}
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
}
if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
case http.MethodDelete:
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
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 serveIMBlocks(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
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
items, nextCursor, err := store.Relations.ListBlocks(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := make([]map[string]any, 0, len(items))
for _, item := range items {
payload = append(payload, blockListItemPayload(item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
func serveIMBlocksBatchDelete(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 imBlockBatchDeleteRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"})
return
}
results := make([]map[string]any, 0, len(body.UserIDs))
for _, rawID := range body.UserIDs {
peer := types.ParseUserId(strings.TrimSpace(rawID))
item := map[string]any{"user_id": strings.TrimSpace(rawID)}
if peer.IsZero() || peer == principal.Uid {
item["ok"] = false
item["reason"] = imReasonMalformedRequest
results = append(results, item)
continue
}
item["user_id"] = peer.UserId()
if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil {
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
item["ok"] = true
}
results = append(results, item)
}
writeIMSuccess(wrt, req, map[string]any{"items": results})
}
func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
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
}
now := time.Now().UTC().Round(time.Millisecond)
switch req.Method {
case http.MethodDelete:
if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId())
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, peer, stableID,
"friend.deleted", "好友关系已解除", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableID+":self",
"friend.deleted", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
case http.MethodPatch:
var body imFriendUpdateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if _, err := store.Relations.UpdateFriendRemark(principal.TenantID, principal.Uid, peer, strings.TrimSpace(body.Remark), now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", ""); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
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 serveIMIndependentPassword(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 imIndependentPasswordRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "youth_mode"
}
if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{
TenantID: principal.TenantID,
UserID: principal.Uid,
Purpose: purpose,
PasswordHash: hash,
}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
enableToken, enableExpiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, "enable_youth_mode", purpose)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"status": "ok",
"password_set": true,
"purpose": purpose,
"enable_action_token": enableToken,
"enable_action_expires_at": enableExpiresAt.Format(time.RFC3339Nano),
"next_action": "enable_youth_mode",
})
}
func serveIMIndependentPasswordVerify(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 imIndependentPasswordVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "youth_mode"
}
action := strings.TrimSpace(body.Action)
if action == "" {
action = "close_youth_mode"
}
pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose)
if err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
"retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1,
})
return
}
if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil {
attempts := pass.FailedAttempts + 1
var lockedUntil *time.Time
if attempts >= imIndependentPasswordMaxAttempts {
until := now.Add(imIndependentPasswordLockSeconds * time.Second)
lockedUntil = &until
}
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now)
if lockedUntil != nil {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
"retry_after_seconds": imIndependentPasswordLockSeconds,
})
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now)
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"action_token": token,
"action": action,
"expires_at": expiresAt.Format(time.RFC3339Nano),
})
}
func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) {
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return nil, "", false
}
vc, err := store.Verifications.Get(tenantID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
logs.Warn.Println("im verification verify: lookup failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenantID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return nil, "", false
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
return vc, token, true
}
func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) {
principal, err := resolveIMHTTPBearer(req)
if err != nil {
logs.Warn.Printf("im http bearer auth failed request_id=%s method=%s path=%s err=%v", req.Header.Get("X-Request-ID"), req.Method, req.URL.Path, err)
if imHTTPBearerAuthUnauthorized(err) {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
} else {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
return nil, false
}
return principal, true
}
func imHTTPBearerAuthUnauthorized(err error) bool {
return errors.Is(err, types.ErrPermissionDenied) ||
errors.Is(err, types.ErrMalformed) ||
errors.Is(err, types.ErrFailed) ||
errors.Is(err, types.ErrExpired)
}
func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
principal, _, _, err := resolveIMHTTPBearerToken(req)
return principal, err
}
func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Duration, error) {
authz := strings.TrimSpace(req.Header.Get("Authorization"))
token, ok := strings.CutPrefix(authz, "Bearer ")
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
return nil, nil, 0, types.ErrPermissionDenied
}
handler := store.Store.GetLogicalAuthHandler("token")
if handler == nil || !handler.IsInitialized() {
return nil, nil, 0, types.ErrUnsupported
}
secret := []byte(strings.TrimSpace(token))
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
secret = decoded
}
rec, _, err := handler.Authenticate(auth.AuthContext{}, secret)
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
if err != nil {
return nil, nil, 0, err
}
return nil, nil, 0, types.ErrPermissionDenied
}
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, secret, time.Duration(rec.Lifetime), nil
}
func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
deviceID, ok := imLogoutDeviceID(wrt, req)
if !ok {
return
}
principal, token, ttl, err := resolveIMHTTPBearerToken(req)
if err != nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
return
}
if ttl < time.Second {
ttl = time.Second
}
enabled, err := store.RevokeLoginToken(principal.TenantID, token, ttl)
if err != nil || !enabled {
logs.Warn.Println("im logout: token revocation failed", principal.TenantID, principal.Uid, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
pushUnbound := false
if deviceID != "" {
if deleter, ok := store.DevicePushTokens.(interface {
Delete(types.TenantID, types.Uid, string) error
}); ok {
if err = deleter.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im logout: push token unbind failed", principal.TenantID, principal.Uid, deviceID, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
pushUnbound = err == nil
}
if store.Devices != nil {
if err = store.Devices.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im logout: legacy device unbind failed", principal.TenantID, principal.Uid, deviceID, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "device_id": deviceID, "push_unbound": pushUnbound})
}
func imLogoutDeviceID(wrt http.ResponseWriter, req *http.Request) (string, bool) {
deviceID := strings.TrimSpace(req.Header.Get("X-Device-ID"))
if deviceID == "" {
deviceID = strings.TrimSpace(req.Header.Get("X-IM-Device-ID"))
}
if req.Body != nil && req.Body != http.NoBody {
var body imAuthLogoutRequest
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "body"})
return "", false
}
if strings.TrimSpace(body.DeviceID) != "" {
deviceID = strings.TrimSpace(body.DeviceID)
}
}
if strings.Contains(deviceID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
return "", false
}
return deviceID, true
}
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
user, err := store.Users.Get(tenantID, uid)
if err != nil {
return "", err
}
if user == nil || strings.TrimSpace(user.Phone) == "" {
return "", types.ErrNotFound
}
return strings.TrimSpace(user.Phone), nil
}
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
if imSensitiveChallengeRateLimitEnabled {
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
})
return
}
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code := "123456"
vc := &types.VerificationCode{
TenantID: principal.TenantID,
ChallengeID: challengeID,
Scene: "sensitive_action",
Action: action,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
TargetUser: principal.Uid,
CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if err := store.Verifications.Create(principal.TenantID, vc); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
logs.Warn.Println("im security verification delivery failed; keeping fixed test code active:", action, vc.PhoneMask, err)
}
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) {
if action == "" {
return "", time.Time{}, types.ErrMalformed
}
tokenID, err := randomIMToken(12)
if err != nil {
return "", time.Time{}, err
}
secret, err := randomIMToken(32)
if err != nil {
return "", time.Time{}, err
}
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second)
if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{
TenantID: tenantID,
UserID: uid,
TokenID: tokenID,
Action: action,
Target: target,
TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret),
State: 0,
ExpiresAt: expiresAt,
}); err != nil {
return "", time.Time{}, err
}
return tokenID + "." + secret, expiresAt, nil
}
func defaultIMActionTarget(action, fallback string) string {
switch action {
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
return "youth_mode"
case "group_owner_transfer", "group_dissolve":
return action
default:
return fallback
}
}
func consumeIMActionTokenHeader(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action string) bool {
actionToken := strings.TrimSpace(req.Header.Get("X-Action-Token"))
if actionToken == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "X-Action-Token"})
return false
}
if store.Security == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return false
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, actionToken, action, defaultIMActionTarget(action, action), time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return false
}
return true
}
func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%d", tenantID)))
h.Write([]byte{0})
h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid))))
h.Write([]byte{0})
h.Write([]byte(action))
h.Write([]byte{0})
h.Write([]byte(target))
h.Write([]byte{0})
h.Write([]byte(tokenID))
h.Write([]byte{0})
h.Write([]byte(secret))
return fmt.Sprintf("%x", h.Sum(nil))
}
func realNamePayload(rv *types.RealNameVerification) map[string]any {
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
status := rv.Status
if status == "manual_review" {
status = "pending"
}
data := map[string]any{
"status": status,
"submission_id": rv.SubmissionID,
"masked_name": rv.MaskedName,
"masked_id_number": rv.MaskedIDNumber,
"can_submit": canSubmit,
}
if rv.Status == "manual_review" {
data["review_status"] = rv.Status
}
if rv.VerifiedAt != nil {
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
}
if rv.ReviewReason != nil {
data["review_reason"] = *rv.ReviewReason
}
return data
}
func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any {
if appeal == nil {
return map[string]any{}
}
attachments := appeal.Attachments
if len(attachments) == 0 && len(appeal.AttachmentsJSON) > 0 {
_ = json.Unmarshal(appeal.AttachmentsJSON, &attachments)
}
return map[string]any{
"appeal_id": appeal.AppealID,
"reason": appeal.Reason,
"attachments": attachments,
"status": appeal.Status,
"created_at": appeal.CreatedAt.Format(time.RFC3339Nano),
}
}
func initialRealNameSubmissionState(req *http.Request, principal *imPrincipal, name, idType, idNumber string) (string, *string, *time.Time) {
status := "pending"
policy, err := store.RealNameConfig.Policy(principal.TenantID)
if err != nil || policy == nil {
logs.Warn.Printf("realname policy lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
return status, nil, nil
}
if policy.ReviewPolicy != types.RealNameReviewPolicyAutoManual {
return status, nil, nil
}
result := reviewIMRealNameAutomatically(req, principal, policy, name, idType, idNumber)
switch result.status {
case "verified":
now := time.Now().UTC().Round(time.Millisecond)
return "verified", nil, &now
default:
reason := strings.TrimSpace(result.reason)
if reason == "" {
reason = "自动审核未通过,已转人工审核"
}
return "manual_review", &reason, nil
}
}
type imRealNameAutoReviewResult struct {
status string
reason string
}
func reviewIMRealNameAutomatically(req *http.Request, principal *imPrincipal, policy *types.RealNamePolicy, name, idType, idNumber string) imRealNameAutoReviewResult {
values, err := store.ThirdPartyConfig.Get("im_realname",
"realname_provider",
"aliyun_region_id",
"aliyun_endpoint",
"aliyun_access_key_id",
"aliyun_access_key_secret",
"aliyun_timeout_ms",
"mock_enabled",
"mock_result")
if err != nil {
logs.Warn.Printf("realname auto review config lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置读取失败,已转人工审核"}
}
if parseIMBool(values["mock_enabled"]) {
switch strings.ToLower(strings.TrimSpace(values["mock_result"])) {
case "verified", "pass", "passed", "success":
return imRealNameAutoReviewResult{status: "verified"}
default:
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
}
}
if strings.TrimSpace(values["realname_provider"]) == "" ||
strings.TrimSpace(values["aliyun_access_key_id"]) == "" ||
strings.TrimSpace(values["aliyun_access_key_secret"]) == "" {
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置未完成,已转人工审核"}
}
if !strings.EqualFold(strings.TrimSpace(values["realname_provider"]), "aliyun_cloudauth") {
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核供应商暂不支持,已转人工审核"}
}
client, err := realnamealiyun.NewID2MetaClient(realnamealiyun.Config{
AccessKeyID: values["aliyun_access_key_id"],
AccessKeySecret: values["aliyun_access_key_secret"],
RegionID: values["aliyun_region_id"],
Endpoint: values["aliyun_endpoint"],
TimeoutMS: realnamealiyun.ParseTimeoutMS(values["aliyun_timeout_ms"], 1500),
})
if err != nil {
logs.Warn.Printf("realname auto review client init failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核客户端初始化失败,已转人工审核"}
}
result, err := client.Verify(name, idNumber)
if err != nil {
logs.Warn.Printf("realname auto review call failed: tenant=%d uid=%s trace=%s err=%v", principal.TenantID, principal.Uid, req.Header.Get("X-Request-ID"), err)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核调用失败,已转人工审核"}
}
if result.Passed {
logs.Info.Printf("realname auto review passed: tenant=%d uid=%s request_id=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.Latency)
return imRealNameAutoReviewResult{status: "verified"}
}
logs.Info.Printf("realname auto review mismatch: tenant=%d uid=%s request_id=%s biz_code=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.BizCode, result.Latency)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
}
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
if inv == nil {
return map[string]any{}
}
data := map[string]any{
"invitation_id": inv.InvitationID,
"topic": inv.Topic,
"inviter_id": inv.InviterUserID.UserId(),
"target_user_id": inv.TargetUserID.UserId(),
"status": groupInvitationDisplayStatus(inv),
"result": inv.Result,
"expires_at": inv.ExpiresAt.Format(time.RFC3339Nano),
}
if inv.JoinRequestID != "" {
data["join_request_id"] = inv.JoinRequestID
}
if inv.HandledAt != nil {
data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano)
}
return data
}
func groupInvitationDisplayStatus(inv *types.GroupInvitation) string {
if inv == nil {
return ""
}
if inv.Status == types.GroupInvitationStatusPending && strings.TrimSpace(inv.JoinRequestID) != "" {
return types.GroupInvitationStatusPendingApproval
}
return inv.Status
}
func groupFilePayloads(files []types.GroupFile) []map[string]any {
items := make([]map[string]any, 0, len(files))
for i := range files {
items = append(items, groupFilePayload(&files[i]))
}
return items
}
func groupFilePayload(file *types.GroupFile) map[string]any {
if file == nil {
return map[string]any{}
}
data := map[string]any{
"group_file_id": file.GroupFileID,
"topic": file.Topic,
"file_id": file.FileID,
"file_name": file.FileName,
"mime_type": file.MIMEType,
"file_type": file.FileType,
"file_size": file.FileSize,
"uploader_id": file.UploaderID.UserId(),
"status": file.Status,
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if file.SourceTopic != "" {
data["source_topic"] = file.SourceTopic
}
if file.SourceSeq > 0 {
data["source_seq"] = file.SourceSeq
}
if file.DeletedAt != nil {
data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano)
data["deleted_by"] = file.DeletedBy.UserId()
}
return data
}
func normalizeIMFileType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "image", "video", "audio", "document", "file":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "file"
}
}
func normalizeIMUploadedFileID(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if cut, _, ok := strings.Cut(value, "?"); ok {
value = cut
}
if cut, _, ok := strings.Cut(value, "#"); ok {
value = cut
}
if strings.Contains(value, "/") && store.Store != nil && store.Store.GetMediaHandler() != nil {
if fid := store.Store.GetMediaHandler().GetIdFromUrl(value); !fid.IsZero() {
return fid.String()
}
}
if fid := types.ParseUid(value); !fid.IsZero() {
return fid.String()
}
if dot := strings.IndexByte(value, '.'); dot > 0 {
if fid := types.ParseUid(value[:dot]); !fid.IsZero() {
return fid.String()
}
}
return ""
}
func linkIMGroupAvatarAttachment(tenantID types.TenantID, topic, avatarFileID string) error {
fileID := normalizeIMUploadedFileID(avatarFileID)
if fileID == "" {
return types.ErrMalformed
}
return store.Files.LinkAttachments(tenantID, topic, types.ZeroUid, []string{fileID})
}
func parseIMLimit(raw string, fallback, max int) int {
limit := fallback
if raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > max {
limit = max
}
return limit
}
func parseIMTime(raw string) (time.Time, error) {
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, err
}
return parsed.UTC().Round(time.Millisecond), nil
}
func parseIMDate(raw string) (time.Time, error) {
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.UTC)
if err != nil {
return time.Time{}, err
}
return parsed, nil
}
func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) {
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if key == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
return "", false
}
return key, true
}
func parseIMNonNegativeCursor(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
cursor, err := strconv.Atoi(raw)
if err != nil || cursor < 0 {
return 0, errors.New("invalid cursor")
}
return cursor, nil
}
func parseIMBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y", "on", "enabled":
return true
default:
return false
}
}
func conversationMatches(sub types.Subscription, query string) bool {
if strings.Contains(strings.ToLower(sub.Topic), query) {
return true
}
encoded, _ := json.Marshal(sub.GetPublic())
return strings.Contains(strings.ToLower(string(encoded)), query)
}
func conversationPayload(sub types.Subscription) map[string]any {
unread := sub.GetSeqId() - sub.ReadSeqId
if unread < 0 {
unread = 0
}
return map[string]any{
"topic": sub.Topic,
"public": sub.GetPublic(),
"private": sub.Private,
"updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano),
"seq": sub.GetSeqId(),
"read": sub.ReadSeqId,
"recv": sub.RecvSeqId,
"unread": unread,
"with": sub.GetWith(),
}
}
func conversationSettingsPayload(settings *types.ConversationSettings) map[string]any {
if settings == nil {
return map[string]any{}
}
return map[string]any{
"topic": settings.Topic,
"pinned": settings.Pinned,
"muted": settings.Muted,
"mute_notifications": settings.Muted,
"remark": settings.Remark,
"message_retention": settings.MessageRetention,
"version": settings.Version,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"permissions": map[string]any{"can_update": true},
}
}
func topicDraftPayload(draft *types.TopicDraft) map[string]any {
if draft == nil {
return map[string]any{}
}
var entities any = []any{}
if len(draft.EntitiesJSON) > 0 {
_ = json.Unmarshal(draft.EntitiesJSON, &entities)
}
return map[string]any{
"topic": draft.Topic,
"content": draft.Content,
"entities": entities,
"version": draft.Version,
"updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func forwardTargetPayload(sub types.Subscription) map[string]any {
topicType := ""
switch types.GetTopicCat(sub.Topic) {
case types.TopicCatGrp:
topicType = "group"
case types.TopicCatP2P:
topicType = "user"
default:
return nil
}
return map[string]any{
"topic": sub.Topic,
"type": topicType,
"display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic),
"avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()),
"can_receive_forward": true,
}
}
func forwardTargetMatches(topic, displayName, query string) bool {
if query == "" {
return true
}
return strings.Contains(strings.ToLower(topic), query) || strings.Contains(strings.ToLower(displayName), query)
}
func imDisplayNameFromPublic(public any, fallback string) string {
if value, ok := imPublicString(public, "fn"); ok {
return value
}
if value, ok := imPublicString(public, "name"); ok {
return value
}
return fallback
}
func imAvatarFileIDFromPublic(public any) string {
if value, ok := imPublicString(public, "avatar_file_id"); ok {
return value
}
if value, ok := imPublicString(public, "photo"); ok {
return value
}
return ""
}
func imPublicString(public any, key string) (string, bool) {
values, ok := public.(map[string]any)
if !ok {
return "", false
}
value, ok := values[key].(string)
if !ok {
return "", false
}
value = strings.TrimSpace(value)
return value, value != ""
}
func receiptMemberPayload(sub types.Subscription) map[string]any {
return map[string]any{
"user_id": sub.User,
"public": sub.GetPublic(),
"read": sub.ReadSeqId,
"recv": sub.RecvSeqId,
}
}
func groupListItemPayload(sub types.Subscription) map[string]any {
public := groupPublicMap(sub.GetPublic())
access := sub.ModeGiven & sub.ModeWant
return map[string]any{
"topic": sub.Topic,
"name": groupName(public, sub.Topic),
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
"member_count": sub.GetSubCnt(),
"my_role": groupMemberRole(access),
"updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func mergeGroupListSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
if payload == nil {
payload = map[string]any{}
}
if settings == nil {
return payload
}
payload["topic"] = settings.Topic
payload["name"] = settings.Name
payload["avatar_file_id"] = settings.AvatarFileID
payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano)
return payload
}
func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
public := map[string]any{}
topic := ""
memberCount := 0
ownerID := ""
if group != nil {
public = groupPublicMap(group.Public)
topic = group.Id
memberCount = group.SubCnt
if owner := types.ParseUid(group.Owner); !owner.IsZero() {
ownerID = owner.UserId()
}
}
version := intFromAny(public["version"])
if version <= 0 {
version = 1
}
access := types.ModeNone
if selfSub != nil {
access = selfSub.ModeGiven & selfSub.ModeWant
if topic == "" {
topic = selfSub.Topic
}
}
return map[string]any{
"topic": topic,
"name": groupName(public, topic),
"avatar_file_id": stringFromAny(public["avatar_file_id"]),
"description": stringFromAny(public["description"]),
"owner": ownerID,
"member_count": memberCount,
"version": version,
"my_role": groupMemberRole(access),
"permissions": groupPermissions(access),
}
}
func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
data := groupDetailPayload(group, selfSub)
private := map[string]any{}
if selfSub != nil {
private = groupPrivateMap(selfSub.Private)
}
data["pinned"] = boolFromAny(private["pinned"])
data["mute_notifications"] = boolFromAny(private["mute_notifications"])
data["message_retention"] = stringFromAny(private["message_retention"])
if data["message_retention"] == "" {
data["message_retention"] = "forever"
}
data["governance"] = defaultGroupGovernance()
return data
}
func groupMemberSettingsPayload(sub *types.Subscription) map[string]any {
private := map[string]any{}
if sub != nil {
private = groupPrivateMap(sub.Private)
}
return map[string]any{
"pinned": boolFromAny(private["pinned"]),
"mute_notifications": boolFromAny(private["mute_notifications"]),
"version": intFromAny(private["version"]),
"updated_at": stringFromAny(private["updated_at"]),
}
}
func groupMemberPayload(sub types.Subscription, mentionable bool, remark string) map[string]any {
access := sub.ModeGiven & sub.ModeWant
user := &types.User{ObjHeader: types.ObjHeader{Id: sub.User}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()}
data := userSummaryPayload(user, remark)
copyUserNickname(data, sub.GetPublic())
if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" {
data["group_nickname"] = stringFromAny(private["nickname"])
}
data["role"] = groupMemberRole(access)
data["mentionable"] = mentionable
data["acs"] = map[string]any{
"mode": access.String(),
"want": sub.ModeWant.String(),
"given": sub.ModeGiven.String(),
}
return data
}
func groupBusinessMemberPayload(member types.GroupMember, remark string) map[string]any {
access := groupMemberAccess(member)
user := &types.User{ObjHeader: types.ObjHeader{Id: member.UserID.String()}, Public: member.Public, Trusted: member.Trusted}
data := userSummaryPayload(user, remark)
copyUserNickname(data, member.Public)
if nickname := strings.TrimSpace(member.Nickname); nickname != "" {
data["group_nickname"] = nickname
}
data["role"] = groupMemberRole(access)
data["member_status"] = member.MemberStatus
data["version"] = member.Version
data["joined_at"] = member.JoinedAt.UTC().Format(time.RFC3339Nano)
data["updated_at"] = member.UpdatedAt.UTC().Format(time.RFC3339Nano)
data["mentionable"] = access.IsJoiner() && access.IsReader()
data["acs"] = map[string]any{
"mode": access.String(),
"want": member.ModeWant.String(),
"given": member.ModeGiven.String(),
}
return data
}
func maskIMGroupMemberProfileEntrypoint(data map[string]any) {
for _, key := range []string{"avatar_file_id", "public_account", "signature", "gender", "region"} {
delete(data, key)
}
data["can_add"] = false
data["action"] = "none"
}
func copyUserNickname(data map[string]any, public any) {
var fields map[string]any
if value, ok := public.(map[string]any); ok {
fields = value
} else if value, ok := public.(types.KVMap); ok {
fields = map[string]any(value)
}
if fields == nil {
return
}
if nickname := strings.TrimSpace(fmt.Sprint(fields["nickname"])); nickname != "" {
data["nickname"] = nickname
}
}
func groupMemberRemarkMap(tenantID types.TenantID, viewer types.Uid, members []types.GroupMember) map[types.Uid]string {
remarks := make(map[types.Uid]string)
for _, member := range members {
if member.UserID.IsZero() || member.UserID == viewer {
continue
}
ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, member.UserID, viewer.P2PName(member.UserID))
if err != nil {
logs.Warn.Printf("group member[%s:%s]: failed to resolve friend remark for viewer %s: %v", member.Topic, member.UserID.UserId(), viewer.UserId(), err)
continue
}
if ctx != nil && ctx.Remark != "" {
remarks[member.UserID] = ctx.Remark
}
}
return remarks
}
func groupMemberAccess(member types.GroupMember) types.AccessMode {
access := member.ModeGiven & member.ModeWant
if access != types.ModeNone {
return access
}
switch member.Role {
case types.GroupMemberRoleOwner:
return types.ModeCFull
case types.GroupMemberRoleAdmin:
return types.ModeCPublic | types.ModeApprove
default:
return types.ModeCPublic
}
}
func canMuteIMGroupMember(tenantID types.TenantID, topic string, selfSub *types.Subscription, actor, target types.Uid) (bool, string) {
if actor.IsZero() || target.IsZero() {
return false, "invalid_user"
}
if actor == target {
return false, "self"
}
actorAccess := types.ModeNone
if selfSub != nil {
actorAccess = selfSub.ModeGiven & selfSub.ModeWant
}
if !actorAccess.IsAdmin() {
return false, "not_admin"
}
targetRole := ""
if member, err := store.GroupBusiness.GetMember(tenantID, topic, target); err == nil && member != nil {
targetRole = groupMemberRole(groupMemberAccess(*member))
} else if sub, err := store.Subs.Get(tenantID, topic, target, false); err == nil && sub != nil {
targetRole = groupMemberRole(sub.ModeGiven & sub.ModeWant)
}
switch targetRole {
case "owner":
return false, "owner"
case "admin":
if !actorAccess.IsOwner() {
return false, "admin"
}
}
return true, ""
}
func canRemoveIMGroupMember(selfSub *types.Subscription, actor types.Uid, target types.GroupMember) (bool, string) {
if actor.IsZero() || target.UserID.IsZero() {
return false, "invalid_user"
}
if actor == target.UserID {
return false, "self"
}
actorAccess := types.ModeNone
if selfSub != nil {
actorAccess = selfSub.ModeGiven & selfSub.ModeWant
}
if !actorAccess.IsAdmin() {
return false, "not_admin"
}
switch groupMemberRole(groupMemberAccess(target)) {
case "owner":
return false, "owner"
case "admin":
if !actorAccess.IsOwner() {
return false, "admin"
}
}
return true, ""
}
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
data := groupMemberPayload(*sub, true, ctx.Remark)
data["topic"] = topic
data["relationship"] = ctx.Relationship
data["capabilities"] = map[string]any{
"can_send": ctx.CanSend,
"can_call": imCanCall(ctx.CanSend),
"can_add_friend": ctx.Relationship == types.RelationshipNone,
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if user != nil {
for key, value := range userSummaryPayload(user, ctx.Remark) {
if key == "avatar_file_id" || key == "display_name" {
data[key] = value
}
}
if public, ok := user.Public.(map[string]any); ok {
copyOptionalProfileFields(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyOptionalProfileFields(data, map[string]any(public))
}
}
return data
}
func groupMemberRole(access types.AccessMode) string {
switch {
case access.IsOwner():
return "owner"
case access.IsAdmin():
return "admin"
default:
return "member"
}
}
func groupCategory(role string) string {
switch role {
case "owner":
return "owned"
case "admin":
return "managed"
default:
return "joined"
}
}
func groupPermissions(access types.AccessMode) map[string]any {
return map[string]any{
"can_edit_profile": access.IsAdmin(),
"can_invite_members": access.IsSharer() || access.IsAdmin(),
"can_manage_members": access.IsAdmin(),
"can_manage_settings": access.IsAdmin(),
"can_leave": !access.IsOwner() && access.IsJoiner(),
}
}
func defaultGroupGovernance() map[string]any {
return map[string]any{
"join_approval": false,
"mute_all": false,
"member_upload_files": true,
"member_private_chat": true,
"member_invite": true,
"version": 1,
}
}
func groupGovernanceMap(value any) map[string]any {
governance := defaultGroupGovernance()
for key, value := range groupPrivateMap(value) {
governance[key] = value
}
return governance
}
func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any {
aux := map[string]any{}
if group != nil {
aux = groupAuxMap(group.Aux)
}
governance := groupGovernanceMap(aux["governance"])
editable := false
if selfSub != nil {
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
}
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
governance[key+"_editable"] = editable
}
return governance
}
func groupGovernancePayloadFromSettings(settings *types.GroupSettings, selfSub *types.Subscription) map[string]any {
governance := defaultGroupGovernance()
if settings != nil {
governance["join_approval"] = settings.JoinApproval
governance["mute_all"] = settings.MuteAll
governance["member_upload_files"] = settings.MemberUploadFiles
governance["member_private_chat"] = settings.MemberPrivateChat
governance["member_invite"] = settings.MemberInvite
governance["version"] = settings.Version
}
editable := false
if selfSub != nil {
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
}
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
governance[key+"_editable"] = editable
}
return governance
}
func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
if payload == nil {
payload = map[string]any{}
}
if settings == nil {
return payload
}
payload["topic"] = settings.Topic
payload["name"] = settings.Name
payload["avatar_file_id"] = settings.AvatarFileID
payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId()
payload["status"] = settings.GroupStatus
payload["version"] = settings.Version
return payload
}
func groupJoinRequestPayload(req *types.GroupJoinRequest) map[string]any {
if req == nil {
return map[string]any{}
}
data := map[string]any{
"request_id": req.RequestID,
"user_id": req.ApplicantUserID.UserId(),
"inviter_id": req.InviterUserID.UserId(),
"source": req.Source,
"message": req.Message,
"status": req.Status,
"version": req.Version,
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": req.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if req.HandledBy != types.ZeroUid {
data["handled_by"] = req.HandledBy.UserId()
}
if req.HandledAt != nil {
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
}
if req.RejectReason != "" {
data["reject_reason"] = req.RejectReason
}
return data
}
func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any {
out := make([]map[string]any, 0, len(items))
for i := range items {
out = append(out, groupJoinRequestPayload(&items[i]))
}
return out
}
func dedupePendingGroupJoinRequests(items []types.GroupJoinRequest, status string) []types.GroupJoinRequest {
if len(items) < 2 || status != types.GroupJoinRequestStatusPending {
return items
}
seen := make(map[string]struct{}, len(items))
deduped := make([]types.GroupJoinRequest, 0, len(items))
for i := len(items) - 1; i >= 0; i-- {
key := items[i].Topic + ":" + items[i].ApplicantUserID.UserId()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
deduped = append(deduped, items[i])
}
for left, right := 0, len(deduped)-1; left < right; left, right = left+1, right-1 {
deduped[left], deduped[right] = deduped[right], deduped[left]
}
return deduped
}
func groupJoinRequestPayloadsWithUsers(tenantID types.TenantID, items []types.GroupJoinRequest) ([]map[string]any, error) {
payloads := groupJoinRequestPayloads(items)
if len(items) == 0 || store.Users == nil {
return payloads, nil
}
seen := make(map[types.Uid]struct{}, len(items)*2)
uids := make([]types.Uid, 0, len(items)*2)
for i := range items {
for _, uid := range []types.Uid{items[i].ApplicantUserID, items[i].InviterUserID} {
if uid.IsZero() {
continue
}
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
uids = append(uids, uid)
}
}
if len(uids) == 0 {
return payloads, nil
}
users, err := store.Users.GetAll(tenantID, uids...)
if err != nil {
return nil, err
}
byID := make(map[types.Uid]*types.User, len(users))
for i := range users {
uid := types.ParseUid(users[i].Id)
if uid.IsZero() {
uid = types.ParseUserId(users[i].Id)
}
if uid.IsZero() {
continue
}
byID[uid] = &users[i]
}
for i := range items {
enrichGroupJoinRequestUserPayload(payloads[i], "user", "", items[i].ApplicantUserID, byID)
enrichGroupJoinRequestUserPayload(payloads[i], "inviter", "inviter_", items[i].InviterUserID, byID)
}
return payloads, nil
}
func enrichGroupJoinRequestUserPayload(data map[string]any, objectKey, fieldPrefix string, uid types.Uid, users map[types.Uid]*types.User) {
if data == nil || uid.IsZero() {
return
}
user := users[uid]
summary := map[string]any{"user_id": uid.UserId(), "display_name": uid.UserId()}
if user != nil {
summary = userSummaryPayload(user, "")
}
data[objectKey] = summary
if displayName, ok := summary["display_name"]; ok {
data[fieldPrefix+"display_name"] = displayName
}
if avatarFileID, ok := summary["avatar_file_id"]; ok {
data[fieldPrefix+"avatar_file_id"] = avatarFileID
}
}
func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
if ann == nil {
return map[string]any{
"announcement_id": "",
"content": "",
"author": "",
"version": 0,
"published_at": "",
"read_count": 0,
"member_count": 0,
"read_summary": map[string]any{"read_count": 0, "member_count": 0},
}
}
return map[string]any{
"announcement_id": ann.AnnouncementID,
"content": ann.Content,
"author": ann.AuthorUserID.UserId(),
"version": ann.Version,
"published_at": ann.PublishedAt.UTC().Format(time.RFC3339Nano),
"read_count": ann.ReadCount,
"member_count": ann.MemberCount,
"read_summary": map[string]any{"read_count": ann.ReadCount, "member_count": ann.MemberCount},
}
}
func groupAnnouncementPayloadWithPermissions(topic string, ann *types.GroupAnnouncement, selfSub *types.Subscription) map[string]any {
data := groupAnnouncementPayload(ann)
data["topic"] = topic
access := types.ModeNone
if selfSub != nil {
access = selfSub.ModeGiven & selfSub.ModeWant
}
data["permissions"] = map[string]any{
"can_edit": access.IsAdmin(),
}
return data
}
func groupMutePayload(mute types.GroupMute) map[string]any {
data := map[string]any{
"user_id": mute.UserID.UserId(),
"muted_by": mute.MutedBy.UserId(),
"muted_at": mute.MutedAt.UTC().Format(time.RFC3339Nano),
"reason": mute.Reason,
"status": mute.Status,
"version": mute.Version,
}
if mute.ExpiresAt != nil {
data["expires_at"] = mute.ExpiresAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func groupMutePayloads(items []types.GroupMute) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, groupMutePayload(item))
}
return out
}
func groupAuxMap(value any) map[string]any {
switch aux := value.(type) {
case map[string]any:
return copyStringAnyMap(aux)
case types.KVMap:
return copyStringAnyMap(map[string]any(aux))
default:
return map[string]any{}
}
}
func groupPublicMap(value any) map[string]any {
switch public := value.(type) {
case map[string]any:
return copyStringAnyMap(public)
case types.KVMap:
return copyStringAnyMap(map[string]any(public))
default:
return map[string]any{}
}
}
func groupJoinRequests(group *types.Topic) []map[string]any {
if group == nil {
return nil
}
return sliceOfMaps(groupAuxMap(group.Aux)["join_requests"])
}
func filterGroupJoinRequests(items []map[string]any, status string) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if status == "" || stringFromAny(item["status"]) == status {
out = append(out, item)
}
}
return out
}
func resolveGroupJoinRequest(items []map[string]any, requestID, action string, actor types.Uid, version int) (map[string]any, bool) {
now := time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano)
for i := range items {
if stringFromAny(items[i]["request_id"]) != requestID {
continue
}
currentVersion := intFromAny(items[i]["version"])
if version > 0 && currentVersion > 0 && version != currentVersion {
items[i]["status"] = "conflict"
return items[i], true
}
switch action {
case "accept":
items[i]["status"] = "accepted"
case "reject":
items[i]["status"] = "rejected"
default:
return nil, false
}
items[i]["handled_by"] = actor.UserId()
items[i]["handled_at"] = now
items[i]["updated_at"] = now
items[i]["version"] = currentVersion + 1
return items[i], true
}
return nil, false
}
func latestGroupAnnouncement(group *types.Topic) map[string]any {
if group == nil {
return map[string]any{}
}
return groupPrivateMap(groupAuxMap(group.Aux)["announcement"])
}
func groupMuteItems(aux map[string]any) []map[string]any {
return sliceOfMaps(aux["mutes"])
}
func upsertGroupMute(items []map[string]any, item map[string]any) []map[string]any {
userID := stringFromAny(item["user_id"])
for i := range items {
if stringFromAny(items[i]["user_id"]) == userID {
items[i] = item
return items
}
}
return append(items, item)
}
func removeGroupMute(items []map[string]any, userID string) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if stringFromAny(item["user_id"]) != userID {
out = append(out, item)
}
}
return out
}
func sliceOfMaps(value any) []map[string]any {
raw, ok := value.([]any)
if !ok {
if typed, ok := value.([]map[string]any); ok {
return typed
}
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, copyStringAnyMap(m))
}
}
return out
}
func applyBoolPatch(target map[string]any, key string, value *bool) {
if value != nil {
target[key] = *value
}
}
func groupRoleEligibility(actorAccess, memberAccess types.AccessMode, role string) (bool, string) {
if !actorAccess.IsOwner() {
return false, "owner_required"
}
switch role {
case "admin":
if memberAccess.IsOwner() {
return false, "owner"
}
if memberAccess.IsAdmin() {
return false, "already_admin"
}
return true, ""
case "owner":
if memberAccess.IsOwner() {
return false, "current_owner"
}
return memberAccess.IsJoiner(), ""
default:
return false, "unsupported_role"
}
}
func groupMuteEligibility(actorAccess, memberAccess types.AccessMode) (bool, string) {
if !actorAccess.IsAdmin() {
return false, "admin_required"
}
if memberAccess.IsOwner() {
return false, "owner"
}
return memberAccess.IsJoiner(), ""
}
func countGroupAdmins(members []types.Subscription) int {
count := 0
for _, member := range members {
access := member.ModeGiven & member.ModeWant
if access.IsAdmin() && !access.IsOwner() {
count++
}
}
return count
}
func groupAdminItems(members []types.Subscription) []map[string]any {
items := make([]map[string]any, 0)
for _, member := range members {
access := member.ModeGiven & member.ModeWant
if access.IsAdmin() && !access.IsOwner() {
items = append(items, groupMemberPayload(member, true, ""))
}
}
return items
}
func groupPrivateMap(value any) map[string]any {
switch private := value.(type) {
case map[string]any:
return copyStringAnyMap(private)
case types.KVMap:
return copyStringAnyMap(map[string]any(private))
default:
return map[string]any{}
}
}
func groupMemberPrivate(nickname string, pinned, mute bool, version int, updatedAt time.Time) map[string]any {
return map[string]any{
"nickname": nickname,
"pinned": pinned,
"mute_notifications": mute,
"version": version,
"profile_version": 1,
"updated_at": updatedAt.UTC().Format(time.RFC3339Nano),
}
}
func groupName(public map[string]any, fallback string) string {
for _, key := range []string{"name", "display_name", "fn"} {
value := strings.TrimSpace(fmt.Sprint(public[key]))
if value != "" && value != "<nil>" {
return value
}
}
return fallback
}
func groupListItemMatches(item map[string]any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
return query == "" || strings.Contains(strings.ToLower(fmt.Sprint(item["topic"])), query) || strings.Contains(strings.ToLower(fmt.Sprint(item["name"])), query)
}
func groupMemberMatches(item map[string]any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return true
}
for _, key := range []string{"display_name", "user_id"} {
if value, ok := item[key]; ok && strings.Contains(strings.ToLower(fmt.Sprint(value)), query) {
return true
}
}
return false
}
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit int, cursor string, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
"v3",
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strings.TrimSpace(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil {
return nil, false, types.ErrPermissionDenied
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return nil, false, err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
return nil, false, types.ErrPermissionDenied
}
msg, err := store.Messages.GetOne(principal.TenantID, topic, seq)
if err != nil {
return nil, false, err
}
if msg == nil {
return nil, false, types.ErrNotFound
}
if msg.RecalledAt != nil || msg.DeletedAt != nil {
return msg, false, types.ErrDuplicate
}
from := types.ParseUid(msg.From)
access := sub.ModeGiven & sub.ModeWant
authorized := from == principal.Uid || (allowAdmin && access.IsAdmin())
return msg, authorized, nil
}
func isIMEditableTextMessage(msg *types.Message) bool {
if msg == nil {
return false
}
if msg.Head == nil {
return true
}
msgType, _ := msg.Head["type"].(string)
return msgType == "" || msgType == "text"
}
func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil)
case errors.Is(err, types.ErrDuplicate):
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}
func messageMutationErrorItem(seq int64, err error) map[string]any {
switch {
case errors.Is(err, types.ErrNotFound):
return messageBatchRecallErrorItem(seq, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found")
case errors.Is(err, types.ErrDuplicate):
return messageBatchRecallErrorItem(seq, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict")
case errors.Is(err, types.ErrPermissionDenied):
return messageBatchRecallErrorItem(seq, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required")
case errors.Is(err, types.ErrMalformed):
return messageBatchRecallErrorItem(seq, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request")
default:
return messageBatchRecallErrorItem(seq, http.StatusInternalServerError, 90000, imReasonInternal, "internal error")
}
}
func messageBatchRecallErrorItem(seq int64, status, code int, reason, message string) map[string]any {
return map[string]any{
"seq": seq,
"status": "failed",
"http": status,
"code": code,
"reason": reason,
"message": message,
}
}
func messageMutationPayload(msg *types.Message, status string) map[string]any {
data := map[string]any{
"status": status,
"topic": msg.Topic,
"seq": msg.SeqId,
"version": msg.Version,
"updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if msg.RecalledAt != nil {
data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano)
data["recalled_by"] = msg.RecalledBy
}
if status == "edited" {
data["head"] = msg.Head
data["content"] = msg.Content
}
return data
}
func favoritePayload(item types.FavoriteItem) map[string]any {
return map[string]any{
"favorite_id": item.FavoriteID,
"type": item.ItemType,
"snapshot": item.Snapshot,
"source": favoriteSourcePayload(item.SourceTopic, item.SourceSeq, types.ZeroUid),
"collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano),
"availability": item.Availability,
}
}
func favoritePayloadForPrincipal(principal *imPrincipal, item types.FavoriteItem) (map[string]any, bool) {
if principal == nil {
return nil, false
}
sourceTopic, ok := normalizeIMFavoriteSourceTopic(principal.Uid, item.SourceTopic)
if !ok {
if legacyFavoriteSnapshotOnly(item) {
if item.Availability == "" {
item.Availability = "available"
}
return favoritePayload(item), true
}
return nil, false
}
if err := requireTopicReader(principal, sourceTopic); err != nil {
return nil, false
}
item.SourceTopic = sourceTopic
if store.Messages != nil {
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, item.SourceSeq)
if err == nil && msg != nil {
item.Availability = messageAvailability(*msg)
if item.Availability == "available" {
item.ItemType = imMessageType(msg)
item.Snapshot = favoriteSnapshot(msg)
item.AttachmentRefs = favoriteAttachmentRefs(msg)
}
} else if errors.Is(err, types.ErrNotFound) {
item.Availability = "removed"
}
}
return favoritePayloadForViewer(principal.Uid, item), true
}
func favoritePayloadForViewer(viewer types.Uid, item types.FavoriteItem) map[string]any {
payload := favoritePayload(item)
payload["source"] = favoriteSourcePayload(item.SourceTopic, item.SourceSeq, viewer)
return payload
}
func favoriteSourcePayload(topic string, seq int64, viewer types.Uid) map[string]any {
source := map[string]any{"topic": topic, "seq": seq}
if viewer.IsZero() || types.GetTopicCat(topic) != types.TopicCatP2P {
return source
}
peerTopic, err := types.P2PNameForUser(viewer, topic)
if err != nil || strings.TrimSpace(peerTopic) == "" {
return source
}
source["peer_user_id"] = peerTopic
source["display_topic"] = peerTopic
return source
}
func legacyFavoriteSnapshotOnly(item types.FavoriteItem) bool {
source := strings.TrimSpace(item.SourceTopic)
return strings.HasPrefix(source, "favSeed") && item.Snapshot != nil
}
func messageSearchPayload(msg types.Message, query string) map[string]any {
text := messageText(msg)
snippet := textSnippet(text, query)
return map[string]any{
"seq": msg.SeqId,
"from": messageFromUserID(msg.From),
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg),
"version": msg.Version,
"snippet": snippet,
"matches": textMatches(snippet, query),
"snapshot": favoriteSnapshot(&msg),
"availability": messageAvailability(msg),
}
}
func messageMutationEventPayload(mutation types.MessageMutation) map[string]any {
return map[string]any{
"seq": mutation.SeqId,
"version": mutation.Version,
"event": mutation.Event,
"operator": mutation.Operator,
"mutated_at": mutation.MutatedAt.UTC().Format(time.RFC3339Nano),
"mutation_version": mutation.MutationVersion,
}
}
func messageContextPayload(msg types.Message) map[string]any {
return map[string]any{
"seq": msg.SeqId,
"from": messageFromUserID(msg.From),
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(&msg),
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"availability": messageAvailability(msg),
}
}
func mediaMessagePayload(msg types.Message) map[string]any {
snapshot := favoriteSnapshot(&msg)
data := map[string]any{
"seq": msg.SeqId,
"from": messageFromUserID(msg.From),
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"availability": messageAvailability(msg),
}
if content, ok := snapshot["content"].(map[string]any); ok {
for key, value := range content {
data[key] = value
}
}
return data
}
func messageFromUserID(from string) string {
from = strings.TrimSpace(from)
if from == "" {
return ""
}
if strings.HasPrefix(from, "usr") {
if uid := types.ParseUserId(from); !uid.IsZero() {
return uid.UserId()
}
return from
}
if uid := types.ParseUid(from); !uid.IsZero() {
return uid.UserId()
}
return from
}
func callRecordPayloads(records []types.CallRecord, viewer types.Uid) []map[string]any {
items := make([]map[string]any, 0, len(records))
for _, record := range records {
items = append(items, callRecordPayload(record, viewer))
}
return items
}
func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any {
peer := record.CalleeID
direction := "outgoing"
if record.CalleeID == viewer {
peer = record.CallerID
direction = "incoming"
}
displayTopic := record.Topic
if viewTopic, err := types.P2PNameForUser(viewer, record.Topic); err == nil && viewTopic != "" {
displayTopic = viewTopic
}
ts := record.StartedAt
if ts.IsZero() {
ts = record.CreatedAt
}
data := map[string]any{
"call_id": record.CallID,
"topic": record.Topic,
"display_topic": displayTopic,
"seq": record.SeqID,
"caller_id": record.CallerID.UserId(),
"callee_id": record.CalleeID.UserId(),
"peer_user_id": peer.UserId(),
"peer": map[string]any{
"user_id": peer.UserId(),
"topic": displayTopic,
},
"direction": direction,
"incoming": direction == "incoming",
"call_type": record.CallType,
"status": record.Status,
"result": record.Status,
"duration_ms": record.DurationMS,
"ts": ts.UTC().Format(time.RFC3339Nano),
"started_at": record.StartedAt.UTC().Format(time.RFC3339Nano),
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if record.EndReason != "" {
data["end_reason"] = record.EndReason
}
if record.AcceptedAt != nil {
data["accepted_at"] = record.AcceptedAt.UTC().Format(time.RFC3339Nano)
}
if record.FinishedAt != nil {
data["finished_at"] = record.FinishedAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid) (map[string]any, map[string]any, error) {
newFriendsPending := 0
if store.Relations != nil {
count, err := store.Relations.CountPendingFriendRequests(tenantID, uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
return nil, nil, err
}
if err == nil {
newFriendsPending = count
}
}
systemUnread := 0
if store.Security != nil {
count, err := store.Security.CountUnreadSystemNotifications(tenantID, uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
return nil, nil, err
}
if err == nil {
systemUnread = count
}
}
entryBadges := map[string]any{
"contacts_total_unread": newFriendsPending,
"new_friends_pending_count": newFriendsPending,
"groups_unread_count": 0,
"file_assistant_unread_count": 0,
"system_unread_count": systemUnread,
}
entries := map[string]any{
"new_friends": map[string]any{
"enabled": true,
"route": "/contacts/new-friends",
"unread_count": newFriendsPending,
"unread_source": "business",
},
"groups": map[string]any{
"enabled": true,
"route": "/contacts/groups",
"unread_count": 0,
"unread_source": "business",
},
"file_assistant": map[string]any{
"enabled": true,
"route": "/chat/file-assistant",
"topic": "slf",
"unread_count": 0,
"unread_source": "topic",
},
"system_messages": map[string]any{
"enabled": true,
"route": "/chat/system",
"topic": "sys",
"unread_count": systemUnread,
"unread_source": "business",
},
}
return entryBadges, entries, nil
}
func userProfilePayload10(profile *types.UserProfile) map[string]any {
data := map[string]any{
"avatar_file_id": profile.AvatarFileID,
"nickname": profile.Nickname,
"birth_date": profile.BirthDate,
"signature": profile.Signature,
"gender": profile.Gender,
"region": profile.Region,
"email": profile.Email,
"public_account": profile.PublicAccount,
"version": profile.Version,
"updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano),
"field_rules": map[string]any{
"nickname": map[string]any{"min_length": 1, "max_length": 32},
"signature": map[string]any{"max_length": 120},
"region": map[string]any{"max_length": 128},
"birth_date": map[string]any{"format": "YYYY-MM-DD"},
"gender": []string{"male", "female", "other", ""},
"email": map[string]any{"max_length": 128},
},
}
return data
}
func currentUserProfilePayload(tenantID types.TenantID, uid types.Uid, profile *types.UserProfile) (map[string]any, error) {
account, err := currentUserAccountPayload(tenantID, uid)
if err != nil {
return nil, err
}
data := userProfilePayload10(profile)
applyDefaultUserAvatar(data, uid.UserId(), profile.Gender)
data["phone_mask"] = account["phone_mask"]
data["username"] = account["username"]
return data, nil
}
func currentUserAccountPayload(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
user, err := store.Users.Get(tenantID, uid)
if err != nil {
return nil, err
}
data := map[string]any{"phone_mask": "", "username": ""}
if user != nil {
data["phone_mask"] = maskIMProfilePhone(user.Phone)
data["username"] = user.Username
}
return data, nil
}
func validIMSecurityChallengeScene(scene string) bool {
switch strings.TrimSpace(scene) {
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password",
"group_owner_transfer", "group_dissolve":
return true
default:
return false
}
}
func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
return map[string]any{
"add_me_methods": settings.AddMeMethods,
"profile_visibility": settings.ProfileVisibility,
"last_seen_visibility": settings.LastSeenVisibility,
"read_receipts": settings.ReadReceipts,
"friend_request_verification": settings.FriendRequestVerification,
"group_invite_verification": settings.GroupInviteVerification,
"version": settings.Version,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"editable": map[string]any{
"add_me_methods": true,
"profile_visibility": true,
"last_seen_visibility": true,
"read_receipts": true,
"friend_request_verification": true,
"group_invite_verification": true,
},
}
}
func imPrivacyAllowsAdd(settings *types.UserPrivacySettings, source string) bool {
if settings == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(source)) {
case "phone", "qr", "group":
source = strings.ToLower(strings.TrimSpace(source))
default:
source = "account"
}
allowed, exists := settings.AddMeMethods[source]
return !exists || allowed
}
func imPrivacyAllowsProfile(settings *types.UserPrivacySettings, relationship string) bool {
if settings == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(settings.ProfileVisibility)) {
case "everyone":
return true
case "friends", "":
return relationship == types.RelationshipFriend
default:
return false
}
}
func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool {
if viewer.IsZero() || target.IsZero() {
return false
}
if viewer == target {
return true
}
// Legacy deployments without the product privacy stores retain MeetingIM's
// original presence behavior.
if store.Profiles == nil || store.Relations == nil {
return true
}
settings, err := store.Profiles.GetPrivacy(tenantID, target)
if err != nil || settings == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(settings.LastSeenVisibility)) {
case "everyone":
return true
case "friends", "":
ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, target, viewer.P2PName(target))
return err == nil && ctx != nil && ctx.Relationship == types.RelationshipFriend
default:
return false
}
}
func appReleasePayload(release *types.AppRelease, currentVersion string, hasUpdate bool, updateType string) map[string]any {
return map[string]any{
"current_version": currentVersion,
"has_update": hasUpdate,
"force_update": hasUpdate && updateType == "force",
"platform": release.Platform,
"channel": release.Channel,
"latest_version": release.LatestVersion,
"min_supported_version": release.MinSupportedVersion,
"update_type": updateType,
"release_notes": release.ReleaseNotes,
"store_url": release.StoreURL,
"package_url": release.PackageURL,
"checksum": release.Checksum,
"remind_after_seconds": release.RemindAfterSeconds,
"published_at": release.PublishedAt.UTC().Format(time.RFC3339Nano),
}
}
func parseNumericAppVersion(raw string) ([]uint64, error) {
raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v")
parts := strings.Split(raw, ".")
if len(parts) == 0 || len(parts) > 8 {
return nil, types.ErrMalformed
}
parsed := make([]uint64, len(parts))
for i, part := range parts {
if part == "" {
return nil, types.ErrMalformed
}
value, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, types.ErrMalformed
}
parsed[i] = value
}
return parsed, nil
}
func compareNumericAppVersion(left, right []uint64) int {
length := len(left)
if len(right) > length {
length = len(right)
}
for i := 0; i < length; i++ {
var leftPart, rightPart uint64
if i < len(left) {
leftPart = left[i]
}
if i < len(right) {
rightPart = right[i]
}
if leftPart < rightPart {
return -1
}
if leftPart > rightPart {
return 1
}
}
return 0
}
func youthModePayload(settings *types.YouthModeSettings) map[string]any {
data := map[string]any{
"enabled": settings.Enabled,
"policy_version": settings.PolicyVersion,
"restrictions": settings.Restrictions,
"restriction_items": youthModeRestrictionItems(settings.Restrictions),
"password_set": settings.PasswordSet,
"can_enable": settings.CanEnable,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if settings.EnabledAt != nil {
data["enabled_at"] = settings.EnabledAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func youthModeRestrictionItems(restrictions []string) []map[string]any {
catalog := map[string]map[string]any{
"night_quiet": {
"code": "night_quiet",
"title": "深夜免打扰",
"description": "夜间减少消息提醒打扰,帮助用户保持健康作息。",
"enforced": true,
},
"content_filter": {
"code": "content_filter",
"title": "内容过滤",
"description": "对不适合青少年的内容进行识别和限制展示。",
"enforced": true,
},
"payment_guard": {
"code": "payment_guard",
"title": "支付守护",
"description": "限制或确认支付、充值等敏感消费行为。",
"enforced": false,
},
}
items := make([]map[string]any, 0, len(restrictions))
for _, code := range restrictions {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if item, ok := catalog[code]; ok {
items = append(items, item)
continue
}
items = append(items, map[string]any{
"code": code,
"title": code,
"description": "",
"enforced": false,
})
}
return items
}
func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
return map[string]any{
"device_id": token.DeviceID,
"provider": token.Provider,
"app_version": token.AppVersion,
"platform": token.Platform,
"sdk_version": token.SDKVersion,
"push_env": token.PushEnv,
"notification_permission": token.NotificationPermission,
"updated_at": token.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func riskChallengePayload(challenge *types.RiskChallenge) map[string]any {
return map[string]any{
"risk_challenge_id": challenge.ChallengeID,
"action": challenge.Action,
"level": challenge.Level,
"notice_code": challenge.NoticeCode,
"notice_params": challenge.NoticeParams,
"state": challenge.State,
"decision": challenge.Decision,
"expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano),
"updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func abuseReasonPayload(reason types.AbuseReason) map[string]any {
return map[string]any{
"target_type": reason.TargetType,
"reason_code": reason.ReasonCode,
"label": reason.Label,
"requires_description": reason.RequiresDescription,
"allows_evidence": reason.AllowsEvidence,
}
}
func defaultIMAbuseReasons(targetType string) []types.AbuseReason {
targetType = strings.ToLower(strings.TrimSpace(targetType))
if targetType == "" {
targetType = "message"
}
return []types.AbuseReason{
{TargetType: targetType, ReasonCode: "spam", Label: "垃圾广告", RequiresDescription: false, AllowsEvidence: true, SortOrder: 10},
{TargetType: targetType, ReasonCode: "fraud", Label: "欺诈风险", RequiresDescription: false, AllowsEvidence: true, SortOrder: 20},
{TargetType: targetType, ReasonCode: "harassment", Label: "骚扰辱骂", RequiresDescription: false, AllowsEvidence: true, SortOrder: 30},
{TargetType: targetType, ReasonCode: "other", Label: "其他", RequiresDescription: true, AllowsEvidence: true, SortOrder: 99},
}
}
func isAllowedIMAbuseTargetType(targetType string) bool {
switch strings.ToLower(strings.TrimSpace(targetType)) {
case "user", "group", "message":
return true
default:
return false
}
}
func abuseReportPayload(report *types.AbuseReport) map[string]any {
return map[string]any{
"report_id": report.ReportID,
"status": report.Status,
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}
func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any {
return map[string]any{
"categories": config.Categories,
"max_length": config.MaxLength,
"attachment_policy": config.AttachmentPolicy,
"allow_contact": config.AllowContact,
"diagnostics_notice": config.DiagnosticsNotice,
"updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any {
return map[string]any{
"feedback_id": feedback.FeedbackID,
"status": feedback.Status,
"created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}
func publicUploadPayload(upload *types.PublicUpload) map[string]any {
return map[string]any{
"file_id": upload.FileID,
"purpose": upload.Purpose,
"mime_type": upload.MimeType,
"size": upload.Size,
"expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano),
}
}
func tenantApplicationPayload(application *types.TenantApplication) map[string]any {
return map[string]any{
"application_id": application.ApplicationID,
"status": application.Status,
"estimated_review_hours": application.EstimatedReviewHour,
"submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano),
}
}
func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any {
return map[string]any{
"feedback_id": feedback.FeedbackID,
"status": feedback.Status,
"submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano),
}
}
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": imCanCall(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.Remark != "" {
data["remark"] = ctx.Remark
}
if ctx.Peer != nil {
peer := userSummaryPayload(ctx.Peer, ctx.Remark)
peer["public"] = ctx.Peer.Public
peer["trusted"] = ctx.Peer.Trusted
data["peer"] = peer
if displayName, ok := peer["display_name"]; ok {
data["display_name"] = displayName
}
if avatarFileID, ok := peer["avatar_file_id"]; ok {
data["avatar_file_id"] = avatarFileID
}
}
if _, ok := data["display_name"]; !ok {
data["display_name"] = ctx.PeerID.UserId()
}
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),
}
if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" {
data["topic"] = topic
}
if req.SourceTopic != "" {
data["source_topic"] = req.SourceTopic
}
if req.HandledAt != nil {
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any {
data := friendRequestPayload(&item.Request)
data["direction"] = item.Direction
if item.Peer != nil {
data["peer"] = userSummaryPayload(item.Peer, "")
}
return data
}
func friendListItemPayload(tenantID types.TenantID, viewer types.Uid, item types.FriendListItem) map[string]any {
relationship := item.Friendship.Status
canSend := relationship == types.RelationshipFriend
if ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, item.Friendship.PeerID, item.Friendship.Topic); err == nil && ctx != nil {
relationship = ctx.Relationship
canSend = ctx.CanSend
}
online := false
onlineVisibility := "hidden"
if relationship == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) {
onlineVisibility = "visible"
online = redisUserOnline(tenantID, item.Friendship.PeerID, false)
}
data := map[string]any{
"user_id": item.Friendship.PeerID.UserId(),
"relationship": relationship,
"remark": item.Friendship.Remark,
"topic": item.Friendship.Topic,
"online": online,
"online_visibility": onlineVisibility,
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano),
"capabilities": map[string]any{"can_send": canSend, "can_call": imCanCall(canSend)},
"display_name_source": "profile",
}
if item.Friendship.Remark != "" {
data["display_name"] = item.Friendship.Remark
data["display_name_source"] = "remark"
}
if item.Peer != nil {
for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) {
_, exists := data[key]
if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") {
data[key] = value
}
}
data["peer"] = userSummaryPayload(item.Peer, item.Friendship.Remark)
if peer, ok := data["peer"].(map[string]any); ok {
peer["online"] = online
}
}
if item.Friendship.Remark == "" && data["display_name"] == data["user_id"] {
data["display_name_source"] = "user_id"
}
data["initial"] = displayInitial(data["display_name"])
return data
}
func userSearchItemPayload(item types.UserSearchItem) map[string]any {
data := userSummaryPayload(item.User, "")
if public, ok := item.User.Public.(map[string]any); ok {
copyOptionalProfileFields(data, public)
} else if public, ok := item.User.Public.(types.KVMap); ok {
copyOptionalProfileFields(data, map[string]any(public))
}
data["relationship"] = item.Relationship
data["can_add"] = item.CanAdd
data["action"] = item.Action
if item.RequestID != "" {
data["request_id"] = item.RequestID
}
return data
}
func imRelationshipAction(relationship string) string {
switch relationship {
case types.RelationshipFriend:
return "chat"
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
return "pending"
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
return "none"
default:
return "add"
}
}
func blockListItemPayload(item types.BlockListItem) map[string]any {
data := map[string]any{
"user_id": item.Block.BlockedID.UserId(),
"blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if item.User != nil {
data["user"] = userSummaryPayload(item.User, "")
}
return data
}
func systemNotificationPayloads(items []types.SystemNotification) []map[string]any {
payloads := make([]map[string]any, 0, len(items))
for i := range items {
payloads = append(payloads, systemNotificationPayload(items[i]))
}
return payloads
}
func systemNotificationPayload(item types.SystemNotification) map[string]any {
summary := strings.TrimSpace(item.Summary)
if summary == "" {
summary = strings.TrimSpace(item.Title)
}
action := normalizeSystemNotificationAction(item.Action, item.EventType)
data := map[string]any{
"id": item.EventID,
"event_type": item.EventType,
"title": item.Title,
"summary": summary,
"action": action,
"created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if item.ReadAt != nil {
data["read_at"] = item.ReadAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func normalizeSystemNotificationAction(action types.KVMap, eventType string) map[string]any {
normalized := map[string]any{
"type": strings.TrimSpace(eventType),
"target_id": "",
"payload": map[string]any{},
}
payload := map[string]any{}
for key, value := range action {
payload[key] = value
normalized[key] = value
}
for _, key := range []string{"meeting_id", "group_id", "topic", "resource_id", "target_id"} {
if value := strings.TrimSpace(fmt.Sprint(payload[key])); value != "" && value != "<nil>" {
normalized["target_id"] = value
break
}
}
normalized["payload"] = payload
return normalized
}
func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
data := userSummaryPayload(user, ctx.Remark)
data["username"] = user.Username
if original := userOriginalDisplayName(user); original != "" {
data["profile_display_name"] = original
data["original_display_name"] = original
}
data["relationship"] = ctx.Relationship
data["topic"] = ctx.Topic
data["capabilities"] = map[string]any{
"can_send": ctx.CanSend,
"can_call": imCanCall(ctx.CanSend),
"can_add_friend": ctx.Relationship == types.RelationshipNone,
}
if ctx.Remark != "" {
data["remark"] = ctx.Remark
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if public, ok := user.Public.(map[string]any); ok {
copyOptionalProfileFields(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyOptionalProfileFields(data, map[string]any(public))
}
return data
}
func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
data := userSummaryPayload(user, ctx.Remark)
data["username"] = user.Username
if original := userOriginalDisplayName(user); original != "" {
data["profile_display_name"] = original
data["original_display_name"] = original
}
data["relationship"] = ctx.Relationship
data["topic"] = ctx.Topic
data["capabilities"] = map[string]any{
"can_send": false,
"can_call": false,
"can_add_friend": false,
}
if ctx.Remark != "" {
data["remark"] = ctx.Remark
}
return data
}
func limitedUserProfilePayload(user *types.User, ctx *types.RelationshipContext, privacy *types.UserPrivacySettings) map[string]any {
data := userSummaryPayload(user, "")
data["username"] = user.Username
if original := userOriginalDisplayName(user); original != "" {
data["profile_display_name"] = original
data["original_display_name"] = original
}
data["relationship"] = ctx.Relationship
data["topic"] = ctx.Topic
data["capabilities"] = map[string]any{
"can_send": false,
"can_call": false,
"can_add_friend": ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "profile_card"),
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if public, ok := user.Public.(map[string]any); ok {
copyLimitedProfileFields(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyLimitedProfileFields(data, map[string]any(public))
}
return data
}
func copyLimitedProfileFields(data map[string]any, public map[string]any) {
for _, key := range []string{"nickname", "public_account"} {
if value, ok := public[key]; ok {
data[key] = value
}
}
}
func copyOptionalProfileFields(data map[string]any, public map[string]any) {
for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} {
if value, ok := public[key]; ok {
data[key] = value
}
}
}
func userSummaryPayload(user *types.User, remark string) map[string]any {
uid := types.ParseUid(user.Id)
userID := uid.UserId()
if uid.IsZero() {
userID = user.Id
}
data := map[string]any{"user_id": userID}
gender := ""
if public, ok := user.Public.(map[string]any); ok {
copyUserPublicSummary(data, public)
gender = stringFromAny(public["gender"])
} else if public, ok := user.Public.(types.KVMap); ok {
publicMap := map[string]any(public)
copyUserPublicSummary(data, publicMap)
gender = stringFromAny(publicMap["gender"])
}
if remark != "" {
data["display_name"] = remark
}
if _, ok := data["display_name"]; !ok {
data["display_name"] = userID
}
applyDefaultUserAvatar(data, userID, gender)
return data
}
func userOriginalDisplayName(user *types.User) string {
data := map[string]any{}
if public, ok := user.Public.(map[string]any); ok {
copyUserPublicSummary(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyUserPublicSummary(data, map[string]any(public))
}
if value := strings.TrimSpace(fmt.Sprint(data["display_name"])); value != "" && value != "<nil>" {
return value
}
uid := types.ParseUid(user.Id)
if uid.IsZero() {
return user.Id
}
return uid.UserId()
}
func copyUserPublicSummary(data map[string]any, public map[string]any) {
for _, key := range []string{"display_name", "fn", "name", "nickname"} {
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
data["display_name"] = value
break
}
}
for _, key := range []string{"avatar_file_id", "photo", "avatar"} {
if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
data["avatar_file_id"] = value
break
}
}
if value, ok := public["avatar_url"]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
data["avatar_url"] = value
}
}
func displayInitial(value any) string {
name := strings.TrimSpace(fmt.Sprint(value))
if name == "" {
return "#"
}
for _, r := range name {
return strings.ToUpper(string(r))
}
return "#"
}
func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonResourceNotFound, "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.ErrInvalidResponse):
writeIMError(wrt, req, http.StatusConflict, 20009, "VERSION_CONFLICT", "version conflict", 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 relationErrorReason(err error) string {
switch {
case errors.Is(err, types.ErrNotFound):
return "NOT_FOUND"
case errors.Is(err, types.ErrPermissionDenied):
return imReasonPermissionDenied
case errors.Is(err, types.ErrMalformed):
return imReasonMalformedRequest
default:
return imReasonInternal
}
}
func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return nil, nil, false
}
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return nil, nil, false
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, nil, false
}
access := types.ModeNone
if sub != nil {
access = sub.ModeGiven & sub.ModeWant
}
if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return nil, nil, false
}
return principal, sub, true
}
func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return nil, "", false
}
if topic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return nil, "", false
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return nil, "", false
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return nil, "", false
}
return principal, topic, true
}
func requireTopicWriter(principal *imPrincipal, topic string) error {
if principal == nil || topic == "" {
return types.ErrMalformed
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsWriter() {
return types.ErrPermissionDenied
}
return nil
}
func requireTopicReader(principal *imPrincipal, topic string) error {
if principal == nil || topic == "" {
return types.ErrMalformed
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
return types.ErrPermissionDenied
}
return nil
}
func copyStringAnyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func stringFromAny(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func boolFromAny(value any) bool {
if got, ok := value.(bool); ok {
return got
}
if got, ok := value.(string); ok {
return got == "true" || got == "1"
}
return false
}
func intFromAny(value any) int {
switch got := value.(type) {
case int:
return got
case int64:
return int(got)
case float64:
return int(got)
case json.Number:
parsed, _ := got.Int64()
return int(parsed)
case string:
parsed, _ := strconv.Atoi(got)
return parsed
default:
return 0
}
}
func imMessageType(msg *types.Message) string {
if msg == nil {
return "text"
}
for _, key := range []string{"type", "x-im-type"} {
if value := strings.TrimSpace(fmt.Sprint(msg.Head[key])); value != "" && value != "<nil>" {
return value
}
}
if content, ok := msg.Content.(map[string]any); ok {
if fileID := stringFromAny(content["file_id"]); fileID != "" {
mime := stringFromAny(content["mime"])
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case strings.HasPrefix(mime, "audio/"):
return "voice"
default:
return "file"
}
}
if stringFromAny(content["url"]) != "" {
return "link"
}
if mtype := draftyIMMessageType(content); mtype != "" {
return mtype
}
}
return "text"
}
// draftyIMMessageType derives the semantic message type from Drafty content
// ({"txt","fmt","ent"}), where "ent" holds entity definitions {"tp","data"}.
// Attachment entities win over inline links; EX is refined by its mime type.
// Returns "" when no recognized entity is present.
func draftyIMMessageType(content map[string]any) string {
ents, ok := content["ent"].([]any)
if !ok || len(ents) == 0 {
return ""
}
link := ""
file := ""
for _, e := range ents {
ent, ok := e.(map[string]any)
if !ok {
continue
}
data, _ := ent["data"].(map[string]any)
switch stringFromAny(ent["tp"]) {
case "IM":
return "image"
case "VD":
return "video"
case "AU":
return "voice"
case "EX":
if file == "" {
file = draftyAttachmentType(data)
}
case "LN":
if link == "" {
link = "link"
}
}
}
if file != "" {
return file
}
return link
}
func draftyAttachmentType(data map[string]any) string {
mime := ""
if data != nil {
mime = stringFromAny(data["mime"])
}
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case strings.HasPrefix(mime, "audio/"):
return "voice"
default:
return "file"
}
}
func favoriteSnapshot(msg *types.Message) map[string]any {
if msg == nil {
return map[string]any{}
}
return map[string]any{
"topic": msg.Topic,
"seq": msg.SeqId,
"from": messageFromUserID(msg.From),
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
"type": imMessageType(msg),
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"text": messageText(*msg),
}
}
func favoriteAttachmentRefs(msg *types.Message) any {
if msg == nil {
return []string{}
}
if content, ok := msg.Content.(map[string]any); ok {
if fileID := stringFromAny(content["file_id"]); fileID != "" {
return []string{fileID}
}
}
return []string{}
}
func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string]any {
payload := map[string]any{
"favorite_id": item.FavoriteID,
"source": map[string]any{
"topic": item.SourceTopic,
"seq": item.SourceSeq,
},
"snapshot": item.Snapshot,
"forwarded": true,
}
if refs, ok := item.AttachmentRefs.([]string); ok && len(refs) > 0 {
payload["attachment_refs"] = refs
} else if item.AttachmentRefs != nil {
payload["attachment_refs"] = item.AttachmentRefs
}
if comment != "" {
payload["comment"] = comment
}
return payload
}
func favoriteForwardMessageHeadContent(item *types.FavoriteItem, clientMid string) (types.KVMap, any) {
itemType := "text"
if item != nil && strings.TrimSpace(item.ItemType) != "" {
itemType = strings.TrimSpace(item.ItemType)
}
head := types.KVMap{"type": itemType, "x-im-type": itemType, "x-im-forwarded": true}
if item != nil {
if snapshot, ok := item.Snapshot.(map[string]any); ok {
if rawHead, ok := snapshot["head"].(map[string]any); ok && len(rawHead) > 0 {
head = types.KVMap{}
for key, value := range rawHead {
head[key] = value
}
}
head["x-im-forwarded"] = true
if _, ok := head["x-im-type"]; !ok {
head["x-im-type"] = itemType
}
if _, ok := head["type"]; !ok {
head["type"] = itemType
}
head["x-im-client-mid"] = clientMid
if content, ok := snapshot["content"]; ok {
return head, content
}
}
}
return forwardBundleMessageHead(clientMid), favoriteForwardContent(item, "")
}
func forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any {
return map[string]any{
"source": map[string]any{"topic": strings.TrimSpace(topic), "seq": seq},
"target_topic": strings.TrimSpace(target),
"status": "failed",
"reason": reason,
}
}
func forwardResultUnknownPayload(target string, seq int64, detailReason string) map[string]any {
return map[string]any{
"target_topic": strings.TrimSpace(target),
"seq": seq,
"status": "uncertain",
"reason": "MESSAGE_PERSIST_RESULT_UNKNOWN",
"detail_reason": detailReason,
"safe_to_retry": false,
}
}
func saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, target, comment, clientMid string) map[string]any {
comment = strings.TrimSpace(comment)
if principal == nil || comment == "" {
return nil
}
msg := &types.Message{
TenantID: principal.TenantID,
Topic: target,
From: principal.Uid.String(),
Head: types.KVMap{
"type": "text",
"x-im-type": "text",
"x-im-forwarded": true,
"x-im-client-mid": clientMid,
},
Content: map[string]any{"text": comment},
}
result := map[string]any{"comment_client_mid": clientMid}
if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil {
result["comment_status"] = "failed"
result["comment_reason"] = relationErrorReason(err)
return result
}
saved, err := saveMessageMaybeKafka(ctx, principal.TenantID, msg, nil, true)
if err != nil {
if saved != nil && saved.Pending {
result["comment_status"] = "pending"
result["comment_seq"] = saved.SeqId
result["comment_reason"] = "MESSAGE_PERSIST_PENDING"
return result
}
result["comment_status"] = "failed"
result["comment_reason"] = relationErrorReason(err)
return result
}
if saved != nil && saved.Pending {
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result["comment_status"] = "pending"
result["comment_seq"] = saved.SeqId
result["comment_reason"] = "MESSAGE_PERSIST_PENDING"
return result
}
if saved != nil {
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result["comment_status"] = "sent"
result["comment_seq"] = saved.SeqId
return result
}
result["comment_status"] = "failed"
result["comment_reason"] = imReasonInternal
return result
}
func mergeForwardCommentResult(item, comment map[string]any) {
if item == nil || comment == nil {
return
}
for key, value := range comment {
item[key] = value
}
}
func forwardClientMid(requestKey string, parts ...any) string {
requestKey = strings.TrimSpace(requestKey)
if requestKey == "" {
return "fwd_" + store.Store.GetUidString()
}
args := make([]any, 0, len(parts)+2)
args = append(args, "forward", requestKey)
args = append(args, parts...)
return "fwd_" + stableIMEventID(args...)
}
func normalizeIMForwardTopic(viewer types.Uid, topic string) (string, bool) {
return normalizeIMUserVisibleTopic(viewer, topic)
}
func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, bool) {
return normalizeIMUserVisibleTopic(viewer, topic)
}
func favoriteItemIdempotencyKey(requestKey, topic string, seq int64) string {
requestKey = strings.TrimSpace(requestKey)
if requestKey == "" {
return ""
}
return stableIMEventID("favorite", requestKey, strings.TrimSpace(topic), seq)
}
func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
topic = strings.TrimSpace(topic)
if topic == "" {
return "", false
}
if topic == "slf" {
return viewer.SlfName(), !viewer.IsZero()
}
if len(topic) < 3 {
return "", false
}
switch topic[:3] {
case "usr":
peer := types.ParseUserId(topic)
if peer.IsZero() {
return "", false
}
p2p := viewer.P2PName(peer)
return p2p, p2p != ""
case "p2p", "grp", "slf":
return topic, true
default:
return "", false
}
}
func buildForwardBundle(principal *imPrincipal, sources []struct {
Topic string `json:"topic"`
Seq imForwardSeq `json:"seq"`
}, target string, sourceReadable map[string]error, comment string) (map[string]any, []map[string]any) {
failures := make([]map[string]any, 0)
sourceMessages := make([]*types.Message, 0, len(sources))
sourceTopics := make([]string, 0, len(sources))
sourceSeqs := make([]int64, 0, len(sources))
for _, source := range sources {
sourceTopic, sourceOK := normalizeIMForwardTopic(principal.Uid, source.Topic)
if sourceTopic == "" || int64(source.Seq) <= 0 || !sourceOK {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, imReasonMalformedRequest))
continue
}
readErr, ok := sourceReadable[sourceTopic]
if !ok {
readErr = requireTopicReader(principal, sourceTopic)
sourceReadable[sourceTopic] = readErr
}
if readErr != nil {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(readErr)))
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, int64(source.Seq))
if err != nil {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, relationErrorReason(err)))
continue
}
if msg == nil {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "NOT_FOUND"))
continue
}
if messageAvailability(*msg) != "available" {
failures = append(failures, forwardFailurePayload(sourceTopic, int64(source.Seq), target, "MESSAGE_UNAVAILABLE"))
continue
}
sourceMessages = append(sourceMessages, msg)
sourceTopics = append(sourceTopics, sourceTopic)
sourceSeqs = append(sourceSeqs, int64(source.Seq))
}
if len(failures) > 0 {
return nil, failures
}
userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages, forwardBundleTitleUIDs(sourceTopics)...)
items := make([]map[string]any, 0, len(sourceMessages))
preview := make([]map[string]any, 0, min(len(sourceMessages), 3))
sourceRefs := make([]map[string]any, 0, len(sourceMessages))
for i, msg := range sourceMessages {
snapshot := favoriteSnapshot(msg)
if fromUser := forwardMessageUserSummary(msg.From, userSummaries); fromUser != nil {
snapshot["from_user"] = fromUser
}
items = append(items, snapshot)
ref := map[string]any{"topic": sourceTopics[i], "seq": sourceSeqs[i]}
sourceRefs = append(sourceRefs, ref)
if len(preview) < 3 {
previewItem := map[string]any{
"from": msg.From,
"type": imMessageType(msg),
"summary": textSnippet(messageText(*msg), ""),
"ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if fromUser := forwardMessageUserSummary(msg.From, userSummaries); fromUser != nil {
previewItem["from_user"] = fromUser
}
preview = append(preview, previewItem)
}
}
bundleID := "fbundle_" + store.Store.GetUidString()
return map[string]any{
"bundle_id": bundleID,
"title": forwardBundleTitle(principal.TenantID, sourceTopics, userSummaries),
"item_count": len(items),
"preview": preview,
"items": items,
"source_refs": sourceRefs,
"comment": strings.TrimSpace(comment),
"forwarded": true,
}, nil
}
func forwardBundleTitleUIDs(sourceTopics []string) []types.Uid {
seen := make(map[types.Uid]bool)
uids := make([]types.Uid, 0, 2)
for _, topic := range sourceTopics {
if types.GetTopicCat(topic) != types.TopicCatP2P {
continue
}
uid1, uid2, err := types.ParseP2P(topic)
if err != nil {
continue
}
for _, uid := range []types.Uid{uid1, uid2} {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
uids = append(uids, uid)
}
}
return uids
}
func forwardBundleTitle(tenantID types.TenantID, sourceTopics []string, userSummaries map[types.Uid]map[string]any) string {
if len(sourceTopics) == 0 {
return "聊天记录"
}
topic := sourceTopics[0]
switch types.GetTopicCat(topic) {
case types.TopicCatP2P:
uid1, uid2, err := types.ParseP2P(topic)
if err != nil {
return "聊天记录"
}
return forwardBundleDisplayName(uid1, userSummaries) + "和" + forwardBundleDisplayName(uid2, userSummaries) + "的聊天记录"
case types.TopicCatGrp:
groupName := topic
if store.Topics != nil {
if group, err := store.Topics.Get(tenantID, topic); err == nil && group != nil {
groupName = imDisplayNameFromPublic(group.Public, topic)
} else if err != nil {
logs.Warn.Printf("im message forward: failed to load group title topic=%s: %v", topic, err)
}
}
if strings.HasSuffix(groupName, "群") {
return groupName + "的聊天记录"
}
return groupName + "群的聊天记录"
default:
return "聊天记录"
}
}
func forwardBundleDisplayName(uid types.Uid, summaries map[types.Uid]map[string]any) string {
if summary := forwardMessageUserSummary(uid.String(), summaries); summary != nil {
if name := strings.TrimSpace(fmt.Sprint(summary["display_name"])); name != "" {
return name
}
}
return uid.UserId()
}
func forwardMessageUserSummaries(tenantID types.TenantID, messages []*types.Message, extraUIDs ...types.Uid) map[types.Uid]map[string]any {
if store.Users == nil {
return nil
}
seen := make(map[types.Uid]bool)
uids := make([]types.Uid, 0, len(messages)+len(extraUIDs))
for _, uid := range extraUIDs {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
uids = append(uids, uid)
}
for _, msg := range messages {
if msg == nil {
continue
}
uid := types.ParseUid(msg.From)
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
uids = append(uids, uid)
}
if len(uids) == 0 {
return nil
}
users, err := store.Users.GetAll(tenantID, uids...)
if err != nil {
logs.Warn.Printf("im http: failed to load forward user summaries: %v", err)
return nil
}
summaries := make(map[types.Uid]map[string]any, len(users))
for i := range users {
uid := users[i].Uid()
if uid.IsZero() {
uid = types.ParseUid(users[i].Id)
}
if !uid.IsZero() {
summaries[uid] = userSummaryPayload(&users[i], "")
}
}
return summaries
}
func forwardMessageUserSummary(from string, summaries map[types.Uid]map[string]any) map[string]any {
uid := types.ParseUid(from)
if uid.IsZero() {
return nil
}
if summary, ok := summaries[uid]; ok && summary != nil {
return summary
}
return map[string]any{
"user_id": uid.UserId(),
"display_name": uid.UserId(),
}
}
func prepareHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) error {
if msg == nil || msg.TenantID != tenantID || strings.TrimSpace(msg.Topic) == "" {
return types.ErrMalformed
}
if msg.SeqId > 0 {
return nil
}
if !kafkaEnabled() && globals.hub == nil {
return nil
}
if store.Topics == nil {
return types.ErrMalformed
}
topicRecord, err := store.Topics.Get(tenantID, msg.Topic)
if err != nil {
return err
}
if topicRecord == nil {
return types.ErrNotFound
}
msg.SeqId = topicRecord.SeqId + 1
if msg.CreatedAt.IsZero() {
msg.ObjHeader.CreatedAt = time.Now().UTC().Round(time.Millisecond)
}
if msg.UpdatedAt.IsZero() {
msg.ObjHeader.UpdatedAt = msg.CreatedAt
}
return nil
}
func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message, markedReadBySender bool) {
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
return
}
timestamp := msg.CreatedAt
if timestamp.IsZero() {
timestamp = time.Now().UTC().Round(time.Millisecond)
}
select {
case globals.hub.routeSrv <- &ServerComMessage{
TenantID: tenantID,
RcptTo: msg.Topic,
AsUser: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
Data: &MsgServerData{
Topic: msg.Topic,
From: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
SeqId: msg.SeqId,
Head: msg.Head,
Content: msg.Content,
},
}:
default:
logs.Warn.Printf("im message forward: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
}
broadcastHTTPForwardPresence(tenantID, msg, timestamp)
sendPushForHTTPForwardMessage(tenantID, msg, markedReadBySender, timestamp)
}
func broadcastHTTPForwardPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
return
}
uid1, uid2, err := types.ParseP2P(msg.Topic)
if err != nil {
return
}
from := types.ParseUid(msg.From).UserId()
for _, rcpt := range []struct {
uid types.Uid
src string
}{
{uid: uid1, src: uid2.UserId()},
{uid: uid2, src: uid1.UserId()},
} {
actor := from
if actor == rcpt.uid.UserId() {
actor = ""
}
select {
case globals.hub.routeSrv <- &ServerComMessage{
TenantID: tenantID,
RcptTo: rcpt.uid.UserId(),
Timestamp: timestamp,
Pres: &MsgServerPres{
Topic: "me",
What: "msg",
Src: rcpt.src,
SeqId: msg.SeqId,
AcsActor: actor,
SkipTopic: msg.Topic,
},
}:
default:
logs.Warn.Printf("im message forward: hub presence queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, rcpt.uid.UserId())
}
}
}
func sendPushForHTTPForwardMessage(tenantID types.TenantID, msg *types.Message, markedReadBySender bool, timestamp time.Time) {
if msg == nil || msg.SeqId <= 0 || msg.Topic == "" || store.Topics == nil {
return
}
subs, err := store.Topics.GetSubs(tenantID, msg.Topic, nil)
if err != nil {
logs.Warn.Printf("im message forward: load recipients topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
return
}
contentType, _ := msg.Head["mime"].(string)
receipt := &push.Receipt{
TenantID: tenantID,
To: make(map[types.Uid]push.Recipient, len(subs)),
Payload: push.Payload{
What: push.ActMsg,
Silent: false,
Topic: msg.Topic,
From: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
SeqId: msg.SeqId,
ContentType: contentType,
Content: msg.Content,
},
}
fromUid := types.ParseUid(msg.From)
for _, sub := range subs {
uid := types.ParseUid(sub.User)
mode := sub.ModeWant & sub.ModeGiven
if uid.IsZero() || !mode.IsPresencer() || !mode.IsReader() {
continue
}
delivered := httpForwardDeliveredCount(tenantID, uid)
if uid == fromUid && delivered == 0 {
delivered = 1
}
recipient := push.Recipient{
Delivered: delivered,
Silent: types.GetTopicCat(msg.Topic) == types.TopicCatGrp && boolFromAny(groupPrivateMap(sub.Private)["mute_notifications"]),
ShouldIncrementUnreadCountInCache: uid != fromUid || !markedReadBySender,
}
receipt.To[uid] = recipient
}
if len(receipt.To) > 0 {
sendPush(receipt)
}
}
func httpForwardDeliveredCount(tenantID types.TenantID, uid types.Uid) int {
delivered := 0
if globals.sessionStore != nil {
globals.sessionStore.Range(func(_ string, sess *Session) bool {
if sess != nil && !sess.isCluster() && !sess.isProxy() && sess.tenantID == tenantID && sess.uid == uid {
delivered++
}
return true
})
}
if presence, err := store.GetUserPresence(tenantID, uid); err != nil {
logs.Warn.Printf("im message forward: read presence uid=%s: %v", uid.UserId(), err)
} else if presence != nil && presence.Online && presence.SessionCount > delivered {
delivered = presence.SessionCount
}
return delivered
}
func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
head := types.KVMap{}
if msg != nil {
for key, value := range msg.Head {
head[key] = value
}
msgType := imMessageType(msg)
if strings.TrimSpace(fmt.Sprint(head["type"])) == "" || fmt.Sprint(head["type"]) == "<nil>" {
head["type"] = msgType
}
if strings.TrimSpace(fmt.Sprint(head["x-im-type"])) == "" || fmt.Sprint(head["x-im-type"]) == "<nil>" {
head["x-im-type"] = msgType
}
head["x-im-forward-source"] = map[string]any{"topic": msg.Topic, "seq": msg.SeqId, "from": msg.From}
}
head["x-im-forwarded"] = true
head["x-im-client-mid"] = clientMid
return head
}
func forwardBundleMessageHead(clientMid string) types.KVMap {
return types.KVMap{
"type": "forward_bundle",
"x-im-type": "forward_bundle",
"x-im-forwarded": true,
"x-im-client-mid": clientMid,
}
}
func forwardedMessageContent(msg *types.Message, comment string) any {
if strings.TrimSpace(comment) == "" {
if msg == nil {
return nil
}
return msg.Content
}
return map[string]any{
"forwarded": true,
"comment": strings.TrimSpace(comment),
"snapshot": favoriteSnapshot(msg),
}
}
func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) {
if rawURL == "" || len(rawURL) > 2048 {
return "block", "high", "invalid_url"
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "block", "high", "invalid_url"
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return "block", "high", "unsupported_scheme"
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
return "block", "high", "private_host"
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return "block", "high", "private_host"
}
}
normalized := strings.ToLower(rawURL)
for _, marker := range []string{"javascript:", "data:", "file:", "password=", "passwd=", "token=", "access_token=", "xn--"} {
if strings.Contains(normalized, marker) {
return "warn", "medium", "suspicious_url"
}
}
return "allow", "low", "ok"
}
func shortTokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return base64.RawURLEncoding.EncodeToString(sum[:])[:16]
}
func messageText(msg types.Message) string {
switch content := msg.Content.(type) {
case string:
return content
case map[string]any:
for _, key := range []string{"text", "txt", "content", "title", "summary", "name", "url"} {
if value := stringFromAny(content[key]); value != "" {
return value
}
}
}
encoded, _ := json.Marshal(msg.Content)
return string(encoded)
}
func messageAvailability(msg types.Message) string {
if msg.DeletedAt != nil || msg.RecalledAt != nil {
return "removed"
}
return "available"
}
func textSnippet(text, query string) string {
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) <= 120 || strings.TrimSpace(query) == "" {
return text
}
idx := runeIndexFold(text, query)
if idx < 0 {
idx = 0
}
start := idx - 40
if start < 0 {
start = 0
}
end := start + 120
if end > len(runes) {
end = len(runes)
}
return string(runes[start:end])
}
func textMatches(text, query string) []map[string]any {
query = strings.TrimSpace(query)
if query == "" {
return []map[string]any{}
}
idx := runeIndexFold(text, query)
if idx < 0 {
return []map[string]any{}
}
return []map[string]any{{"start": idx, "end": idx + len([]rune(query))}}
}
func runeIndexFold(text, query string) int {
query = strings.TrimSpace(query)
if text == "" || query == "" {
return -1
}
textRunes := []rune(strings.ToLower(text))
queryRunes := []rune(strings.ToLower(query))
if len(queryRunes) == 0 || len(queryRunes) > len(textRunes) {
return -1
}
for i := 0; i <= len(textRunes)-len(queryRunes); i++ {
matched := true
for j := range queryRunes {
if textRunes[i+j] != queryRunes[j] {
matched = false
break
}
}
if matched {
return i
}
}
return -1
}
func validIMRealName(name, idType, idNumber string) bool {
if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" {
return false
}
return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber)
}
func protectIMSensitive(tenantID types.TenantID, value string) string {
return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive")))
}
func maskIMName(name string) string {
runes := []rune(name)
if len(runes) <= 1 {
return name
}
return string(runes[0]) + "*"
}
func maskIMIDNumber(id string) string {
if len(id) <= 8 {
return id
}
return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:]
}
func validIMIndependentPassword(password string) bool {
if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) {
return false
}
if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] {
return false
}
ascending, descending := true, true
for i := 1; i < len(password); i++ {
ascending = ascending && password[i] == password[i-1]+1
descending = descending && password[i]+1 == password[i-1]
}
return !ascending && !descending
}
func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool {
if req.Body == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
return true
}
func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) {
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(fallbackCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if store.Tenants == nil {
logs.Err.Println("im http: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
return tenant, true
}
func resolveIMHTTPRegistrationTenant(wrt http.ResponseWriter, req *http.Request, body imVerificationCreateRequest) (*types.Tenant, bool) {
if body.RegistrationType == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
return nil, false
}
headerCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
bodyCode := strings.TrimSpace(body.TenantCode)
var tenantCode string
switch *body.RegistrationType {
case registrationTypePersonal:
if bodyCode != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if headerCode != "" && !strings.EqualFold(headerCode, imDefaultTenantCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
tenantCode = imDefaultTenantCode
case registrationTypeEnterprise:
if bodyCode == "" {
bodyCode = headerCode
} else if headerCode != "" && !strings.EqualFold(headerCode, bodyCode) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if bodyCode == "" || len(bodyCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if strings.EqualFold(bodyCode, imDefaultTenantCode) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
tenantCode = bodyCode
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "registration_type"})
return nil, false
}
return resolveIMHTTPTenant(wrt, req, tenantCode)
}
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
Code: 0,
Data: data,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) {
writeIMJSON(wrt, status, imHTTPResponse{
Code: code,
Reason: reason,
Message: message,
Details: details,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) {
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.WriteHeader(status)
if err := json.NewEncoder(wrt).Encode(resp); err != nil {
logs.Warn.Println("im http: failed to write response", err)
}
}
func isAllowedVerificationScene(scene string) bool {
switch scene {
case "register", "login", "reset_password", "sensitive_action":
return true
default:
return false
}
}
func normalizeIMPhone(phone, country string) (string, error) {
if strings.TrimSpace(country) == "" {
country = "CN"
}
number, err := phonenumbers.Parse(strings.TrimSpace(phone), country)
if err != nil || !phonenumbers.IsValidNumber(number) {
return "", types.ErrMalformed
}
return phonenumbers.Format(number, phonenumbers.E164), nil
}
func maskIMPhone(phone string) string {
if len(phone) <= 7 {
return phone
}
return phone[:4] + "****" + phone[len(phone)-3:]
}
func maskIMProfilePhone(phone string) string {
phone = strings.TrimSpace(phone)
if strings.HasPrefix(phone, "+86") {
phone = strings.TrimPrefix(phone, "+86")
}
if phone == "" {
return ""
}
if len(phone) <= 7 {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
func randomIMNumericCode(length int) (string, error) {
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
code := fmt.Sprintf("%0*d", length, n.Int64())
return code, nil
}
func randomIMToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
h := sha256.New()
h.Write(globals.apiKeySalt)
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
for _, part := range parts {
h.Write([]byte{0})
h.Write([]byte(part))
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) {
return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt)
}
func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) {
nonce, err := randomIMToken(12)
if err != nil {
return "", err
}
claims := imQRCodeClaims{
Version: 1,
TenantID: tenantID,
UserID: uid.UserId(),
Topic: topic,
Purpose: purpose,
Expires: expiresAt.Unix(),
Nonce: nonce,
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
mac := signIMQRCodePayload(encodedPayload)
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil
}
func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) {
payload, signature, ok := strings.Cut(token, ".")
if !ok || payload == "" || signature == "" {
return nil, types.ErrMalformed
}
got, err := base64.RawURLEncoding.DecodeString(signature)
if err != nil {
return nil, types.ErrMalformed
}
want := signIMQRCodePayload(payload)
if !hmac.Equal(got, want) {
return nil, types.ErrMalformed
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return nil, types.ErrMalformed
}
var claims imQRCodeClaims
if err = json.Unmarshal(raw, &claims); err != nil {
return nil, types.ErrMalformed
}
if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() {
return nil, types.ErrMalformed
}
if expectedPurpose != "" && claims.Purpose != expectedPurpose {
return nil, types.ErrMalformed
}
return &claims, nil
}
func signIMQRCodePayload(payload string) []byte {
mac := hmac.New(sha256.New, globals.apiKeySalt)
mac.Write([]byte("im:qrcode:v1"))
mac.Write([]byte{0})
mac.Write([]byte(payload))
return mac.Sum(nil)
}
func verificationChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/verification-codes/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func passwordResetChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/password-reset/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func securityChallengeIDFromPath(path string) string {
const marker = "/v1/im/security/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func riskChallengeIDFromPath(path string) string {
const marker = "/v1/im/security/risk-challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "confirm" {
return ""
}
return strings.TrimSpace(challengeID)
}
func cleanStringList(values []string, limit int) []string {
if limit <= 0 {
limit = len(values)
}
capacity := len(values)
if capacity > limit {
capacity = limit
}
cleaned := make([]string, 0, capacity)
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
cleaned = append(cleaned, value)
if len(cleaned) >= limit {
break
}
}
return cleaned
}
func isAllowedPublicUploadPurpose(purpose string) bool {
switch strings.TrimSpace(purpose) {
case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback,
types.PublicUploadPurposeUserAvatar,
types.PublicUploadPurposeGroupAvatar:
return true
default:
return false
}
}
func isAllowedPublicUploadMime(purpose, mimeType string) bool {
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
switch purpose {
case types.PublicUploadPurposeEnterpriseLicense:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp") ||
mimeType == "application/pdf"
case types.PublicUploadPurposeTenantFeedback:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")
case types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar:
return strings.HasPrefix(mimeType, "image/jpeg") ||
strings.HasPrefix(mimeType, "image/png") ||
strings.HasPrefix(mimeType, "image/webp")
default:
return false
}
}
func detectIMUploadMime(file interface {
io.Reader
io.Seeker
}, contentType string) (string, error) {
buff := make([]byte, 512)
n, err := file.Read(buff)
if err != nil && err != io.EOF {
return "", err
}
if _, err = file.Seek(0, io.SeekStart); err != nil {
return "", err
}
mimeType := http.DetectContentType(buff[:n])
if mimeType == "application/octet-stream" {
if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" {
mimeType = parsed
}
}
return strings.ToLower(strings.TrimSpace(mimeType)), nil
}
func isValidCreditCode(code string) bool {
if len(code) != 18 {
return false
}
for _, r := range code {
if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') {
return false
}
}
return true
}
func isValidIMEmail(email string) bool {
if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") {
return false
}
return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email)
}
func cleanTenantCodeHint(code string) string {
code = strings.TrimSpace(code)
if len(code) > 64 {
return code[:64]
}
return code
}
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
targetType := strings.ToLower(strings.TrimSpace(target.Type))
snapshot := map[string]any{"type": targetType}
switch targetType {
case "user":
uid := types.ParseUserId(strings.TrimSpace(target.UserID))
if uid.IsZero() || uid == reporter {
return "", 0, "", 0, nil, types.ErrMalformed
}
if store.Users != nil {
if _, err := store.Users.Get(tenantID, uid); err != nil {
return "", 0, "", 0, nil, err
}
}
snapshot["user_id"] = uid.UserId()
return targetType, uid, "", 0, snapshot, nil
case "group":
topic := strings.TrimSpace(target.Topic)
if types.GetTopicCat(topic) != types.TopicCatGrp {
return "", 0, "", 0, nil, types.ErrMalformed
}
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
return "", 0, "", 0, nil, err
}
snapshot["topic"] = topic
return targetType, 0, topic, 0, snapshot, nil
case "message":
topic := strings.TrimSpace(target.Topic)
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
if topic == "" || seq <= 0 || err != nil {
return "", 0, "", 0, nil, types.ErrMalformed
}
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
return "", 0, "", 0, nil, err
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
if err != nil {
return "", 0, "", 0, nil, err
}
snapshot["topic"] = topic
snapshot["seq"] = seq
snapshot["from"] = messageFromUserID(msg.From)
snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano)
return targetType, 0, topic, seq, snapshot, nil
default:
return "", 0, "", 0, nil, types.ErrMalformed
}
}
func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error {
if store.Subs == nil {
return types.ErrPermissionDenied
}
sub, err := store.Subs.Get(tenantID, topic, uid, false)
if err != nil {
return err
}
if sub == nil || !sub.ModeGiven.IsReader() {
return types.ErrPermissionDenied
}
return nil
}
func publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType, template, text string, actors []imSystemMessageActor, actions []imSystemMessageAction, extra map[string]any) {
if tenantID == types.ZeroTenantID || strings.TrimSpace(topic) == "" || store.Store == nil || !store.Store.IsOpen() || store.Topics == nil || store.Messages == nil {
return
}
msgType = strings.TrimSpace(msgType)
if msgType == "" {
msgType = "system_notice"
}
if msgType != "system_notice" && msgType != "system_action" {
logs.Warn.Printf("im system message: unsupported type %q for topic %s", msgType, topic)
return
}
template = strings.TrimSpace(template)
text = strings.TrimSpace(text)
if template == "" || text == "" {
return
}
topicRecord, err := store.Topics.Get(tenantID, topic)
if err != nil || topicRecord == nil {
if err != nil {
logs.Warn.Printf("im system message: failed to load topic %s: %v", topic, err)
}
return
}
content := map[string]any{
"text": text,
"template": template,
"level": "normal",
}
if len(actors) > 0 {
content["actors"] = actors
}
if len(actions) > 0 {
content["actions"] = actions
}
if len(extra) > 0 {
content["extra"] = extra
}
now := time.Now().UTC().Round(time.Millisecond)
msg := &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
TenantID: tenantID,
SeqId: topicRecord.SeqId + 1,
Topic: topic,
From: types.ZeroUid.String(),
Head: types.KVMap{
"x-im-schema": 1,
"x-im-type": msgType,
"x-im-template": template,
"x-im-from": "system",
},
Content: content,
}
saved, err := saveMessageMaybeKafka(context.Background(), tenantID, msg, nil, false)
if err != nil && !errors.Is(err, types.ErrDuplicate) {
logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err)
return
}
if saved != nil && saved.SeqId > 0 {
msg.SeqId = saved.SeqId
}
if saved != nil && saved.Duplicate {
return
}
broadcastIMConversationSystemMessage(tenantID, msg, now)
sendPushForStoredGroupMessage(tenantID, msg)
}
func broadcastIMConversationSystemMessage(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || strings.TrimSpace(msg.Topic) == "" {
return
}
if timestamp.IsZero() {
timestamp = time.Now().UTC().Round(time.Millisecond)
}
out := &ServerComMessage{
TenantID: tenantID,
RcptTo: msg.Topic,
AsUser: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
PreserveTopicName: true,
Data: &MsgServerData{
Topic: msg.Topic,
From: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
SeqId: msg.SeqId,
Head: msg.Head,
Content: msg.Content,
},
}
select {
case globals.hub.routeSrv <- out:
default:
logs.Warn.Printf("im system message: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
}
deliverIMConversationSystemMessageToP2PSessions(tenantID, msg, out)
if !publishRealtimeP2PConversationSystemMessage(tenantID, msg.Topic, out) {
publishRealtimeTopicMessage(msg.Topic, out)
}
broadcastIMConversationSystemPresence(tenantID, msg, timestamp)
}
func publishIMGroupInvitationNotification(tenantID types.TenantID, inv *types.GroupInvitation) {
if tenantID == types.ZeroTenantID || inv == nil || inv.TargetUserID.IsZero() || store.Security == nil {
return
}
eventID := stableIMEventID("group-invitation", inv.InvitationID, inv.TargetUserID.UserId(), "created")
action := types.KVMap{
"type": "group_invitation_accept",
"invitation_id": inv.InvitationID,
"topic": inv.Topic,
"inviter_id": inv.InviterUserID.UserId(),
}
if inv.Message != "" {
action["message"] = inv.Message
}
if err := createIMSystemNotification(tenantID, inv.TargetUserID, eventID, "group.invitation.created",
"群聊邀请", "邀请你加入群聊。", action); err != nil {
logs.Warn.Printf("group invitation: create notification invitation=%s target=%s: %v", inv.InvitationID, inv.TargetUserID.UserId(), err)
}
}
func publishIMGroupMemberJoinedSystemNotice(tenantID types.TenantID, topic string, inviter, target types.Uid, text string, extra map[string]any) {
if target.IsZero() {
return
}
actors := make([]imSystemMessageActor, 0, 2)
if !inviter.IsZero() && inviter != target {
actors = append(actors, imSystemMessageActor{
UserID: inviter.UserId(),
Click: map[string]any{"type": "user_profile", "target_id": inviter.UserId()},
})
}
actors = append(actors, imSystemMessageActor{
UserID: target.UserId(),
Click: map[string]any{"type": "user_profile", "target_id": target.UserId()},
})
publishIMConversationSystemNotice(tenantID, topic,
"system_action", "group_member_joined", text,
actors,
[]imSystemMessageAction{{Type: "group_profile", Label: "查看群聊", TargetID: topic}},
extra)
}
func deliverIMConversationSystemMessageToP2PSessions(tenantID types.TenantID, msg *types.Message, out *ServerComMessage) {
if globals.sessionStore == nil || msg == nil || out == nil || out.Data == nil {
return
}
uid1, uid2, err := types.ParseP2P(msg.Topic)
if err != nil {
return
}
participants := map[types.Uid]struct{}{uid1: {}, uid2: {}}
globals.sessionStore.Range(func(_ string, session *Session) bool {
if session == nil || session.tenantID != tenantID {
return true
}
if _, ok := participants[session.uid]; !ok {
return true
}
if _, err := types.P2PNameForUser(session.uid, msg.Topic); err != nil {
return true
}
if session.getSub(msg.Topic) != nil {
return true
}
msgCopy, ok := p2pConversationSystemMessageForUser(out, msg.Topic, session.uid)
if !ok {
return true
}
if !session.queueOut(msgCopy) {
logs.Warn.Printf("im system message: session queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, session.uid.UserId())
}
return true
})
}
func publishRealtimeP2PConversationSystemMessage(tenantID types.TenantID, topic string, out *ServerComMessage) bool {
uid1, uid2, err := types.ParseP2P(topic)
if err != nil {
return false
}
for _, uid := range []types.Uid{uid1, uid2} {
msgCopy, ok := p2pConversationSystemMessageForUser(out, topic, uid)
if !ok {
continue
}
publishRealtimeUserMessage(uid, msgCopy)
}
return true
}
func p2pConversationSystemMessageForUser(out *ServerComMessage, topic string, uid types.Uid) (*ServerComMessage, bool) {
if out == nil || uid.IsZero() {
return nil, false
}
viewTopic, err := types.P2PNameForUser(uid, topic)
if err != nil || viewTopic == "" {
return nil, false
}
msgCopy := out.copy()
msgCopy.RcptTo = uid.UserId()
if msgCopy.Data != nil {
msgCopy.Data.Topic = viewTopic
}
if msgCopy.Info != nil {
msgCopy.Info.Topic = viewTopic
}
if msgCopy.Pres != nil {
msgCopy.Pres.Topic = viewTopic
}
return msgCopy, true
}
func broadcastIMConversationSystemPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
return
}
uid1, uid2, err := types.ParseP2P(msg.Topic)
if err != nil {
return
}
for _, uid := range []types.Uid{uid1, uid2} {
select {
case globals.hub.routeSrv <- &ServerComMessage{
TenantID: tenantID,
RcptTo: uid.UserId(),
Timestamp: timestamp,
PreserveTopicName: true,
Pres: &MsgServerPres{
Topic: "me",
What: "msg",
Src: msg.Topic,
SeqId: msg.SeqId,
SkipTopic: msg.Topic,
},
}:
default:
logs.Warn.Printf("im system message: hub presence queue full topic=%s seq=%d user=%s", msg.Topic, msg.SeqId, uid.UserId())
}
}
}
func systemNotificationIDFromPath(path string) string {
const marker = "/v1/im/system-notifications/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
eventID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "read" {
return ""
}
return strings.TrimSpace(eventID)
}
func groupInvitationResourcePath(path string) (string, string, bool) {
const marker = "/v1/im/group-invitations/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], marker)
invitationID, tail, ok := strings.Cut(rest, "/")
if !ok || strings.TrimSpace(invitationID) == "" {
return "", "", false
}
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
}
func messageReceiptsPath(path string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, rest, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" {
return "", 0, false
}
seqValue, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "receipts" {
return "", 0, false
}
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
return topic, seq, true
}
func topicFromMessagesSearchPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/messages/search"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromMessageMutationsPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/message-mutations"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromMediaPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/media"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromCallsPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/calls"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromDraftPath(path string) string {
const prefix = "/v1/im/topics/"
const suffix = "/draft"
return topicFromPathWithSuffix(path, prefix, suffix)
}
func topicFromPathWithSuffix(path, prefix, suffix string) string {
idx := strings.Index(path, prefix)
if idx < 0 || !strings.HasSuffix(path, suffix) {
return ""
}
rest := strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix)
return strings.Trim(rest, "/")
}
func parseIMMutationSince(primary, fallback string) (int64, error) {
value := strings.TrimSpace(primary)
if value == "" {
value = strings.TrimSpace(fallback)
}
if value == "" {
return 0, nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0, strconv.ErrSyntax
}
return parsed, nil
}
func messageContextPath(path string) (string, int64, bool) {
const prefix = "/v1/im/topics/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], prefix)
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
return "", 0, false
}
seq, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || seq <= 0 {
return "", 0, false
}
return parts[0], seq, true
}
func messageActionPath(path, action string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, rest, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" {
return "", 0, false
}
seqValue, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != action {
return "", 0, false
}
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
return topic, seq, true
}
func topicMessagesActionPath(path, action string) (string, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, suffix, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" || suffix != action {
return "", false
}
return topic, 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 conversationResourceFromPath(path string) (string, string) {
const prefix = "/v1/im/conversations/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", ""
}
rest := strings.Trim(strings.TrimPrefix(path[idx:], prefix), "/")
topic, tail, ok := strings.Cut(rest, "/")
if !ok {
return topic, ""
}
return strings.TrimSpace(topic), strings.Trim(strings.TrimSpace(tail), "/")
}
func groupResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/groups/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
topic, tail, ok := strings.Cut(rest, "/")
if topic == "" {
return "", "", false
}
if !ok {
return topic, "", true
}
return topic, strings.Trim(tail, "/"), true
}
func userResourcePath(path string) (string, string, bool) {
const prefix = "/v1/im/users/"
idx := strings.Index(path, prefix)
if idx < 0 {
return "", "", false
}
rest := strings.TrimPrefix(path[idx:], prefix)
identifier, tail, ok := strings.Cut(rest, "/")
identifier = strings.TrimSpace(identifier)
if !ok || identifier == "" || tail == "" {
return "", "", false
}
return identifier, strings.Trim(tail, "/"), true
}
func groupMemberProfilePath(tail string) (types.Uid, bool) {
parts := strings.Split(strings.Trim(tail, "/"), "/")
if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" {
return types.ZeroUid, false
}
uid := types.ParseUserId(strings.TrimSpace(parts[1]))
return uid, !uid.IsZero()
}
func groupJoinRequestActionPath(tail string) (string, string, bool) {
parts := strings.Split(strings.Trim(tail, "/"), "/")
if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" {
return "", "", false
}
switch parts[2] {
case "accept", "reject":
return strings.TrimSpace(parts[1]), parts[2], true
default:
return "", "", false
}
}
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
}
left, right, err := types.ParseP2P(topic)
if err != nil {
return types.ZeroUid
}
if left == uid {
return right
}
if right == uid {
return left
}
return types.ZeroUid
}
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
topic = strings.TrimSpace(topic)
if len(topic) < 3 || uid.IsZero() {
return "", types.ZeroUid
}
if types.GetTopicCat(topic) == types.TopicCatMe {
peer := types.ParseUserId(topic)
if peer.IsZero() || peer == uid {
return "", types.ZeroUid
}
return uid.P2PName(peer), peer
}
peer := peerFromP2PTopic(topic, uid)
if peer.IsZero() {
return "", types.ZeroUid
}
return topic, peer
}
func resolveConversationContextTopic(principal *imPrincipal, topic string) (string, types.Uid, error) {
normalized, peer := normalizeP2PConversationTopic(topic, principal.Uid)
if normalized != "" && !peer.IsZero() {
return normalized, peer, nil
}
topic = strings.TrimSpace(topic)
if types.GetTopicCat(topic) != types.TopicCatP2P {
return "", types.ZeroUid, nil
}
if _, _, err := types.ParseP2P(topic); err != nil {
return "", types.ZeroUid, nil
}
cursor := ""
for {
items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid, "", "", cursor, 100)
if err != nil {
return "", types.ZeroUid, err
}
for _, item := range items {
if strings.TrimSpace(item.Friendship.Topic) == topic && !item.Friendship.PeerID.IsZero() {
return topic, item.Friendship.PeerID, nil
}
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
return "", types.ZeroUid, nil
}
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized
}
return strings.TrimSpace(topic)
}