messages_search

package
v1.16.0 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 46 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
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

func PrincipalKind(c *wkhttp.Context) string

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

func SetPrincipal(c *wkhttp.Context, p Principal)

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 FilePayload struct {
	URL       string `json:"url,omitempty"`
	Name      string `json:"name,omitempty"`
	Caption   string `json:"caption,omitempty"`
	SizeBytes int64  `json:"size,omitempty"`
	Ext       string `json:"extension,omitempty"`
}

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

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

Handler wires the four /v1/messages/_search* endpoints. New is invoked from 1module.go via the standard register.AddModule entry point.

func New

func New(ctx *config.Context) *Handler

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

func Shared(ctx *config.Context) *Handler

Shared 返回进程内唯一的搜索 Handler。web(本模块 SetupAPI)、bot(bot_api)、 uk(botfather)三条路由树共用它,从而共享限流桶 / sender 缓存 / 后端模式解析—— 单个 bot 的搜索配额是一致的一份,而非按入口分裂。首个调用方(模块装载顺序决定) 用其 *config.Context 构造;后续调用返回同一实例(各模块拿到的 ctx 是同一个)。

func (*Handler) MountSubtree added in v1.11.0

func (h *Handler) MountSubtree(r *wkhttp.WKHttp, prefix string, front ...wkhttp.HandlerFunc)

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

func (h *Handler) Route(r *wkhttp.WKHttp)

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 ImagePayload struct {
	URL     string `json:"url,omitempty"`
	Caption string `json:"caption,omitempty"`
	Name    string `json:"name,omitempty"`
	Width   int    `json:"width,omitempty"`
	Height  int    `json:"height,omitempty"`
}

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

type Pagination struct {
	HasMore    bool   `json:"has_more"`
	NextCursor string `json:"next_cursor"`
}

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

func AuthenticateUK(c *wkhttp.Context) (Principal, error)

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

func AuthenticateUserBot(c *wkhttp.Context) (Principal, error)

AuthenticateUserBot 解析 as-bot 主体:subjectUID = botUID(要求上游 authBot() 已在 context 落 robot_id)。App Bot 返回 ErrAppBotSearchDenied(一期不支持,决策五, 调用方须显式拒绝、不得静默放行)。

func NewOBOPrincipal added in v1.11.0

func NewOBOPrincipal(botUID, grantorUID, spaceID string) (Principal, error)

NewOBOPrincipal 组装 as-user(OBO) 主体:subjectUID = grantorUID(走真人分支)、 限流/审计按 botUID、审计并记 grantorUID。grant + scope + grantorCanReadChannel 的 实时权限校验(TOCTOU 与发消息侧一致)由调用方在此之前完成(#F 复用 bot_api/obo_check.go);本构造器只从两个已校验入参组装载体。

type RateLimitCfg

type RateLimitCfg struct {
	QPS   float64
	Burst int
}

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 VideoPayload struct {
	URL    string `json:"url,omitempty"`
	Cover  string `json:"cover,omitempty"`
	Width  int    `json:"width,omitempty"`
	Height int    `json:"height,omitempty"`
	Second int    `json:"second,omitempty"`
}

type VoicePayload

type VoicePayload struct {
	URL string `json:"url,omitempty"`
}

Jump to

Keyboard shortcuts

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