message

package
v1.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: Apache-2.0 Imports: 54 Imported by: 0

Documentation

Overview

Package message — POST /v1/sidebar/sync

Data-flow overview

  1. Validate request (tab ∈ {follow,recent}, device_uuid required).
  2. Call ctx.IMSyncUserConversation to get the raw conversation list from the IM core (timestamp, unread, last_msg_seq).
  3. 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())
  4. 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.
  5. Append standalone thread ext entries not already in the IM result.
  6. 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.
  7. 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

View Source
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 解析,直接占位。

View Source
const (
	// 消息已删除
	CMDMessageDeleted = "messageDeleted"
	// CMDMessageErase 消息擦除
	CMDMessageErase = "messageEerase"

	// DefaultRevokeTimeout 默认撤回超时时间(24小时)
	// 用户撤回自己的消息时,如果超过该时间限制则不允许撤回
	// 管理员/群主撤回他人消息不受此限制
	DefaultRevokeTimeout = 24 * 60 * 60 // 24 hours in seconds
)
View Source
const (
	ReminderTypeMentionMe      = 1 // 有人@我
	ReminderTypeApplyJoinGroup = 2 // 申请加群
)
View Source
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

func RegisterSidebarRoutes(r *wkhttp.WKHttp, ctx *config.Context)

RegisterSidebarRoutes mounts /v1/sidebar/sync onto the router.

func RewriteMention added in v1.4.0

func RewriteMention(payload map[string]interface{}) map[string]interface{}

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

func TruncatedPayload(raw []byte) map[string]interface{}

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 ChannelState struct {
	ChannelID   string `json:"channel_id"`
	ChannelType uint8  `json:"channel_type"`
	Calling     int    `json:"calling"` // 是否正在通话
}

type Conversation

type Conversation struct {
	log.Log
	// contains filtered or unexported fields
}

Conversation 最近会话

func NewConversation

func NewConversation(ctx *config.Context) *Conversation

New New

func (*Conversation) Route

func (co *Conversation) Route(r *wkhttp.WKHttp)

Route 路由配置

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB DB

func NewDB

func NewDB(ctx *config.Context) *DB

NewDB NewDB

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 Manager

type Manager struct {
	log.Log
	// contains filtered or unexported fields
}

Manager 消息管理

func NewManager

func NewManager(ctx *config.Context) *Manager

NewManager NewManager

func (*Manager) Route

func (m *Manager) Route(r *wkhttp.WKHttp)

Route 路由配置

type Message

type Message struct {
	log.Log
	// contains filtered or unexported fields
}

Message 消息相关API

func New

func New(ctx *config.Context) *Message

New New

func (*Message) Route

func (m *Message) Route(r *wkhttp.WKHttp)

Route 路由配置

func (*Message) Stop

func (m *Message) Stop()

Stop stops the background timer goroutine

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

type ProhibitWordModel struct {
	Content   string
	IsDeleted int
	Version   int64
	db.BaseModel
}

ProhibitWordModel 违禁词model

type ProhibitWordResp

type ProhibitWordResp struct {
	Id        int64  `json:"id"`
	Content   string `json:"content"`    // 违禁词
	IsDeleted int    `json:"is_deleted"` // 是否删除
	Version   int64  `json:"version"`    // 版本
	CreatedAt string `json:"created_at"` // 时间
}

type ReminderType

type ReminderType int

type Service

type Service struct {
	log.Log
	// contains filtered or unexported fields
}

func NewService

func NewService(ctx *config.Context) *Service

func (*Service) DeleteConversation

func (s *Service) DeleteConversation(uid string, channelID string, channelType uint8) error

func (*Service) GetChannelOffsetWithUID

func (s *Service) GetChannelOffsetWithUID(uid string, channelIDs []string) ([]*channelOffsetResp, error)

func (*Service) GetDeletedMessages

func (s *Service) GetDeletedMessages(messageIDs []string) ([]*messageExtraResp, error)

func (*Service) GetDeletedMessagesWithUID

func (s *Service) GetDeletedMessagesWithUID(uid string, messageIDs []string) ([]*messageUserExtraResp, error)

func (*Service) GetRevokedMessages

func (s *Service) GetRevokedMessages(messageIDs []string) ([]*messageExtraResp, error)
type Sidebar struct {
	log.Log
	// contains filtered or unexported fields
}

Sidebar handles POST /v1/sidebar/sync.

func NewSidebar

func NewSidebar(ctx *config.Context) *Sidebar

NewSidebar creates a Sidebar handler.

func (*Sidebar) Sync

func (sb *Sidebar) Sync(c *wkhttp.Context)

Sync handles POST /v1/sidebar/sync.

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL