Documentation
¶
Overview ¶
Package message — POST /v1/sidebar/sync
Data-flow overview ¶
- Validate request (tab ∈ {follow,recent}, device_uuid required).
- Call ctx.IMSyncUserConversation to get the raw conversation list from the IM core (timestamp, unread, last_msg_seq).
- Load ancillary data in parallel-ish batches: a. group_setting → category_id, category_sort (groupCategoryDB) b. user_conversation_ext → unfollowed groups, followed DMs, thread ext rows c. user_pinned_channel → pinned set (raw DB query via ctx.DB())
- Apply tab-specific filtering: follow – groups with category + not unfollowed; followed DMs; threads with ext row whose parent group is in the follow set. recent – per-channel-type activity window from system_settings (sidebar.recent_filter_{group,thread,person}_days); a window of 0 disables filtering for that type. Defaults reproduce the historical behaviour for the types the recent tab carries (groups/threads = 3-day window, DMs unfiltered). See buildRecentItems / loadRecentCutoffs.
- Append standalone thread ext entries not already in the IM result.
- Sort: follow – category_sort ASC → pinned DESC → follow_sort ASC → intra-category sort ASC → target_id ASC (Issue #41 — sidebar drag wins over category-management UI; pin overrides everything within a category; see sortFollowItems for the full rationale). recent – pinned DESC, timestamp DESC.
- Return SidebarSyncResp{Items, Version}.
Module dependencies: imports modules/conversation_ext (for ext rows) and modules/thread (for QueryByShortIDs to enrich thread items with last_message_at). Does NOT import modules/group or modules/user — group_setting / pinned data are read via ctx.DB() raw queries to avoid pulling in those packages.
Note: a follow-up review item (Important #4 — read pinned via a user-module helper) may eventually replace the raw user_pinned_channel query.
Module-side composer that wires (*Message).groupService.GetMembers + (*Message).robotService.ExistRobot into the pkg/mentionrewrite.ExpandAisToBotUIDs callback shape.
Mininglamp-OSS/octo-server#144: the leaf helper in pkg/mentionrewrite stays free of any modules/* dependency to preserve the leaf invariant called out in pkg/mentionrewrite/rewrite.go (the historical `robot → message → robot` import cycle). Each ingress chokepoint owns its own thin composer instead. This is the message-ingress version; modules/bot_api/mention_expand.go and the equivalent inline helper in modules/robot/api.go are its peers.
modules/message/mention_rewrite.go
Thin re-export of pkg/mentionrewrite.RewriteMention into the `message` package so the existing message-module dispatch sites (Message.sendMessage in api.go) can keep using an unqualified symbol — same pattern as sanitize_user_ingress.go wrapping pkg/obopayload.StripReservedKeys.
Why a separate package owns the helper ====================================== The three-state rewrite has to be invoked from THREE client-controlled message ingresses:
- modules/message/api.go (Message.sendMessage)
- modules/bot_api/send.go (BotAPI.sendMessage)
- modules/robot/api.go (Robot.sendMessage)
`modules/message` already imports `modules/robot` (revoke flow), so placing the helper in `modules/message` and importing it from `modules/robot` would create a `robot → message → robot` cycle. The helper therefore lives in `pkg/mentionrewrite` (leaf package, no module deps) and this file re-exports it for the message-package callers. See pkg/mentionrewrite/rewrite.go for the contract and the PR#70 audit doc (docs/2026-05-mention-all-chokepoint-audit.md §5) for the design rationale.
Spec: Mininglamp-OSS/octo-server#94, Multica YUJ-1343.
modules/message/sanitize_user_ingress.go
PR#82 R8 (Jerry-Xin 2026-05-19 review on head 244fe9fa) — strip any reserved `__obo_*` top-level key from user-supplied payloads before the message module persists or dispatches them.
Why this exists =============== The OBO persona-clone fan-out listener (modules/bot_api/obo_fanout.go) breaks its dispatch→listener loop by dropping inbound messages whose payload carries `__obo_processed__: true` (gate 3). The bot API (/v1/bot/sendMessage) already rejects any `__obo_*` top-level key on client input so a bot can't forge the marker. But the user-message ingress (/v1/message/send → m.sendMessage) was accepting and dispatching arbitrary user payload keys, so a normal user in a DM or group could send `{"__obo_processed__": true, ...}` and suppress fan-out to every grantor's persona-clone bot — silently breaking the persona-clone delivery guarantee.
Strip (vs. reject) at the user ingress ====================================== Bot clients are expected to know the reserved namespace and the bot API rejects with a 4xx so authors notice the mistake. Real users never knowingly send `__obo_*` — the keys come from a malicious client trying to exploit gate 3. A silent strip is the right UX (legitimate clients see no behavior change) and the simplest fix (no error surface to model in mobile / web clients).
Shared contract =============== Both ingresses share pkg/obopayload's prefix definition so the strip (here) and the reject (modules/bot_api/send.go) cannot drift apart. The fan-out listener's gate-3 check also pulls its marker key from pkg/obopayload so a future refactor renaming the marker can't leave one ingress filtering a stale name.
Index ¶
- Constants
- func CoerceTextPayloadContent(m map[string]interface{})
- func CollectGroupSpaceMap(conversations []*config.SyncUserConversationResp, extraGroupNos []string, ...) (map[string]string, bool)
- func EnsureSystemBotsPresentRaw(conversations []*config.SyncUserConversationResp) []*config.SyncUserConversationResp
- func FilterRawConversationsBySpace(conversations []*config.SyncUserConversationResp, filterSpaceID string, ...) []*config.SyncUserConversationResp
- func RegisterSidebarRoutes(r *wkhttp.WKHttp, ctx *config.Context)
- func RewriteMention(payload map[string]interface{}) map[string]interface{}
- func TruncatedPayload(raw []byte) map[string]interface{}
- type ChannelOffsetResp
- type ChannelState
- type Conversation
- type DB
- type GroupCategorySetting
- type IService
- type Manager
- type Message
- type MessageExtraResp
- type MessageUserExtraResp
- type MsgSyncResp
- type ProhibitWordModel
- type ProhibitWordResp
- type ReminderType
- type Service
- func (s *Service) DeleteConversation(uid string, channelID string, channelType uint8) error
- func (s *Service) GetChannelOffsetWithUID(uid string, channelIDs []string) ([]*channelOffsetResp, error)
- func (s *Service) GetDeletedMessages(messageIDs []string) ([]*messageExtraResp, error)
- func (s *Service) GetDeletedMessagesWithUID(uid string, messageIDs []string) ([]*messageUserExtraResp, error)
- func (s *Service) GetRevokedMessages(messageIDs []string) ([]*messageExtraResp, error)
- type Sidebar
- type SidebarItem
- type SpaceMembership
- type SyncUserConversationResp
- type SyncUserConversationRespWrap
Constants ¶
const ( LargePayloadThreshold = 10 * 1024 TextContentMaxRunes = 4000 )
LargePayloadThreshold caller 用此字节阈值决定是否调用 TruncatedPayload 走类型 感知的截断流程;不是真正的 payload 上限。历史背景:issue #1097 中 Bot 把嵌套 JSON 对象塞进 type=Text 的 content,前端按 string 解析时递归 JSON.parse 爆栈。 CoerceTextPayloadContent 已防御性把 Text content 规约为 string,根因消除后 只对 Text 按 rune 截(issue #1310),其它类型 content 携带结构化数据,原样 下发不截断。 hardParsePayloadLimit 更高一级的硬上限:超过则不再尝试 JSON 解析,直接占位。
const ( // 消息已删除 CMDMessageDeleted = "messageDeleted" // CMDMessageErase 消息擦除 CMDMessageErase = "messageEerase" // DefaultRevokeTimeout 默认撤回超时时间(24小时) // 用户撤回自己的消息时,如果超过该时间限制则不允许撤回 // 管理员/群主撤回他人消息不受此限制 DefaultRevokeTimeout = 24 * 60 * 60 // 24 hours in seconds )
const ( ReminderTypeMentionMe = 1 // 有人@我 ReminderTypeApplyJoinGroup = 2 // 申请加群 )
const CacheReadedCountPrefix = "readedCount:" // 消息已读数量
Variables ¶
This section is empty.
Functions ¶
func CoerceTextPayloadContent ¶
func CoerceTextPayloadContent(m map[string]interface{})
CoerceTextPayloadContent 对 type=Text 的消息把 content 字段强制规约为字符串。 正常客户端 content 本就是 string;兼容 bot 等误把嵌套 object 塞进 content 的场景(见 issue #1097),避免前端按 string 解析时崩溃。
func CollectGroupSpaceMap ¶ added in v1.4.1
func CollectGroupSpaceMap( conversations []*config.SyncUserConversationResp, extraGroupNos []string, groupService group.IService, ) (map[string]string, bool)
CollectGroupSpaceMap 是 (groupNo -> spaceID) 的批量推导器:扫描 conversation 列表里的群和子区父群,去重后调一次 group service,输出 客户端可见性判定所需的映射表。
extraGroupNos 用于补充 conversations 之外的 groupNo(典型场景:v2 sidebar 的 DB-only thread ext 行的父群可能不在 IM 返回里 —— GH octo-server#153 Round-2 Critical 2)。传 nil / 空切片表示纯走 conversations。
调用方:
- FilterRawConversationsBySpace / FilterConversationsBySpace:Space 过滤;
- api_sidebar.go Sidebar.Sync:把 group.SpaceID 回填到 SidebarItem.SpaceID (GH octo-server#153),让客户端 WebSocket 实时消息能正确路由到当前 Space tab,避免 conversation-level SpaceID 缺失导致 fail-open。
返回 (map, ok)。ok=false 表示底层 group service 调用失败 —— 调用方据此决定 fail-open 还是 fail-closed(v2 sidebar 必须 fail-closed,参见 decideConvKeepInSpace.failClosedOnUnknownGroupSpace 注释)。
func EnsureSystemBotsPresentRaw ¶
func EnsureSystemBotsPresentRaw(conversations []*config.SyncUserConversationResp) []*config.SyncUserConversationResp
EnsureSystemBotsPresentRaw 与 EnsureSystemBotsPresent 等价但操作 *config.SyncUserConversationResp(v2 sidebar 用)。系统 Bot 占位写法对齐 v1: ChannelID/ChannelType 设置好,其它字段保持零值。
func FilterRawConversationsBySpace ¶
func FilterRawConversationsBySpace( conversations []*config.SyncUserConversationResp, filterSpaceID string, loginUID string, ctx *config.Context, groupService group.IService, ) []*config.SyncUserConversationResp
FilterRawConversationsBySpace 是 FilterConversationsBySpace 在 v2 sidebar 上的 对应版本:v2 直接操作 IM 返回的 *config.SyncUserConversationResp(没有 enriched SpaceID/parsed Payload),所以单独写一个入口,但内部沿用 decideConvKeepInSpace 同一套规则,保证 v1/v2 Space 可见性一致。
背景 (PR #21 review by Jerry-Xin):原 v2 实现根本没做 Space 过滤,导致 X-Space-ID=B 的请求会拿到 Space A 的活跃 DM/Group/Thread。
差异点:
- SpaceID 通过 spacepkg.ParseChannelID 推导,与 v1 newSyncUserConversationResp 中的 line 1345 同一份逻辑。
- hasSpaceMsg:DM Recents 的 Payload 是 []byte(IM 原始 JSON),需要 lazily json.Unmarshal;解析失败的消息当作"无 space_id"处理(保守不放行)。
func RegisterSidebarRoutes ¶
RegisterSidebarRoutes mounts /v1/sidebar/sync onto the router.
func RewriteMention ¶ added in v1.4.0
RewriteMention normalizes the payload's `mention` sub-map per the three-state contract. Delegates to pkg/mentionrewrite so the shared behavior cannot drift between the three ingress packages. See pkg/mentionrewrite.RewriteMention for the full contract.
func TruncatedPayload ¶
TruncatedPayload 仅对 type=Text (=1) 按 rune 数截 content(issue #1310); 其它类型(媒体 Image/Voice/Video/File、富文本 RichText、群通知/客服等系统消息) content 携带结构化关键信息,按字节切片会破坏前端解析,全部原样下发。
仅在以下场景产生占位:
- 超过 1MB 硬上限(hardParsePayloadLimit)
- JSON 解析失败或得到空 map
Text 类型 content 已被 CoerceTextPayloadContent 规约为 string,无递归解码风险。
内部对 raw 反序列化产生的 map 进行就地修改,调用方拿到的返回值即同一个 map; 由于 raw []byte 来自 caller,map 是 TruncatedPayload 自己 Unmarshal 出来的, 不存在外部别名引用,因此就地修改是安全的。
导出供 search 等其他路径复用。
Types ¶
type ChannelOffsetResp ¶ added in v1.7.0
type ChannelOffsetResp = channelOffsetResp
ChannelOffsetResp is the equivalent alias for the per-(uid, channel) clear-history watermark response (GetChannelOffsetWithUID).
type ChannelState ¶
type Conversation ¶
Conversation 最近会话
type GroupCategorySetting ¶
type GroupCategorySetting struct {
GroupNo string
// CategoryID 是 "live" category id —— 已经过 group_category.status != 2
// 过滤。NULL 含义统一为"该群当前未分类",同时覆盖三种数据情况:
// (a) group_setting 行不存在 category_id(用户从未分配);
// (b) group_setting.category_id 指向已被软删的 category
// (TOCTOU race 残留 / category_cleanup 未跑);
// (c) group_setting.category_id 指向根本不存在的 category(异常残留)。
// 三种情况下游一律按"未分类"处理 —— sidebar 不展示在 follow tab、
// 物化路径不写 ext 行、API 不向客户端暴露 dangling id。这是 follow-tab
// 整套机制(issue #151)依赖的 schema-app 不变量:sidebar 显示 / 物化 /
// /v1/follow/sort 鉴权三处都按这个语义统一判定,不再有 stale-id 暴露。
//
// 实现上 SELECT 必须取 gc.category_id(JOIN 出来的字段),不是 gs.category_id
// (持久化的 stale 字段)—— 二者只在 JOIN 命中时相等,JOIN miss(status=2 或
// gc 不存在)时 gc.category_id 自然为 NULL,恰好对应 (b)(c) 两种 dangling 情况。
CategoryID *string
// CategorySort 是 group_setting.category_sort —— 类别内排序(v1 兼容字段,
// v1 API_conversation 直接回显该值,故保留语义不变)。
CategorySort int `db:"category_sort"`
// CategoryGroupSort 是 group_category.sort —— 类别之间的排序权重,
// 对应 swagger v2 sidebar 的 SidebarItem.category_sort 字段。
CategoryGroupSort int `db:"category_group_sort"`
}
GroupCategorySetting 群组分类设置(来自 group_setting JOIN group_category)。
PR #21 review (lml2468 blocker #3):swagger 承诺 v2 SidebarItem.category_sort 来自 group_category.sort,且 /category/sort 接口也只更新 group_category.sort 并 bump follow_version。如果 sidebar 只读 group_setting.category_sort(类别 内排序),用户重排序类别后 sidebar 完全不变,与 contract 不符。本结构两个 sort 字段一起读出:
- CategoryGroupSort → group_category.sort,category 之间的相对顺序 (也就是 SidebarItem.CategorySort 暴露给客户端的值);
- IntraCategorySort → group_setting.category_sort,同类别内组之间的顺序 (sidebar 排序时作为二级 key,不暴露给客户端,避免破坏现有 schema)。
type IService ¶
type IService interface {
// 查询消息拥有者uid的已删除消息
GetDeletedMessagesWithUID(uid string, messageIDs []string) ([]*messageUserExtraResp, error)
// 查询消息的撤回消息
GetRevokedMessages(messageIDs []string) ([]*messageExtraResp, error)
// 查询消息的删除消息
GetDeletedMessages(messageIDs []string) ([]*messageExtraResp, error)
// 查询用户清空channel消息标记
GetChannelOffsetWithUID(uid string, channelIDs []string) ([]*channelOffsetResp, error)
// 删除会话
DeleteConversation(uid string, channelID string, channelType uint8) error
}
type MessageExtraResp ¶ added in v1.7.0
type MessageExtraResp = messageExtraResp
MessageExtraResp is an exported alias for the message_extra response shape returned by GetRevokedMessages and GetDeletedMessages. The underlying messageExtraResp type already ships every field over JSON to clients today; the alias lets external packages — most notably modules/messages_search — build IService stubs / fakes against the real shape without re-exporting it.
type MessageUserExtraResp ¶ added in v1.7.0
type MessageUserExtraResp = messageUserExtraResp
MessageUserExtraResp is the equivalent alias for the per-user message state response (GetDeletedMessagesWithUID).
type MsgSyncResp ¶
type MsgSyncResp struct {
Header messageHeader `json:"header"` // 消息头部
Setting uint8 `json:"setting"` // 设置
MessageID int64 `json:"message_id"` // 服务端的消息ID(全局唯一)
MessageIDStr string `json:"message_idstr"` // 服务端的消息ID(全局唯一)字符串形式
MessageSeq uint32 `json:"message_seq"` // 消息序列号 (用户唯一,有序递增)
ClientMsgNo string `json:"client_msg_no"` // 客户端消息唯一编号
StreamNo string `json:"stream_no,omitempty"` // 流编号
FromUID string `json:"from_uid"` // 发送者UID
// 外部来源标识:仅在 /message/channel/sync 群聊路径填充,供前端在外部群渲染来源 Space 徽标。
// 详见 Mininglamp-OSS/octo-server#1188。
FromIsExternal int `json:"from_is_external"` // 发送者是否为外部成员 0.否 1.是
FromSourceSpaceName string `json:"from_source_space_name,omitempty"` // 发送者来源 Space 名称(为空则前端不渲染)
// 归属 Space(YUJ-63 / #1208):外部/内部语义由前端"相对当前查看 Space"判断。
// 外部成员:from_home_space_id = 发送者来源 space_id;
// 内部成员:from_home_space_id = 群自身 space_id。
// 后端 from_is_external / from_source_space_name 原语义保留。
FromHomeSpaceID string `json:"from_home_space_id,omitempty"` // 发送者归属 Space ID
FromHomeSpaceName string `json:"from_home_space_name,omitempty"` // 发送者归属 Space 名称
ToUID string `json:"to_uid,omitempty"` // 接受者uid
ChannelID string `json:"channel_id"` // 频道ID
ChannelType uint8 `json:"channel_type"` // 频道类型
Expire uint32 `json:"expire,omitempty"` // expire
Timestamp int32 `json:"timestamp"` // 服务器消息时间戳(10位,到秒)
Payload map[string]interface{} `json:"payload"` // 消息内容
SignalPayload string `json:"signal_payload"` // signal 加密后的payload base64编码,TODO: 这里为了兼容没加密的版本,所以新用SignalPayload字段
ReplyCount int `json:"reply_count,omitempty"` // 回复集合
ReplyCountSeq string `json:"reply_count_seq,omitempty"` // 回复数量seq
ReplySeq string `json:"reply_seq,omitempty"` // 回复seq
Reactions []*reactionSimpleResp `json:"reactions,omitempty"` // 回应数据
IsDeleted int `json:"is_deleted"` // 是否已删除
VoiceStatus int `json:"voice_status,omitempty"` // 语音状态 0.未读 1.已读
Streams []*streamItemResp `json:"streams,omitempty"` // 流数据
// ---------- 旧字段 这些字段都放到MessageExtra对象里了 ----------
Readed int `json:"readed"` // 是否已读(针对于自己)
Revoke int `json:"revoke,omitempty"` // 是否撤回
Revoker string `json:"revoker,omitempty"` // 消息撤回者
ReadedCount int `json:"readed_count,omitempty"` // 已读数量
UnreadCount int `json:"unread_count,omitempty"` // 未读数量
ExtraVersion int64 `json:"extra_version"` // 扩展数据版本号
// 消息扩展字段
MessageExtra *messageExtraResp `json:"message_extra,omitempty"` // 消息扩展
}
MgSyncResp 消息同步请求
type ProhibitWordModel ¶
ProhibitWordModel 违禁词model
type ProhibitWordResp ¶
type ReminderType ¶
type ReminderType int
type Service ¶
func NewService ¶
func (*Service) DeleteConversation ¶
func (*Service) GetChannelOffsetWithUID ¶
func (*Service) GetDeletedMessages ¶
func (*Service) GetDeletedMessagesWithUID ¶
func (*Service) GetRevokedMessages ¶
type Sidebar ¶
Sidebar handles POST /v1/sidebar/sync.
func NewSidebar ¶
NewSidebar creates a Sidebar handler.
type SidebarItem ¶
type SidebarItem struct {
TargetType int `json:"target_type"` // 1 DM / 2 group / 5 thread
TargetID string `json:"target_id"`
ChannelType uint8 `json:"channel_type"`
ChannelID string `json:"channel_id"`
// SpaceID 是该 sidebar 条目所属 Space 的 ID(GH octo-server#153)。
// - GROUP: group 表的 space_id;
// - COMMUNITY_TOPIC: 父群的 space_id;
// - PERSON: 留空 —— DM 的 Space 归属在消息级 payload.space_id 上,
// conversation 级别保持空避免误锁定。
// 客户端 WebSocket 收到群消息时拿这个字段决定渲染到哪个 Space tab,
// 与服务端 FilterRawConversationsBySpace 的可见性判定同口径。
SpaceID string `json:"space_id,omitempty"`
// MySourceSpaceID 是当前用户加入该群的"来源 Space"(外部成员场景)。
// - GROUP: externalGroupMap[channelID],即 group_external_member.source_space_id;
// - COMMUNITY_TOPIC: externalGroupMap[parentGroupNo](与父群保持同口径);
// - 其它(PERSON / 内部群成员): 留空。
// 与 v1 SyncUserConversationResp.MySourceSpaceID 字段口径一致
// (GH octo-server#153 Round-2 P1)。客户端在 source Space 下用 sidebar 时
// 需要这个字段才能识别"我以哪个 Space 身份加入了这个外部群"。
MySourceSpaceID string `json:"my_source_space_id,omitempty"`
Timestamp int64 `json:"timestamp"`
Unread int `json:"unread"`
IsPinned bool `json:"is_pinned"`
IsFollowed bool `json:"is_followed"`
CategoryID *string `json:"category_id,omitempty"`
// CategorySort 暴露给客户端的"类别之间排序权重",来源是 group_category.sort
// (PR #21 review by lml2468 blocker #3)。改类别顺序会 bump follow_version
// 并改变这里返回的值,与 /category/sort 接口及 swagger 一致。
CategorySort int `json:"category_sort,omitempty"`
FollowSort int `json:"follow_sort,omitempty"`
ParentChannelID string `json:"parent_channel_id,omitempty"` // thread only
// Status 仅对 thread 条目(target_type=5)有意义:1=active 2=archived
// 3=deleted,语义与 modules/thread/const.go 的 ThreadStatus* 枚举一致
// (GH octo-server#310)。客户端据此同步过滤已归档子区,无需等待 channelInfo。
// omitempty 让 DM / 群条目不带该字段,保持线上协议向后兼容。
Status int `json:"status,omitempty"`
// contains filtered or unexported fields
}
SidebarItem is one entry in the sidebar response.
type SpaceMembership ¶ added in v1.5.0
type SpaceMembership struct {
ChannelID string `json:"channel_id"` // 群 channel_id / group_no
SpaceID string `json:"space_id"` // 群表权威 Space ID
MySourceSpaceID string `json:"my_source_space_id,omitempty"` // 外部群成员的 source Space ID
}
SpaceMembership 是 /v1/conversation/sync 的 Space sideband 数据。 conversations[] 仍按增量返回;该字段每次返回用户已加入的全部群, 供客户端刷新 group/my-row 缓存,避免增量批次缺少某个群时 SpaceFilter 因缓存 miss 走 fail-open。
type SyncUserConversationResp ¶
type SyncUserConversationResp struct {
ChannelID string `json:"channel_id"` // 频道ID
ChannelType uint8 `json:"channel_type"` // 频道类型
SpaceID string `json:"space_id,omitempty"` // Space ID
// MySourceSpaceID 仅在 GROUP / COMMUNITY_TOPIC 频道且当前用户以外部成员
// 身份加入时非空。值取自 group_member.source_space_id,对应"我从哪个
// Space 加入了这个外部群"。客户端 WebSocket 收到该群实时消息时,可据此
// 把消息归属到当前 user 的 source Space —— 与服务端
// FilterConversationsBySpace 对外部群的可见性判定保持同口径,避免
// 三端 fail-open 把跨 Space 消息渲染到错误的 Space tab (GH#153)。
MySourceSpaceID string `json:"my_source_space_id,omitempty"` // 外部群成员的 source Space ID
Thread *threadMetaResp `json:"thread,omitempty"` // 子区元数据(仅 thread 频道)
CategoryID *string `json:"category_id,omitempty"` // 用户自定义分类ID(仅群组)
CategorySort int `json:"category_sort,omitempty"` // 分类内排序(仅群组)
Unread int `json:"unread,omitempty"` // 未读消息
SpaceUnread *int `json:"space_unread,omitempty"` // Space 维度未读(仅 Person 频道)
SpaceLastMessage *MsgSyncResp `json:"space_last_message,omitempty"` // Space 维度最后一条消息(仅 Person 频道)
Mute int `json:"mute,omitempty"` // 免打扰
Stick int `json:"stick,omitempty"` // 置顶
Timestamp int64 `json:"timestamp"` // 最后一次会话时间
LastMsgSeq int64 `json:"last_msg_seq"` // 最后一条消息seq
LastClientMsgNo string `json:"last_client_msg_no"` // 最后一条客户端消息编号
OffsetMsgSeq int64 `json:"offset_msg_seq"` // 偏移位的消息seq
Version int64 `json:"version,omitempty"` // 数据版本
Recents []*MsgSyncResp `json:"recents,omitempty"` // 最近N条消息
Extra *conversationExtraResp `json:"extra,omitempty"` // 扩展
BotType string `json:"bot_type,omitempty"` // Bot 类型("app_bot" 表示应用 Bot)
}
SyncUserConversationResp 最近会话离线返回
func EnsureSystemBotsPresent ¶
func EnsureSystemBotsPresent(conversations []*SyncUserConversationResp) []*SyncUserConversationResp
EnsureSystemBotsPresent 保证 Space-scoped sync 响应中一定包含系统 Bot (目前 botfather / u_10000 / fileHelper)的 conversation entry。
背景 (YUJ-216 / GH#1280):
- POST /v1/conversation/sync 带 X-Space-ID 时,IM 核心只会返回自 `version` 之后有新消息的 conversation。系统 Bot 若没有新消息就不会 出现在增量响应中,经 Space 过滤后客户端也拿不到。移动端没有 Web 那样的前端兜底,就会导致用户在某些 Space 下"消失"了 botfather 私聊。
- 修复策略:只要调用方开启了 Space 过滤,就在最终响应中显式补齐每一个 系统 Bot 的 entry。已经存在的 entry(有真实 Recents)保持不变;缺席的 以最小占位形式注入,兼容老客户端。
占位 entry 的字段原则:
- ChannelID / ChannelType:对齐 Person DM;
- SpaceID: 空串 —— 系统 Bot 不属于任何 Space;
- Recents / LastMsgSeq / Unread / Version / Timestamp 保持零值,避免 客户端误以为有新消息或错把占位写回 ack;
- 其他字段沿用结构体默认值,等价于"已知此频道、无新内容"。
不影响消息级 space_id 过滤:本函数只补 conversation-level 占位, 对 Recents 内 payload.space_id 字段不做任何修改。
func FilterConversationsBySpace ¶
func FilterConversationsBySpace( conversations []*SyncUserConversationResp, filterSpaceID string, loginUID string, ctx *config.Context, groupService group.IService, ) []*SyncUserConversationResp
FilterConversationsBySpace 对已获取的会话列表按 spaceID 过滤。 关键逻辑: - 群聊 space_id 不在 channel_id 前缀中,需查 group 表 - 系统 Bot (botfather, u_10000, fileHelper) 所有 Space 可见 - 普通 Bot 需查 space_member 表确认是否在目标 Space - 默认 Space(用户最早加入的)中显示裸 UID 旧会话 - DB 查询失败时 skipBotFilter=true,不过滤避免误删
type SyncUserConversationRespWrap ¶
type SyncUserConversationRespWrap struct {
UID string `json:"uid"` // 请求者uid
Conversations []*SyncUserConversationResp `json:"conversations"`
Users []*user.UserDetailResp `json:"users"` // 用户详情
Groups []*group.GroupResp `json:"groups"` // 群
ChannelStates []*ChannelState `json:"channel_status"` // 频道状态
SpaceMemberships []SpaceMembership `json:"space_memberships"` // 用户加入的全部群的 Space 归属
}
SyncUserConversationRespWrap SyncUserConversationRespWrap
Source Files
¶
- 1module.go
- api.go
- api_authorize.go
- api_channel_files.go
- api_conversation.go
- api_i18n.go
- api_manager.go
- api_message_get.go
- api_pinned.go
- api_reminders.go
- api_sidebar.go
- api_vo.go
- common.go
- const.go
- conversation_sync_cursor.go
- db.go
- db_channel_offset.go
- db_conversation_extra.go
- db_device_offset.go
- db_group_category.go
- db_manager.go
- db_member_readed.go
- db_message_extra.go
- db_message_reaction.go
- db_message_user_extra.go
- db_pinned.go
- db_reminders.go
- db_user_last_offset.go
- event.go
- mention_expand.go
- mention_rewrite.go
- sanitize_user_ingress.go
- service.go
- space_filter.go
- space_filter_default_catchall.go
- space_unread.go