Documentation
¶
Index ¶
- Variables
- func AnalyzeKeyword(ctx context.Context, a tokenAnalyzer, keyword string) (effectiveKeyword string, useMSM bool, err error)
- func ESClient(cfg SearchConfig) (*elastic.Client, error)
- func PrincipalKind(c *wkhttp.Context) string
- func PublishReconReport(r ReconReport) int64
- func SetPrincipal(c *wkhttp.Context, p Principal)
- type AroundResult
- type CursorList
- type Doc
- type FileHit
- type FilePayload
- type FileTypeEntry
- type GifPayload
- type GlobalChannelRef
- type GlobalFileFilters
- type GlobalSearchFilters
- type GroupAggConfig
- type GroupBucket
- type GroupsResult
- type Handler
- type ImagePayload
- type InnerMessage
- type MediaHit
- type MergeForwardMsg
- type MergeForwardPayload
- type MessageHit
- type OuterPreview
- type Pagination
- type Payload
- type Principal
- type RateLimitCfg
- type ReconReport
- type RichTextBlock
- type RichTextDetail
- type RichTextMention
- type RichTextMentionEntity
- type RichTextPayload
- type SearchAllHit
- type SearchAllReq
- type SearchAroundReq
- type SearchConfig
- type SearchFilesReq
- type SearchFilters
- type SearchGlobalFilesReq
- type SearchGlobalGroupsReq
- type SearchGlobalMessagesReq
- type SearchMediaReq
- type SearchMessagesReq
- type TextPayload
- type VideoPayload
- type VoicePayload
Constants ¶
This section is empty.
Variables ¶
var ( ErrPrincipalUnauthenticated = errPrincipalUnauthenticated ErrAppBotSearchDenied = errPrincipalAppBotDenied )
导出鉴权错误哨兵,供入口中间件按类型映射响应(App Bot 拒绝 vs 一般未鉴权)。
Functions ¶
func AnalyzeKeyword ¶
func AnalyzeKeyword(ctx context.Context, a tokenAnalyzer, keyword string) (effectiveKeyword string, useMSM bool, err error)
AnalyzeKeyword runs IK-smart segmentation + stopword filtering against `keyword` exactly once, returning the inputs needed by one or more buildKeywordClauseFromAnalyzed calls. This lets a handler that issues multiple multi_match clauses against the same keyword (e.g. _search_all with separate text/file Should branches) reuse one `_analyze` roundtrip.
Return contract per docs/messages-search/2026-06-23-multimatch-or-trap-fix.md §4.1:
- err != nil → _analyze failed. effectiveKeyword == keyword, useMSM == true. Feeding this pair to buildKeywordClauseFromAnalyzed yields the §4.4 degraded shape (raw keyword + cross_fields + MSM 75%). Caller may warn-log the error; the returned values are still safe to use.
- post-strip token slice empty (pure stopwords like "的" / "的了"): effectiveKeyword == keyword (the original, unsplit string), useMSM == false. Builder emits raw keyword + cross_fields, no MSM — preserves the "user literally searches for a function word" semantic.
- content tokens remain: effectiveKeyword == content tokens joined with spaces, useMSM == true. Builder emits the joined string + cross_fields + MSM 75%. Stopwords drop out of the MSM denominator so a 5-token query with one stopword becomes a 4-of-4 (75% ≈ 3) test on content words.
func ESClient ¶
func ESClient(cfg SearchConfig) (*elastic.Client, error)
ESClient returns the process-wide olivere/elastic v6 client connected to the configured OpenSearch read cluster. Sniffing is disabled because the cluster usually sits behind a service VIP that does not expose intra-cluster IPs to callers.
Self-healing: a previous sync.Once layout meant a single ping failure at boot would pin osErr forever; we now re-attempt construction on every call that finds the cached client nil. Successful builds are cached; failed builds are retried on the next request rather than poisoning the cache.
The cached client is keyed on nothing: the first successful build wins for the process lifetime. That is correct today because SearchConfig is read once from the environment at module init and never mutates; if config ever becomes reloadable, this cache must be keyed on the connection fields (addrs/credentials) or invalidated on change.
Concurrency: the build (which includes a ping with up to ~5s of network wait) runs *outside* the mutex so that concurrent requests during an OS outage fail fast in parallel instead of serialising behind one lock holder. Two goroutines may race to build; the loser's client is closed and the winner's is kept.
func PrincipalKind ¶ added in v1.11.0
PrincipalKind 返回本请求已解析主体的类型名(user / user_bot / obo / uk),未解析 返回空串。供入口接线的单测断言「命中哪条路由 + on_behalf_of → 对应 principal」。
func PublishReconReport ¶
func PublishReconReport(r ReconReport) int64
PublishReconReport updates the read-only drift gauges from a reconciliation report. Idempotent; safe to call on every report push. Returns the computed signed drift so callers (and tests) can assert on it.
func SetPrincipal ¶ added in v1.11.0
SetPrincipal 把已解析的搜索主体写入 context,handler 经 h.principal(c) 读取。 bot / uk / obo 路由显式调用;web 真人路由不调用,靠 Handler.principal 惰性默认。
Types ¶
type AroundResult ¶
type AroundResult struct {
Before []MessageHit `json:"before"`
Anchor MessageHit `json:"anchor"`
After []MessageHit `json:"after"`
HasMoreBefore bool `json:"has_more_before"`
HasMoreAfter bool `json:"has_more_after"`
}
AroundResult is the response envelope for POST /v1/messages/_search_around. The window is returned in chronological (time_asc) order: `before` (older than the anchor, oldest-first), then `anchor`, then `after` (newer than the anchor, oldest-first). has_more_before / has_more_after tell the client whether paging further in each direction would yield more visible messages.
type CursorList ¶
type CursorList struct {
Data any `json:"data"`
Pagination Pagination `json:"pagination"`
}
CursorList is the R1 success envelope for cursor-paginated list endpoints.
Matches the contract in docs/messages-search/api-spec-v2-server-to-frontend.html (v4.2): { "data": [...], "pagination": { "has_more": bool, "next_cursor": str } }.
TODO: switch to a generic envelope.CursorList[T] once octo-lib publishes one.
type Doc ¶
type Doc struct {
MessageID int64 `json:"messageId"`
MessageSeq uint64 `json:"messageSeq"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
ChannelID string `json:"channelId"`
ChannelType uint32 `json:"channelType"`
Timestamp int64 `json:"timestamp"`
Payload *Payload `json:"payload,omitempty"`
Revoked bool `json:"revoked,omitempty"`
// SpaceID mirrors the OS doc's `spaceId` keyword introduced in v1.9 to
// scope DM (p2p) search by Space membership. The indexer derives this
// from `payload.space_id`; older documents without the field are
// fail-closed by the term filter in applySpaceIDScope (no match → no
// hit) rather than implicitly visible.
SpaceID string `json:"spaceId,omitempty"`
// ParentMessageID and Virtual mark rich-text-derived sub-documents that
// the indexer emits per embedded image/file inside a payload.type=14
// rich-text message (Part B virtual-docs contract, see
// docs/messages-search/richtext-virtual-docs-octo-server-dev.md §1).
//
// Both fields are reader-internal — they never reach the JSON response.
// `Virtual=true` drives the `must_not(virtual=true)` filter on the four
// text-search builders so derivative children don't masquerade as
// independent messages on _search / _search_all / _search_around.
// `ParentMessageID` is the visibility key used by filterVisible: revoke /
// delete / channel-offset / visibles state is owned by the parent rich-text
// row in MySQL and the child has no row of its own.
//
// *int64 distinguishes "field absent" (legacy / non-virtual docs) from a
// zero parent id. Per indexer contract `Virtual=true` ⇒ `ParentMessageID`
// non-nil and equal to the parent's messageId. Plain docs (Virtual=false)
// leave ParentMessageID nil and keep the existing behaviour.
ParentMessageID *int64 `json:"parentMessageId,omitempty"`
Virtual bool `json:"virtual,omitempty"`
// SubSeq is the sort-tiebreaker for virtual sub-documents derived from
// rich-text parents (Part B). Per indexer contract:
// - plain message docs and rich-text parent docs (Virtual=false): SubSeq=0
// - virtual sub-documents (Virtual=true): SubSeq>=1
// Together with (timestamp, messageId) this guarantees a globally unique
// sort tuple so OpenSearch search_after never silently skips siblings
// that share (timestamp, messageId) with their parent. Storage docs that
// pre-date the field deserialize to 0, which matches the plain/parent
// convention — safe for the read/deserialize path before the indexer field
// exists. NOTE: sorting on subSeq is NOT safe by itself — applySort must
// pass UnmappedType+Missing(0) so a reader-first deploy doesn't 400 on the
// missing mapping (see dsl.go::applySort).
SubSeq int `json:"subSeq,omitempty"`
// Visibles is the per-message allowlist a sender may attach to a group
// message so only the listed UIDs see it (mirrors the read-path gate
// in modules/message/api.go::MsgSyncResp.from at the visibles-array
// branch). When non-empty and the caller's UID is absent the search
// post-filter must drop the hit. Schema is reserved here ahead of the
// indexer write — see CONSTRAINTS-2026-06-12 for the transient
// fail-open while the field is unwritten.
Visibles []string `json:"visibles,omitempty"`
// PayloadRaw carries the indexer-preserved original message payload blob
// (wukongim-message-indexer writes the full source payload under
// `_source.payloadRaw` with mapping `enabled:false`, see indexer
// transform/doc.go). It is only consumed by the typed rich-text (type=14)
// projection in buildRichTextDetail — the legacy structured `payload.*`
// fields above stay authoritative for everything else. Absent on docs
// indexed before the indexer started writing the field; downstream
// projectors must fail-soft (nil RichText, snippet fallback) in that case.
PayloadRaw json.RawMessage `json:"payloadRaw,omitempty"`
}
Doc mirrors the OpenSearch `_source` shape produced by wukongim-message-indexer (see indexer-os-changes.md §3.2). We only deserialise structured `payload.*` subobjects — `payloadRaw` is `enabled:false` in the mapping and would force per-doc JSON parsing on the hot path with no upside.
type FileHit ¶
type FileHit struct {
MessageID string `json:"message_id"`
MessageSeq int64 `json:"message_seq"`
FileName string `json:"file_name"`
FileSizeBytes int64 `json:"file_size_bytes,omitempty"`
FileExt string `json:"file_ext,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
PreviewURL *string `json:"preview_url"`
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name,omitempty"`
SenderAvatarURL string `json:"sender_avatar_url,omitempty"`
SentAt string `json:"sent_at"`
ChannelID string `json:"channel_id,omitempty"`
ChannelType uint8 `json:"channel_type,omitempty"`
}
FileHit is the response shape per A doc §2.3.
preview_url is always nil this release: business payload doesn't supply a preview link and indexer doesn't carry one in the document. A doc v4.2 §2.3 permits the field to be null when previewing isn't available.
channel_id / channel_type are omitempty because single-channel callers (_search_files) historically didn't return them (the request channel is implicit). Global callers (_search_global_files, _search_global_messages via SearchAllHit.file) MUST populate both so the frontend has a route to jump into the source room; for DM the channel_id is the peer uid, not the OS fakeChannelID (see peerFromFakeChannelID in channel.go).
type FilePayload ¶
type FileTypeEntry ¶ added in v1.9.0
type FileTypeEntry struct {
Key string `json:"key"`
Label string `json:"label"`
Exts []string `json:"exts"`
}
FileTypeEntry is one row of the enum returned by GET /v1/messages/_search_file_types (§7.5).
type GifPayload ¶
type GifPayload struct {
URL string `json:"url,omitempty"`
}
type GlobalChannelRef ¶ added in v1.9.0
type GlobalChannelRef struct {
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
}
GlobalChannelRef is the request shape for a filters.channel_ids entry on the global endpoints. Kept as an exported alias so both request payloads (SearchGlobalMessagesReq / SearchGlobalFilesReq) share one type without pulling in each other's package.
type GlobalFileFilters ¶ added in v1.9.0
type GlobalFileFilters struct {
SenderIDs []string `json:"sender_ids,omitempty"`
// MemberUID / MemberUIDs: see GlobalSearchFilters. Same wire contract on
// the file endpoint.
MemberUID string `json:"member_uid,omitempty"`
MemberUIDs []string `json:"member_uids,omitempty"`
ChannelIDs []GlobalChannelRef `json:"channel_ids,omitempty"`
ChannelTypes []uint8 `json:"channel_types,omitempty"`
FileExts []string `json:"file_exts,omitempty"`
FileSizeMin int64 `json:"file_size_min,omitempty"`
FileSizeMax int64 `json:"file_size_max,omitempty"`
SentAtFrom string `json:"sent_at_from,omitempty"`
SentAtTo string `json:"sent_at_to,omitempty"`
}
GlobalFileFilters is the file-endpoint-only filter block: the shared base (via GlobalSearchFilters) plus file_exts / file_size_min / file_size_max.
type GlobalSearchFilters ¶ added in v1.9.0
type GlobalSearchFilters struct {
SenderIDs []string `json:"sender_ids,omitempty"`
// MemberUID is the legacy single-select "包含成员" field. Superseded by
// MemberUIDs (multi-select, bug 5) but kept on the wire for backwards
// compatibility with older clients and to survive a rolling deploy window
// where a stale frontend can still coexist with a fresh backend. When both
// fields arrive, MemberUIDs wins; if only MemberUID is set the handler
// folds it into the plural path via normalizeMemberUIDs.
MemberUID string `json:"member_uid,omitempty"`
MemberUIDs []string `json:"member_uids,omitempty"`
ChannelIDs []GlobalChannelRef `json:"channel_ids,omitempty"`
ChannelTypes []uint8 `json:"channel_types,omitempty"`
ContentTypes []int `json:"content_types,omitempty"`
SentAtFrom string `json:"sent_at_from,omitempty"`
SentAtTo string `json:"sent_at_to,omitempty"`
}
GlobalSearchFilters is the shared filter block for the two global endpoints. A superset of SearchFilters (sender_ids / sent_at_from / sent_at_to) plus the four global-only dimensions: member_uid, channel_ids, channel_types, content_types.
content_types is meaningful only for _search_global_messages (the mixed message+file stream). _search_global_files hardlocks payload.type=8 so it ignores content_types entirely; file_exts / file_size_min / file_size_max live in SearchGlobalFilesFilters instead.
type GroupAggConfig ¶ added in v1.11.0
type GroupAggConfig struct {
MaxGroups int
PerGroupMax int
PresenceProbe int
PreviewBudget int
K2 int
}
GroupAggConfig carries the L1 aggregation (_search_global_groups) tunables.
- MaxGroups: terms(channelId) bucket cap. Beyond it the response sets pagination.has_more=true and returns the most-active MaxGroups buckets.
- PerGroupMax: single-group preview cap (clamp N to this).
- PresenceProbe: over-fetch margin. top_hits size T = PerGroupMax + PresenceProbe so backend B's presence calibration has sample headroom.
- PreviewBudget / K2: reserved for backend B (per-frequency preview allocation / presence deep-probe cap); read here so the config surface is complete and the two PRs don't re-open config.go.
func (GroupAggConfig) TopHitsSize ¶ added in v1.11.0
func (g GroupAggConfig) TopHitsSize() int
TopHitsSize is the over-fetch size T for the per-bucket top_hits preview aggregation: PerGroupMax preview slots + PresenceProbe calibration headroom.
type GroupBucket ¶ added in v1.11.0
type GroupBucket struct {
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
ParentGroupNo string `json:"parent_group_no,omitempty"`
GroupName string `json:"group_name"`
ThreadID string `json:"thread_id,omitempty"`
ThreadName string `json:"thread_name,omitempty"`
// MatchCount is the pre-visibility OS doc_count (may exceed the caller's
// visible count — see the type doc). Left as-is by design §6 / owner
// option A; MatchCountApprox flags it.
MatchCount int64 `json:"match_count"`
MatchCountApprox bool `json:"match_count_approx"`
LatestAt string `json:"latest_at"`
Preview []MessageHit `json:"preview"`
}
GroupBucket is one aggregated channel/thread/DM bucket. DM buckets carry the reversed peer uid in ChannelID (channel_type=1) with no parent_group_no / thread fields; group buckets set parent_group_no to their own channel_id; thread buckets (channel_type=5) carry parent_group_no + thread_id + thread_name.
match_count is the OS doc_count — a PRE-visibility approximate metric, so match_count_approx is always true. Per design §6 + owner decision (option A) it is deliberately left as the pre-filter count and NOT post-calibrated: it therefore INCLUDES hits hidden from the caller by the five visibility gates (admin/mutual delete, self-delete, cleared history, visibles) and so may read HIGHER than the number of messages the caller can actually open. This is an accepted, non-sensitive approximation (it exposes only a possibly-inflated count, never a hidden message's content/sender/time); match_count_approx=true is the wire signal of exactly this. latest_at, by contrast, IS recomputed from the calibrated visible hit (see calibratedBucket.latestVisibleTS) — never the OS pre-filter max(timestamp) — because a hidden newest match there would leak its time and bias bucket order (a real leak, now fixed).
type GroupsResult ¶ added in v1.11.0
type GroupsResult struct {
Sequence int64 `json:"sequence"`
QueryID string `json:"query_id"`
TotalGroups int64 `json:"total_groups"`
TotalGroupsApprox bool `json:"total_groups_approx"`
Groups []GroupBucket `json:"groups"`
}
GroupsResult is the `data` object of the L1 response. The outer envelope is the shared {data, pagination} shell (pagination.has_more = 命中群数 > maxGroups; next_cursor is always "").
total_groups is a cardinality HLL estimate over the PRE-visibility candidate set, so total_groups_approx is always true. Per the aggregation-first design §6 ("count 近似,精确计数成本无界不做") this is an accepted, non-sensitive pre-filter approximation — deliberately NOT post-calibrated against filterVisible (owner decision, option A): recomputing it could only count the visible docs inside the bounded sampling window, which is itself an approximation at unbounded cost. Contrast latest_at / bucket order, which WERE real leaks of hidden messages' recency and are now recomputed from the visible hit (see GroupBucket.LatestAt).
type Handler ¶
Handler wires the four /v1/messages/_search* endpoints. New is invoked from 1module.go via the standard register.AddModule entry point.
func New ¶
New constructs the Handler. ES client setup is deferred to first request so that a missing OS dependency does not prevent the rest of the server from booting (the request layer will surface UPSTREAM_UNAVAILABLE instead).
func Shared ¶ added in v1.11.0
Shared 返回进程内唯一的搜索 Handler。web(本模块 SetupAPI)、bot(bot_api)、 uk(botfather)三条路由树共用它,从而共享限流桶 / sender 缓存 / 后端模式解析—— 单个 bot 的搜索配额是一致的一份,而非按入口分裂。首个调用方(模块装载顺序决定) 用其 *config.Context 构造;后续调用返回同一实例(各模块拿到的 ctx 是同一个)。
func (*Handler) MountSubtree ¶ added in v1.11.0
MountSubtree 在 r 上以 prefix 为前缀挂载全部 _search* 端点,前置 front(调用方的 鉴权 + principal 解析中间件),再接搜索专属链 searchRateLimiter → auditMiddleware → backendGate,最后经 routeMounters 注册与 /v1/messages 完全一致的端点集合。
front 约定:必须在放行前对成功请求调用 SetPrincipal(bot=user_bot/obo、uk=uk), 因为 searchRateLimiter(取限流键)与 auditMiddleware(取审计主体)都依赖 principal。 front 内部若鉴权/授权失败,应自行响应并 Abort,链在此中止。
backendGate 位于链尾(鉴权 + 限流 + 审计之后),与 web 侧一致:即便后端为 disabled/zinc,拒绝也照样计量与审计,杜绝无度量的「搜索关闭」枚举旁路(V9)。
func (*Handler) Route ¶
Route mounts the four endpoints under /v1/messages with the standard auth/space/uid-limit chain plus the per-user search rate limiter and the audit middleware (PRM-02). Individual handlers are wired in their own search_*.go files via the registerHandler helper.
The backendGate middleware runs INSIDE the chain (after auth + rate limit + audit) so a disabled / zinc deployment still meters and audits the refusal — an attacker cannot enumerate channels through an unmetered "search off" reply (V9). When the backend is not `es` every _search* endpoint returns SEARCH_DISABLED uniformly.
func (*Handler) SetOBOChecker ¶ added in v1.11.0
func (h *Handler) SetOBOChecker(c oboChecker)
SetOBOChecker 注入 as-user(OBO) scope 门实现(#B 在装配 bot 搜索路由时调用)。
type ImagePayload ¶
type InnerMessage ¶
type InnerMessage struct {
MessageID string `json:"message_id"`
Type int `json:"type"`
SearchText string `json:"search_text,omitempty"`
SenderID string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name,omitempty"`
SentAt string `json:"sent_at,omitempty"`
}
InnerMessage is the per-child shape surfaced under MessageHit.inner_messages for forward (type=11) hits. SenderName is filled in after senderJoin runs; SenderID / SentAt are omitted when the indexer hasn't yet populated the underlying msgs[].from / msgs[].timestamp fields.
type MediaHit ¶
type MediaHit struct {
MessageID string `json:"message_id"`
MessageSeq int64 `json:"message_seq"`
MediaKind string `json:"media_kind"`
ThumbURL string `json:"thumb_url,omitempty"`
VideoURL string `json:"video_url,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name,omitempty"`
SentAt string `json:"sent_at"`
MonthBucket string `json:"month_bucket"`
}
MediaHit is the response shape per A doc §2.2.
duration_ms is omitted on image hits via `omitempty`; thumb_url is required per spec but we still tag it omitempty so historical rows missing the field don't blow up the wire shape. Spec §2.2 lists no channel_id (the request channel is implicit) and no sender_avatar_url (waterfall card layout has no avatar surface) — both are intentionally absent.
type MergeForwardMsg ¶
type MergeForwardMsg struct {
MessageID int64 `json:"messageId"`
Type int `json:"type"`
SearchText string `json:"searchText,omitempty"`
From string `json:"from,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
MergeForwardMsg is the per-child projection from `payload.mergeForward.msgs[]`. `from` and `timestamp` are forward-compat fields the indexer will start writing in a follow-up release; both are omitempty so older OS docs (which only carry messageId/type/searchText) deserialise to a zero value and the API can degrade `sender_id` / `sent_at` to omitted on the wire.
type MergeForwardPayload ¶
type MergeForwardPayload struct {
ChildCount int `json:"childCount,omitempty"`
Msgs []MergeForwardMsg `json:"msgs,omitempty"`
}
type MessageHit ¶
type MessageHit struct {
MessageID string `json:"message_id"`
MessageSeq int64 `json:"message_seq"`
MessageKind string `json:"message_kind"`
Snippet string `json:"snippet,omitempty"`
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name,omitempty"`
SenderAvatarURL string `json:"sender_avatar_url,omitempty"`
SentAt string `json:"sent_at"`
OuterPreview *OuterPreview `json:"outer_preview,omitempty"`
InnerMessages []InnerMessage `json:"inner_messages,omitempty"`
ChannelID string `json:"channel_id"`
// ChannelType is echoed only for the global endpoints (_search_global_*),
// which return hits from many rooms in a single response — omitempty on the
// single-channel surfaces keeps the wire shape byte-identical for the
// legacy _search / _search_all / _search_around callers that pass 0.
ChannelType uint8 `json:"channel_type,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
VideoURL string `json:"video_url,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
// RichText is the typed projection of a payload.type=14 rich-text message,
// emitted only when the hit is rich-text AND the indexer preserved
// `_source.payloadRaw`. Older docs (pre-payloadRaw indexer) leave it nil,
// in which case the client renders the existing snippet/text fallback.
// Non-richtext hits never carry this field.
RichText *RichTextDetail `json:"rich_text,omitempty"`
}
MessageHit is the response shape per A doc §2.1.
MessageKind / ThumbURL / Width / Height / DurationMs are populated only for image (payload.type=2) and video (payload.type=5) hits surfaced by /_search_all browse mode (or by /_search_around, which has no type whitelist). They mirror MediaHit's renderable fields so the client can render a media card directly from a MessageHit without a separate projection. All are omitempty so plain text / forward hits keep their wire shape unchanged.
type OuterPreview ¶
type OuterPreview struct {
ChildCount int `json:"child_count"`
}
OuterPreview is the optional summary card returned for forward messages.
type Pagination ¶
Pagination carries the server's opinion on whether more results exist plus the opaque cursor for fetching the next page. NextCursor is always emitted (even as "") because spec v4.2 §1.4 requires the field on the wire so clients can do a literal `pagination.next_cursor === ""` check.
type Payload ¶
type Payload struct {
Type *int `json:"type,omitempty"`
Text *TextPayload `json:"text,omitempty"`
Image *ImagePayload `json:"image,omitempty"`
Gif *GifPayload `json:"gif,omitempty"`
Voice *VoicePayload `json:"voice,omitempty"`
Video *VideoPayload `json:"video,omitempty"`
File *FilePayload `json:"file,omitempty"`
MergeForward *MergeForwardPayload `json:"mergeForward,omitempty"`
RichText *RichTextPayload `json:"richText,omitempty"`
}
Payload is the structured projection of the message payload. Each typed subobject is allocated only when the indexer recognised its content type, so a non-nil pointer is the strongest "this message is of type X" signal.
type Principal ¶ added in v1.11.0
type Principal interface {
// Kind 凭证类型。
Kind() principalKind
// SubjectUID 是「可达频道集」所依据的身份:真人登录 uid、bot 自身(as-bot)、
// grantor(obo)或 key 拥有者(uk)。
SubjectUID() string
// SpaceID 请求所属 Space;bot 路由不挂 SpaceMiddleware 故为空,uk 取 api_key_space_id。
SpaceID() string
// RequiresSpaceScope 报告「空 Space 是否必须 fail-close」(决策十 / YUJ-57)。
// 真人语义且实际驻留在某个 Space 的主体(user / uk)依赖 spaceId 段收窄跨 Space
// DM 泄露,空 Space 触发必填门 fail-close;而**天然无 Space** 的主体(user_bot /
// obo)根本不属于任何 Space,其可读频道集由 per-principal 谓词
//(IsFriend / grantor allowlist)枚举,DM 可见性无需 spaceId 段兜底,故空 Space
// 合法、**不**被必填门提前挡下(否则无 space 的 bot 永远搜不到任何结果)。
RequiresSpaceScope() bool
// BlacklistPolicy 报告是否对该主体套用真人双向黑名单门。
BlacklistPolicy() blacklistPolicy
// RateLimitKey 限流令牌桶键:as-bot / obo 都按 botUID 计(防单 bot 打爆),
// uk 按 key UID,user 按自身 uid。
RateLimitKey() string
// AuditBotUID / AuditGrantorUID 供审计追溯(as-user 同时记 botUID + grantorUID);
// 不适用时返回 ""。
AuditBotUID() string
AuditGrantorUID() string
}
Principal 是搜索主体的策略接口。handler 只依赖此接口取身份 / Space / 策略; 归一化可读谓词(canReadChannel / enumerateReadableChannels)以 Handler 方法承载, 按 Kind 分派(见 predicate.go)。
func AuthenticateUK ¶ added in v1.11.0
AuthenticateUK 解析 uk 主体:subjectUID = keyModel.UID、spaceID = api_key_space_id (要求上游 authUserAPIKey() 已落 api_key_uid / api_key_space_id)。
func AuthenticateUserBot ¶ added in v1.11.0
AuthenticateUserBot 解析 as-bot 主体:subjectUID = botUID(要求上游 authBot() 已在 context 落 robot_id)。App Bot 返回 ErrAppBotSearchDenied(一期不支持,决策五, 调用方须显式拒绝、不得静默放行)。
func NewOBOPrincipal ¶ added in v1.11.0
NewOBOPrincipal 组装 as-user(OBO) 主体:subjectUID = grantorUID(走真人分支)、 限流/审计按 botUID、审计并记 grantorUID。grant + scope + grantorCanReadChannel 的 实时权限校验(TOCTOU 与发消息侧一致)由调用方在此之前完成(#F 复用 bot_api/obo_check.go);本构造器只从两个已校验入参组装载体。
type RateLimitCfg ¶
RateLimitCfg drives the per-loginUID 5 QPS / 20 burst limiter.
type ReconReport ¶
type ReconReport struct {
ESDocCount int64 `json:"es_doc_count"`
MySQLRowCount int64 `json:"mysql_row_count"`
SampleMismatch int64 `json:"sample_mismatch"`
RanAtUnixSeconds int64 `json:"ran_at_unix_seconds"`
}
ReconReport is the structured drift summary the indexer-side reconciliation job pushes to octo-server's read-only ingestion point. octo-server NEVER computes drift itself (that needs the OS write side); it only stores the last report so the gauges above can be scraped.
func (ReconReport) DocDrift ¶
func (r ReconReport) DocDrift() int64
DocDrift is the signed ES-minus-MySQL count.
type RichTextBlock ¶
type RichTextBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Size int64 `json:"size,omitempty"`
Name string `json:"name,omitempty"`
Extension string `json:"extension,omitempty"`
Mime string `json:"mime,omitempty"`
Caption string `json:"caption,omitempty"`
}
RichTextBlock mirrors a single block in a payload.type=14 rich-text message's `content[]`. The JSON tags are aligned with octo-web's RichTextContent.ts / octo-lib common/richtext.go so the search projection returns the same shape the existing channel/sync renderer already consumes. Image/file-specific fields (extension/mime/caption) follow the octo-web schema which extends octo-lib's MVP shape; unknown / unused fields stay omitempty so unrelated block types serialise to the minimal `{type,text}` or `{type,url,...}` form.
type RichTextDetail ¶
type RichTextDetail struct {
Content []RichTextBlock `json:"content"`
Plain string `json:"plain,omitempty"`
Mention *RichTextMention `json:"mention,omitempty"`
}
RichTextDetail is the typed projection of a rich-text payload exposed under MessageHit.rich_text. Shape matches the channel/sync payload contract so the client can render search hits with the same components as the timeline.
type RichTextMention ¶
type RichTextMention struct {
Entities []RichTextMentionEntity `json:"entities,omitempty"`
All int `json:"all,omitempty"`
Humans int `json:"humans,omitempty"`
Ais int `json:"ais,omitempty"`
}
RichTextMention is the payload.mention object: per-user entities plus the three @-all tri-state flags (all / humans / ais). Whole object is optional and missing when the message has no @ mentions.
type RichTextMentionEntity ¶
type RichTextMentionEntity struct {
UID string `json:"uid"`
Offset int `json:"offset"`
Length int `json:"length"`
}
RichTextMentionEntity is one @-mention anchor inside a text block, used by the renderer to highlight the matching `[offset, offset+length)` rune range of the surrounding text.
type RichTextPayload ¶
type RichTextPayload struct {
SearchText string `json:"searchText,omitempty"`
}
RichTextPayload mirrors the indexer's richText projection. Only `searchText` is materialised here — the full block tree (text/image/file blocks) lives in payloadRaw on the OS doc and is not read by the search path. searchText is the indexer's plain-text join of all rich-text blocks plus embedded image/file name+caption, written under analyzer ik_max_word. See Part A doc.
type SearchAllHit ¶
type SearchAllHit struct {
ResultType string `json:"result_type"`
SortedAt string `json:"sorted_at"`
Message *MessageHit `json:"message,omitempty"`
File *FileHit `json:"file,omitempty"`
}
SearchAllHit is the response shape per A doc §2.4. Either Message or File is populated based on ResultType; SortedAt is a flat copy of the inner sent_at to make pagination deterministic across mixed result types.
type SearchAllReq ¶
type SearchAllReq = SearchMessagesReq
SearchAllReq is the request body for POST /v1/messages/_search_all. Same shape as _search; keyword optional and gated identically.
type SearchAroundReq ¶
type SearchAroundReq struct {
ChannelType uint8 `json:"channel_type"`
ChannelID string `json:"channel_id"`
AnchorMessageID string `json:"anchor_message_id"`
Filters SearchFilters `json:"filters,omitempty"`
PageSize int `json:"page_size,omitempty"`
}
SearchAroundReq is the request body for POST /v1/messages/_search_around. It locates a known anchor message and returns the chronological window around it (older + anchor + newer). There is no keyword, sort, or cursor: the window is anchored on a specific message_id and always returned in time_asc order so the client can render a contiguous conversation slice.
type SearchConfig ¶
type SearchConfig struct {
OSAddrs []string
OSUsername string
OSPassword string
OSReadAlias string
// OSInsecureHTTP permits sending basic-auth credentials to non-loopback
// http:// addresses. Off by default: credentials over cleartext HTTP are
// rejected at client build time unless this is explicitly set.
OSInsecureHTTP bool
// OSInsecureSkipVerify disables TLS certificate verification when talking
// to the OpenSearch read cluster. Required for dev / test environments
// that use self-signed or internal-CA-signed certificates that the
// pod's system trust store does not include. MUST stay false in
// production deployments where the OS cluster has properly trusted
// certificates. Off by default; opt in via
// OCTO_SEARCH_OS_INSECURE_SKIP_VERIFY=true.
OSInsecureSkipVerify bool
Timeout time.Duration
RateLimit RateLimitCfg
CursorHMAC string
// UserAvatarBaseURL, when non-empty, is prepended to the relative
// `users/{uid}/avatar` template so the response carries an absolute
// URL (spec v4.2 §2.1 / R8). When empty we keep the relative path and
// rely on the frontend joining it with its own API base — see
// docs/messages-search/FIX-2026-06-12.md for the SRE rollout note.
UserAvatarBaseURL string
// RequireSpaceID gates the p2p (DM) Space-scoping filter.
//
// - true (default): every p2p search MUST carry a non-empty
// X-Space-ID / `space_id` (resolved via SpaceMiddleware) and the
// OS DSL filters by `spaceId`. Requests without a Space resolve
// to NOT_FOUND (resource=channel) — fail-closed.
// - false: skip the spaceId term filter entirely. Operational
// escape hatch used while the v1.9 indexer / OS mapping is being
// rolled out and the corpus has not been backfilled with the
// `spaceId` field. Logged at WARN on every p2p request so the
// deviation cannot stay enabled silently.
RequireSpaceID bool
// StopwordStripEnabled gates the conditional stopword strip + `_analyze`
// pre-processing introduced by
// docs/messages-search/2026-06-23-multimatch-or-trap-fix.md.
//
// - true (default): the search_messages / search_files / search_all
// keyword paths call OS `_analyze?analyzer=ik_smart` and drop
// stopwords (defaultStopwords) before constructing multi_match.
// - false: ops-only kill switch. Skip `_analyze` entirely and fall
// back to the §4.4 degraded shape — raw keyword + cross_fields +
// MSM 75% — on every keyword request, including the previously
// branchless pure-stopword path. Use when the strip behavior is
// misclassifying queries in production and a one-line config flip
// is preferable to a redeploy.
StopwordStripEnabled bool
// Groups holds the L1 group-aggregation (_search_global_groups) knobs.
// See aggregation-first design doc §9; every value is config-driven, never
// hardcoded, and never controlled by the request.
Groups GroupAggConfig
}
SearchConfig holds runtime configuration for the OpenSearch-backed /v1/messages/_search* endpoints.
TODO: lift this struct to octo-lib/config.SearchConfig once the next octo-lib release window opens. For now we read directly from environment variables to avoid coupling this feature work to an octo-lib bump.
type SearchFilesReq ¶
type SearchFilesReq struct {
ChannelType uint8 `json:"channel_type"`
ChannelID string `json:"channel_id"`
Keyword string `json:"keyword,omitempty"`
Filters SearchFilters `json:"filters,omitempty"`
Sort string `json:"sort,omitempty"`
PageSize int `json:"page_size,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
SearchFilesReq is the request body for POST /v1/messages/_search_files. `Keyword` is optional — when empty the DSL drops the multi_match clause and becomes a pure type-filter listing.
type SearchFilters ¶
type SearchFilters struct {
SenderIDs []string `json:"sender_ids,omitempty"`
SentAtFrom string `json:"sent_at_from,omitempty"`
SentAtTo string `json:"sent_at_to,omitempty"`
}
SearchFilters models the optional structured filters every endpoint shares.
type SearchGlobalFilesReq ¶ added in v1.9.0
type SearchGlobalFilesReq struct {
Keyword string `json:"keyword,omitempty"`
Filters GlobalFileFilters `json:"filters,omitempty"`
Sort string `json:"sort,omitempty"`
PageSize int `json:"page_size,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
SearchGlobalFilesReq is the request body for POST /v1/messages/_search_global_files (§7.2). Global variant of SearchFilesReq — no channel_type/channel_id, adds file_exts / file_size / channel_ids / channel_types / member_uid filters.
type SearchGlobalGroupsReq ¶ added in v1.11.0
type SearchGlobalGroupsReq struct {
Keyword string `json:"keyword,omitempty"`
Sequence int64 `json:"sequence,omitempty"`
Filters GlobalSearchFilters `json:"filters,omitempty"`
}
SearchGlobalGroupsReq is the request body for POST /v1/messages/_search_global_groups — the L1 group-aggregation (聚合优先) overview (aggregation-first design §2). Unlike _search_global_messages it has NO sort / page_size / cursor: L1 returns one aggregated overview per request and the bucket order is fixed to latest_at desc. `sequence` is echoed back verbatim for the frontend's stale-response guard (§4) — the backend does not validate its monotonicity.
type SearchGlobalMessagesReq ¶ added in v1.9.0
type SearchGlobalMessagesReq struct {
Keyword string `json:"keyword,omitempty"`
Filters GlobalSearchFilters `json:"filters,omitempty"`
Sort string `json:"sort,omitempty"`
PageSize int `json:"page_size,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
SearchGlobalMessagesReq is the request body for POST /v1/messages/_search_global_messages (§7.1). Same shape as SearchMessagesReq minus channel_type/channel_id, plus the global-only filter block.
type SearchMediaReq ¶
type SearchMediaReq struct {
ChannelType uint8 `json:"channel_type"`
ChannelID string `json:"channel_id"`
Filters SearchFilters `json:"filters,omitempty"`
Sort string `json:"sort,omitempty"`
PageSize int `json:"page_size,omitempty"`
Cursor string `json:"cursor,omitempty"`
Keyword string `json:"keyword,omitempty"` // must be empty
}
SearchMediaReq is the request body for POST /v1/messages/_search_media. Distinct from SearchMessagesReq because keyword must be empty (rejected with 400 if provided) and `relevance` sort is forbidden.
type SearchMessagesReq ¶
type SearchMessagesReq struct {
ChannelType uint8 `json:"channel_type"`
ChannelID string `json:"channel_id"`
Keyword string `json:"keyword,omitempty"`
Filters SearchFilters `json:"filters,omitempty"`
Sort string `json:"sort,omitempty"`
PageSize int `json:"page_size,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
SearchMessagesReq is the request body for POST /v1/messages/_search.
`Keyword` is optional; when empty the DSL drops the multi_match clause and the endpoint behaves as a time-ordered listing. To prevent an unconditional full-channel scan, an empty keyword still requires at least one effective filter (see validateSearchNotEmpty). `Sort` accepts time_desc (default) | time_asc | relevance — relevance requires a non-empty keyword. `PageSize` is normalised into [1, 100] with a default of 20.
type TextPayload ¶
type TextPayload struct {
Content string `json:"content,omitempty"`
}
type VideoPayload ¶
type VoicePayload ¶
type VoicePayload struct {
URL string `json:"url,omitempty"`
}
Source Files
¶
- 1module.go
- api.go
- api_i18n.go
- audit.go
- authz.go
- card.go
- channel.go
- config.go
- cursor.go
- dsl.go
- envelope.go
- es_client.go
- keyword_query.go
- obo.go
- predicate.go
- presence.go
- principal.go
- ratelimit.go
- recon_metrics.go
- route_subtree.go
- search_all.go
- search_around.go
- search_file_types.go
- search_files.go
- search_global.go
- search_global_files.go
- search_global_groups.go
- search_global_messages.go
- search_media.go
- search_messages.go
- sender_join.go
- source.go
- space_scope.go
- stopwords.go
- util.go
- validate.go
- visibility.go