fix: resolve im backend issue list regressions

This commit is contained in:
richjohnrambo
2026-09-17 14:59:16 +08:00
parent 760de35e10
commit 50f8581667
13 changed files with 750 additions and 181 deletions
+464 -88
View File
@@ -1486,6 +1486,25 @@ func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) {
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
@@ -2241,6 +2260,12 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
if !ok {
return
}
for _, attendeeID := range attendeeIDs {
if !ensureIMMeetingUserExists(principal.TenantID, attendeeID) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "attendee_user_ids", "user_id": attendeeID.UserId()})
return
}
}
recorderID := principal.Uid
if value := strings.TrimSpace(body.RecorderUserID); value != "" {
recorderID = types.ParseUserId(value)
@@ -2568,6 +2593,27 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID
if !ok {
return
}
var body struct {
Version int `json:"version"`
}
if req.Body != nil && req.ContentLength != 0 {
if !decodeIMJSONBody(wrt, req, &body) {
return
}
}
if body.Version <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
return
}
current, err := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if current.Version != body.Version {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": current.Version})
return
}
meeting, err := store.Meetings.Cancel(principal.TenantID, principal.Uid, meetingID, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
@@ -2663,6 +2709,9 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
if !validIMReportStatusQuery(wrt, req) || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") {
return
}
reports, nextCursor, err := store.DailyReports.List(principal.TenantID, principal.Uid,
req.URL.Query().Get("scope"),
req.URL.Query().Get("from"),
@@ -2677,12 +2726,7 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{
"items": dailyReportListPayloads(reports),
"next_cursor": nextCursor,
"permissions": map[string]any{
"can_create": true,
"makeup_allowed_days": 7,
"can_view_managed": false,
"can_remind_unsubmitted": false,
},
"permissions": dailyReportPermissions(req),
})
case http.MethodPost:
var body struct {
@@ -2692,7 +2736,7 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) {
return
}
reportDate, err := parseIMDate(body.ReportDate)
if err != nil {
if err != nil || !validIMReportDateRange(reportDate) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
return
}
@@ -2781,8 +2825,8 @@ func serveIMDailyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, rep
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.submitted", report.ReportID,
[]types.Uid{principal.Uid}, "日报已提交", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "daily_report", "daily_report.draft.saved", report.ReportID,
[]types.Uid{principal.Uid}, "日报", report.ReportDate.Format("2006-01-02"), map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, dailyReportDetailPayload(report, principal.Uid))
}
@@ -2801,6 +2845,19 @@ func serveIMDailyReportSubmit(wrt http.ResponseWriter, req *http.Request, report
if !decodeIMJSONBody(wrt, req, &body) {
return
}
current, err := store.DailyReports.Get(principal.TenantID, principal.Uid, reportID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !validIMReportDateRange(current.ReportDate) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "report_date"})
return
}
if strings.TrimSpace(current.Content) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "content"})
return
}
report, err := store.DailyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
@@ -2887,6 +2944,9 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
if !validIMReportStatusQuery(wrt, req) || !validIMOptionalMonthQuery(wrt, req, "from_month") || !validIMOptionalMonthQuery(wrt, req, "to_month") {
return
}
reports, nextCursor, err := store.MonthlyReports.List(principal.TenantID, principal.Uid,
req.URL.Query().Get("scope"),
req.URL.Query().Get("from_month"),
@@ -2906,7 +2966,7 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) {
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if !validIMMonth(body.Month) {
if !validIMMonth(body.Month) || !validIMMonthRange(strings.TrimSpace(body.Month)) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"})
return
}
@@ -2995,8 +3055,8 @@ func serveIMMonthlyReportSaveDraft(wrt http.ResponseWriter, req *http.Request, r
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID,
[]types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.draft.saved", report.ReportID,
[]types.Uid{principal.Uid}, "月报", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
@@ -3015,13 +3075,30 @@ func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, repo
if !decodeIMJSONBody(wrt, req, &body) {
return
}
current, err := store.MonthlyReports.Get(principal.TenantID, principal.Uid, reportID)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if !validIMMonthRange(current.Month) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "month"})
return
}
if strings.TrimSpace(current.WorkSummary) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "work_summary"})
return
}
if strings.TrimSpace(current.NextPlan) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonValidationFailed, "validation failed", map[string]any{"field": "next_plan"})
return
}
report, err := store.MonthlyReports.Submit(principal.TenantID, principal.Uid, reportID, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.reopened", report.ReportID,
[]types.Uid{principal.Uid}, "月报已重开", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "monthly_report", "monthly_report.submitted", report.ReportID,
[]types.Uid{principal.Uid}, "月报已提交", report.Month, map[string]any{"report_id": report.ReportID, "version": report.Version, "status": report.Status})
writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid))
}
@@ -3271,23 +3348,25 @@ func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) {
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"`
GroupInviteVerification *bool `json:"group_invite_verification"`
Version int `json:"version"`
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,
GroupInviteVerification: body.GroupInviteVerification,
Version: body.Version,
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)
@@ -3611,7 +3690,7 @@ func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
writeIMSuccess(wrt, req, map[string]any{"items": systemNotificationPayloads(items), "next_cursor": nextCursor})
}
func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
@@ -3892,7 +3971,15 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
if err != nil {
if saved != nil && saved.Pending {
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}
result := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
result["favorite_id"] = favoriteID
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
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
@@ -4002,16 +4089,21 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
if err != nil {
if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
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"],
}
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
@@ -4094,14 +4186,17 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
if err != nil {
if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
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",
}
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
@@ -4664,6 +4759,10 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
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
@@ -4697,6 +4796,48 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
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"})
@@ -4920,9 +5061,31 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
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,
"friend.request.created", "新的好友申请", message); err != nil {
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)
}
@@ -5642,11 +5805,13 @@ func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic strin
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_snapshot": groupDetailPayload(group, selfSub),
"group": groupPayload,
"group_snapshot": groupPayload,
})
}
@@ -6569,6 +6734,10 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
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 {
@@ -6595,6 +6764,10 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
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
@@ -6750,12 +6923,14 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.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": groupDetailPayload(group, selfSub),
"status": status,
"source": "qr",
"purpose": claims.Purpose,
"expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano),
"group": groupPayload,
"group_snapshot": groupPayload,
"status": status,
"source": "qr",
})
return
}
@@ -7406,16 +7581,23 @@ func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPr
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 errors.Is(err, types.ErrUnsupported) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
} else {
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
@@ -7437,6 +7619,9 @@ func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Dur
}
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
@@ -7790,6 +7975,21 @@ func parseIMDate(raw string) (time.Time, error) {
return parsed, nil
}
func validIMReportDateRange(date time.Time) bool {
year := date.UTC().Year()
nowYear := time.Now().UTC().Year()
return year >= nowYear-1 && year <= nowYear+1
}
func validIMReportStatusQuery(wrt http.ResponseWriter, req *http.Request) bool {
status := strings.TrimSpace(req.URL.Query().Get("status"))
if status == "" || status == types.DailyReportStatusDraft || status == types.DailyReportStatusSubmitted {
return true
}
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "status"})
return false
}
func parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) {
if strings.TrimSpace(raw) == "" {
return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil
@@ -7802,7 +8002,8 @@ func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field
if value == "" {
return true
}
if _, err := parseIMDate(value); err != nil {
date, err := parseIMDate(value)
if err != nil || !validIMReportDateRange(date) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
return false
}
@@ -7816,6 +8017,26 @@ func validIMMonth(raw string) bool {
return true
}
func validIMMonthRange(month string) bool {
parsed, err := time.ParseInLocation("2006-01", strings.TrimSpace(month), time.UTC)
if err != nil {
return false
}
return validIMReportDateRange(parsed)
}
func validIMOptionalMonthQuery(wrt http.ResponseWriter, req *http.Request, field string) bool {
value := strings.TrimSpace(req.URL.Query().Get(field))
if value == "" {
return true
}
if !validIMMonth(value) || !validIMMonthRange(value) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
return false
}
return true
}
func requireIMIdempotencyKey(wrt http.ResponseWriter, req *http.Request) (string, bool) {
key := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if key == "" {
@@ -8035,7 +8256,6 @@ func mergeGroupListSettingsPayload(payload map[string]any, settings *types.Group
payload["topic"] = settings.Topic
payload["name"] = settings.Name
payload["avatar_file_id"] = settings.AvatarFileID
payload["member_count"] = settings.MemberCount
payload["updated_at"] = settings.UpdatedAt.UTC().Format(time.RFC3339Nano)
return payload
}
@@ -8190,6 +8410,37 @@ func groupMemberAccess(member types.GroupMember) types.AccessMode {
}
}
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 groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
data := groupMemberPayload(*sub, true, ctx.Remark)
data["topic"] = topic
@@ -8316,7 +8567,6 @@ func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSett
payload["avatar_file_id"] = settings.AvatarFileID
payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId()
payload["member_count"] = settings.MemberCount
payload["status"] = settings.GroupStatus
payload["version"] = settings.Version
return payload
@@ -8447,6 +8697,9 @@ func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
"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{
@@ -8455,6 +8708,9 @@ func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
"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},
}
}
@@ -8974,21 +9230,36 @@ func callRecordPayload(record types.CallRecord, viewer types.Uid) map[string]any
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,
"seq": record.SeqID,
"caller_id": record.CallerID.UserId(),
"callee_id": record.CalleeID.UserId(),
"peer_user_id": peer.UserId(),
"direction": direction,
"call_type": record.CallType,
"status": record.Status,
"result": record.Status,
"duration_ms": record.DurationMS,
"started_at": record.StartedAt.UTC().Format(time.RFC3339Nano),
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
"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
@@ -9682,6 +9953,18 @@ func dailyReportDetailPayload(report *types.DailyReport, viewer types.Uid) map[s
return data
}
func dailyReportPermissions(req *http.Request) map[string]any {
scope := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("scope")))
departmentID := strings.TrimSpace(req.URL.Query().Get("department_id"))
managed := scope == "managed" || scope == "department" || departmentID != ""
return map[string]any{
"can_create": scope == "" || scope == "mine" || scope == "self",
"makeup_allowed_days": 7,
"can_view_managed": managed,
"can_remind_unsubmitted": managed,
}
}
func monthlyReportListPayloads(reports []types.MonthlyReport) []map[string]any {
items := make([]map[string]any, 0, len(reports))
for _, report := range reports {
@@ -9836,19 +10119,21 @@ func validIMSecurityChallengeScene(scene string) bool {
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,
"group_invite_verification": settings.GroupInviteVerification,
"version": settings.Version,
"updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano),
"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,
"group_invite_verification": true,
"add_me_methods": true,
"profile_visibility": true,
"last_seen_visibility": true,
"read_receipts": true,
"friend_request_verification": true,
"group_invite_verification": true,
},
}
}
@@ -10221,22 +10506,28 @@ func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]a
}
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 item.Friendship.Status == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) {
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": item.Friendship.Status,
"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": item.Friendship.Status == types.RelationshipFriend, "can_call": imCanCall(item.Friendship.Status == types.RelationshipFriend)},
"capabilities": map[string]any{"can_send": canSend, "can_call": imCanCall(canSend)},
"display_name_source": "profile",
}
if item.Friendship.Remark != "" {
@@ -10303,9 +10594,62 @@ func blockListItemPayload(item types.BlockListItem) map[string]any {
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{
@@ -10330,6 +10674,10 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st
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{
@@ -10372,6 +10720,23 @@ func userSummaryPayload(user *types.User, remark string) map[string]any {
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)) != "" {
@@ -10670,6 +11035,17 @@ func forwardFailurePayload(topic string, seq int64, target, reason string) map[s
}
}
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 == "" {