fix: align IM client interaction edge cases
This commit is contained in:
@@ -3432,9 +3432,13 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
||||
if len(usrq) > 0 {
|
||||
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
||||
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
|
||||
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?)`
|
||||
q, args, _ = sqlx.In(q, usrq)
|
||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at,
|
||||
fs.remark AS friendship_remark
|
||||
FROM im_users AS u
|
||||
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
|
||||
LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
|
||||
WHERE u.id IN (?)`
|
||||
q, args, _ = sqlx.In(q, store.DecodeUid(uid), t.RelationshipFriend, usrq)
|
||||
if !keepDeleted {
|
||||
// Optionally skip deleted users.
|
||||
q += " AND u.state!=?"
|
||||
@@ -3466,6 +3470,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
||||
sub.SetTrusted(common.FromJSON(usr2.Trusted))
|
||||
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
|
||||
sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent)
|
||||
sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(usr2.ID).UserId(), usr2.FriendshipRemark.String)
|
||||
join[joinOn] = sub
|
||||
p2pResolved[joinOn] = true
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ type profileUserRow struct {
|
||||
ProfileRegion sql.NullString `db:"profile_region"`
|
||||
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
|
||||
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
|
||||
FriendshipRemark sql.NullString `db:"friendship_remark"`
|
||||
}
|
||||
|
||||
func (row profileUserRow) effectiveUpdatedAt() time.Time {
|
||||
@@ -151,6 +152,10 @@ func profilePublicFromFields(base any, nickname, avatarFileID, signature, gender
|
||||
}
|
||||
|
||||
func ensureP2PPrivatePeer(private any, peerID string) any {
|
||||
return ensureP2PPrivatePeerRemark(private, peerID, "")
|
||||
}
|
||||
|
||||
func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any {
|
||||
if strings.TrimSpace(peerID) == "" {
|
||||
return private
|
||||
}
|
||||
@@ -161,6 +166,9 @@ func ensureP2PPrivatePeer(private any, peerID string) any {
|
||||
if _, ok := data["peer"]; !ok {
|
||||
data["peer"] = peerID
|
||||
}
|
||||
if remark = strings.TrimSpace(remark); remark != "" {
|
||||
data["remark"] = remark
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1050,9 +1058,13 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
||||
if len(usrq) > 0 {
|
||||
q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
|
||||
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
|
||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
|
||||
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)`
|
||||
q, args, _ = sqlx.In(q, id, usrq)
|
||||
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at,
|
||||
fs.remark AS friendship_remark
|
||||
FROM im_users AS u
|
||||
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
|
||||
LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=?
|
||||
WHERE u.tenant_id=? AND u.id IN (?)`
|
||||
q, args, _ = sqlx.In(q, decodeUID(uid), t.RelationshipFriend, id, usrq)
|
||||
if !keepDeleted {
|
||||
q += " AND u.state!=?"
|
||||
args = append(args, t.StateDeleted)
|
||||
@@ -1075,6 +1087,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
||||
sub.SetTrusted(common.FromJSON(user.Trusted))
|
||||
sub.SetDefaultAccess(user.Access.Auth, user.Access.Anon)
|
||||
sub.SetLastSeenAndUA(user.lastSeen(), user.UserAgent)
|
||||
sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(user.ID).UserId(), user.FriendshipRemark.String)
|
||||
join[joinOn] = sub
|
||||
p2pResolved[joinOn] = true
|
||||
}
|
||||
|
||||
+86
-12
@@ -45,6 +45,7 @@ const (
|
||||
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
||||
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
||||
imReasonPermissionDenied = "PERMISSION_DENIED"
|
||||
imReasonForbidden = "FORBIDDEN"
|
||||
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
||||
imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED"
|
||||
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
|
||||
@@ -587,6 +588,7 @@ 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) {
|
||||
@@ -4430,15 +4432,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
if tail == "messages" {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
if topic == "" {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false})
|
||||
serveIMConversationMessages(wrt, req, principal, topic)
|
||||
return
|
||||
}
|
||||
if tail != "context" {
|
||||
@@ -4466,6 +4460,52 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMSuccess(wrt, req, relationshipContextPayload(ctx))
|
||||
}
|
||||
|
||||
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"})
|
||||
@@ -4904,7 +4944,7 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
|
||||
serveIMGroupOwnerTransfer(wrt, req, topic)
|
||||
return
|
||||
}
|
||||
if tail == "mutes" || strings.HasPrefix(tail, "mutes/") {
|
||||
if tail == "mutes" || tail == "mutes/batch" || strings.HasPrefix(tail, "mutes/") {
|
||||
serveIMGroupMutes(wrt, req, topic, tail)
|
||||
return
|
||||
}
|
||||
@@ -5928,9 +5968,24 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st
|
||||
[]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}},
|
||||
[]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})
|
||||
}
|
||||
dispatchIMGroupEvents(principal.TenantID)
|
||||
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
|
||||
default:
|
||||
@@ -6086,7 +6141,7 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
||||
case tail == "mutes" && req.Method == http.MethodPost:
|
||||
case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost:
|
||||
var body imGroupMuteBatchRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
@@ -6411,6 +6466,10 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier s
|
||||
return
|
||||
}
|
||||
if !imPrivacyAllowsProfile(privacy, ctx.Relationship) {
|
||||
if ctx.Relationship == types.RelationshipBlockedByMe {
|
||||
writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx))
|
||||
return
|
||||
}
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
|
||||
return
|
||||
}
|
||||
@@ -9749,6 +9808,21 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st
|
||||
return data
|
||||
}
|
||||
|
||||
func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any {
|
||||
data := userSummaryPayload(user, ctx.Remark)
|
||||
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 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 {
|
||||
|
||||
@@ -347,10 +347,10 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [
|
||||
var targets []t.Uid
|
||||
for _, uid := range uids {
|
||||
recipient := rcpt.To[uid]
|
||||
if recipient.Delivered > 0 {
|
||||
continue
|
||||
}
|
||||
userData := cloneData(data)
|
||||
if recipient.Delivered > 0 {
|
||||
userData["silent"] = "true"
|
||||
}
|
||||
topic := rcpt.Payload.Topic
|
||||
var audience map[string][]string
|
||||
if useAlias {
|
||||
|
||||
@@ -146,6 +146,30 @@ func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
|
||||
oldTokens := store.DevicePushTokens
|
||||
uid := types.Uid(42)
|
||||
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
|
||||
uid: {{DeviceId: "push-token", Platform: "ios"}},
|
||||
}}
|
||||
defer func() { store.DevicePushTokens = oldTokens }()
|
||||
useAlias := false
|
||||
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias}
|
||||
payloads, _ := preparePayloads(&push.Receipt{
|
||||
TenantID: 7,
|
||||
To: map[types.Uid]push.Recipient{
|
||||
uid: {Delivered: 1},
|
||||
},
|
||||
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
|
||||
}, config)
|
||||
if len(payloads) != 1 {
|
||||
t.Fatalf("unexpected payload count: %d", len(payloads))
|
||||
}
|
||||
if payloads[0].Message == nil || payloads[0].Message.Extras["silent"] != "true" {
|
||||
t.Fatalf("expected silent payload for delivered recipient: %#v", payloads[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
|
||||
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
|
||||
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
|
||||
|
||||
Reference in New Issue
Block a user