Prechádzať zdrojové kódy

Add MessageSearchService: get_search_status and search_messages

Chatto grew a MessageSearchService, but chattolib cannot reach it yet: the
service exists only on unreleased main, so nothing in the vendored proto
set could call it. This adds it.

Two RPCs, because they answer different questions. search_messages() is
the query; get_search_status() is the precondition that makes its failures
readable. Search is off by default — an operator enables it and runs a
provider that builds the index — so a failed search is a server state, not
a bug, and the two must not be confused: DISABLED is a decision that
retrying will not change, INDEXING is transient and carries a
retry_after_seconds worth honouring. get_search_status() answers even while
Search is disabled, so it is safe to call first; search_messages() raises
FAILED_PRECONDITION when disabled and UNAVAILABLE while the provider is
not ready.

The generated bindings are additive. scripts/generate_pb.sh wipes the tree
before regenerating, so without the fetch-list entry the new proto would
vanish on the next run and take its stubs with it; the proto comes from
main because that is the only ref carrying it, which also means it must not
be read as the wire surface of the version in pyproject.toml.

ThreadSearchContext is a separate dataclass from FollowedThread although
the two are field-identical today: they come from different RPCs, the
search one documents its messages as "when still visible", and a shared
type would turn any future divergence into a silent breakage.

search_messages takes the structured filters as arguments rather than
leaving them to the query string, and sends them as proto3 presence —
unset stays unset, so the server intersects what it receives with what
the caller may read instead of narrowing by an empty filter.

Verified: 102 passed / 2 skipped, ruff clean, mypy clean. The bindings were
generated with protoc 35.1 to match the gencode version of every existing
_pb2 file (7.35.1) rather than dragging the whole tree onto a newer
compiler.
Paul Klumpp 1 týždeň pred
rodič
commit
f97b7900d5

+ 1 - 0
AGENTS.md

@@ -97,6 +97,7 @@ to its grants.
 | Room directory | `RoomDirectoryService` | `ListRooms`, `ListRoomGroups`, `GetRoomGroup`, `BatchGetRoomGroups`, `GetRoom`, `BatchGetRooms` |
 | Rooms | `RoomService` | `CreateRoom`, `UpdateRoom`, `ArchiveRoom`, `UnarchiveRoom`, `JoinRoom`, `JoinRoomGroup`, `StartDM`, `LeaveRoom`, `AddMember`, `RemoveMember`, `ListMembers`, `GetMember`, `BatchGetMembers`, `RemoveUser`, `LiftSuspension`, `ListSuspensions`, `RefreshTypingIndicator`, `GetRoomEvents`, `GetRoomEventsAround`, `MarkRoomAsRead`, `ListRoomAttachments` |
 | Messages | `MessageService` | `FetchLinkPreview`, `CreateMessage`, `UpdateMessage`, `DeleteMessage`, `DeleteAttachment`, `DeleteLinkPreview`, `GetMessage`, `BatchGetMessages`, `AddReaction`, `RemoveReaction` |
+| Message search | `MessageSearchService` | `GetStatus`, `SearchMessages` — **operator-gated**: off by default, and both RPCs are absent on servers predating it. `get_search_status()` distinguishes DISABLED (operator decision) from INDEXING (transient, carries `retry_after`) from READY/DEGRADED; `search_messages()` raises `FAILED_PRECONDITION` when disabled and `UNAVAILABLE` while the provider cannot answer. |
 | Threads | `ThreadService` | `FollowThread`, `UnfollowThread`, `ListFollowedThreads` (`unread_only` filters to threads with unread replies, since 0.5.0b8; `root_author_filter` — `ThreadRootAuthorFilter.VIEWER`/`OTHERS` — filters by the author of the thread *root*, since 0.5.0b16), `GetThreadEvents`, `GetThreadEventsAround`, `MarkThreadAsRead` |
 | Notifications | `NotificationService` | `ListNotifications`, `GetNotification`, `BatchGetNotifications`, `ListRoomNotifications`, `ListRoomNotificationCounts`, `HasNotifications`, `DismissNotification`, `DismissAllNotifications` |
 | Notification prefs | `NotificationPreferencesService` | `Get`/`Update` × `Server`/`Room` |

+ 187 - 0
proto/chatto/api/v1/message_search.proto

@@ -0,0 +1,187 @@
+syntax = "proto3";
+
+package chatto.api.v1;
+
+import "buf/validate/validate.proto";
+import "chatto/api/v1/message_types.proto";
+import "chatto/api/v1/room_timeline.proto";
+import "chatto/api/v1/rooms.proto";
+import "chatto/api/v1/threads.proto";
+import "google/protobuf/duration.proto";
+import "google/protobuf/timestamp.proto";
+
+// Ordering for message or grouped-thread search results.
+enum MessageSearchOrder {
+  // Use relevance order.
+  MESSAGE_SEARCH_ORDER_UNSPECIFIED = 0;
+  // Highest message score first; grouped results use their best matching message.
+  MESSAGE_SEARCH_ORDER_RELEVANCE = 1;
+  // Newest matching message first, also when results are grouped.
+  MESSAGE_SEARCH_ORDER_NEWEST = 2;
+  // Return threads with the most recent activity first. Requires thread grouping.
+  MESSAGE_SEARCH_ORDER_THREAD_ACTIVITY = 3;
+  // Newest root-message creation first, with unknown dates last. Requires thread
+  // grouping. Older servers can reject this order.
+  MESSAGE_SEARCH_ORDER_THREAD_CREATED = 4;
+}
+
+// Candidate scope, intersected with the caller's current access and query filters.
+enum MessageSearchScope {
+  // Search all accessible messages. This is the existing default.
+  MESSAGE_SEARCH_SCOPE_UNSPECIFIED = 0;
+  // Search roots and replies in followed threads, including direct messages.
+  MESSAGE_SEARCH_SCOPE_FOLLOWED_THREADS = 1;
+}
+
+// Determines the result type and pagination unit.
+enum MessageSearchGroupBy {
+  // Return individual matching messages.
+  MESSAGE_SEARCH_GROUP_BY_UNSPECIFIED = 0;
+  // Return distinct thread rows. A root without replies is also a thread.
+  MESSAGE_SEARCH_GROUP_BY_THREAD = 1;
+}
+
+// Current availability of message search on this server.
+enum MessageSearchState {
+  // The server did not report a meaningful state.
+  MESSAGE_SEARCH_STATE_UNSPECIFIED = 0;
+  // Search is disabled by the server operator.
+  MESSAGE_SEARCH_STATE_DISABLED = 1;
+  // Search is enabled but its provider has not established index state yet.
+  MESSAGE_SEARCH_STATE_STARTING = 2;
+  // The provider is rebuilding or catching up and cannot answer queries yet.
+  MESSAGE_SEARCH_STATE_INDEXING = 3;
+  // Search is ready to answer queries.
+  MESSAGE_SEARCH_STATE_READY = 4;
+  // Search can answer queries but has a known non-fatal limitation.
+  MESSAGE_SEARCH_STATE_DEGRADED = 5;
+  // Search is enabled but no provider can currently answer.
+  MESSAGE_SEARCH_STATE_UNAVAILABLE = 6;
+}
+
+// Request to search current message bodies visible to the authenticated user.
+message SearchMessagesRequest {
+  // Required query expression. Words are required terms, quoted text is an
+  // exact phrase, and `AND` may separate terms. The query also accepts `in:`,
+  // `from:`, `before:`, `after:`, and `has:attachment` filters; a recognized
+  // filter may be used without a word or phrase.
+  string query = 1 [(buf.validate.field).string = {
+    min_len: 1
+    max_bytes: 1024
+  }];
+  // Optional room-ID scope. The server intersects this room with rooms the
+  // current user may read and with any `in:` filters in query.
+  optional string room_id = 2 [(buf.validate.field).string = {
+    min_len: 1
+    max_bytes: 128
+  }];
+  // Optional author-ID scope. The server intersects this author with any
+  // `from:` filters in query.
+  optional string author_id = 3 [(buf.validate.field).string = {
+    min_len: 1
+    max_bytes: 128
+  }];
+  // Exclusive lower bound on message creation time. The stricter bound wins
+  // when query also contains `after:`.
+  google.protobuf.Timestamp created_after = 4;
+  // Exclusive upper bound on message creation time. The stricter bound wins
+  // when query also contains `before:`.
+  google.protobuf.Timestamp created_before = 5;
+  // When true, return only messages that currently have attachments.
+  bool has_attachments = 6;
+  // Defaults to relevance in both result modes. Thread activity order requires
+  // thread grouping; newest order uses matching-message creation time.
+  MessageSearchOrder order = 7 [(buf.validate.field).enum.defined_only = true];
+  // Maximum results to return (messages or distinct threads). Zero uses 50; the
+  // maximum is 100. Stale or no-longer-visible provider hits are omitted, so a
+  // page may contain fewer messages than requested even when next_cursor is
+  // present.
+  uint32 page_size = 8 [(buf.validate.field).uint32.lte = 100];
+  // Opaque cursor returned by the preceding response. It is bound to the
+  // authenticated user and every other query field.
+  string cursor = 9 [(buf.validate.field).string.max_bytes = 8192];
+  // Candidate scope. Followed threads are selected before result pagination.
+  MessageSearchScope scope = 10 [(buf.validate.field).enum.defined_only = true];
+  // Result grouping. The server deduplicates threads before pagination.
+  MessageSearchGroupBy group_by = 11 [(buf.validate.field).enum.defined_only = true];
+  // Select root authors within FOLLOWED_THREADS before search and pagination.
+  // Other scopes require UNSPECIFIED. This does not restrict reply authors;
+  // author_id and from: still select the authors of matching messages.
+  ThreadRootAuthorFilter root_author_filter = 12 [(buf.validate.field).enum.defined_only = true];
+}
+
+// One current, authorized match. With thread grouping, each result represents
+// one thread and message is its best match in the selected order.
+message MessageSearchResult {
+  // Current renderable message that matched the query.
+  Message message = 1;
+  // Search-provider relevance score. Higher values are more relevant for the
+  // same query. Clients may merge results from servers using compatible
+  // search implementations by this value.
+  double relevance_score = 2;
+  // Present only with THREAD grouping. The matching message can be a reply;
+  // the root and latest reply in this context need not match the query.
+  ThreadSearchContext thread_context = 3;
+}
+
+// One ordered page of current, authorized message or thread results. Pagination reads a live
+// search index rather than a pinned snapshot, so results may move, repeat, or
+// disappear between page requests while the index advances.
+message SearchMessagesResponse {
+  // Ordered matches. With THREAD grouping, one result per matching thread.
+  // Otherwise each result is one message, without thread_context.
+  repeated MessageSearchResult results = 1;
+  // Opaque cursor for the next page in the selected result mode.
+  // Empty means no more matches. Live activity can reorder thread pages.
+  string next_cursor = 2;
+  // Total distinct matching threads. Present only when group_by is THREAD.
+  optional uint64 thread_total_count = 4;
+  // Related entities for grouped results, avoiding per-row reads.
+  RoomTimelineIncludes includes = 5;
+}
+
+// Current context for a grouped match. The viewer need not follow this thread.
+// Search evidence stays on the containing MessageSearchResult.
+message ThreadSearchContext {
+  // Renderable root message, when still visible.
+  Message root_message = 1;
+  // Room containing this thread.
+  RoomSummary room = 2;
+  // Current thread metadata and viewer follow/read state.
+  ThreadSummary thread = 3;
+  // Most recent visible reply, when present; it need not match the query.
+  Message latest_reply = 4;
+  // Conversation-label participants for direct messages; empty for channels.
+  repeated string direct_message_participant_user_ids = 5;
+}
+
+// Request for current message-search availability.
+message GetStatusRequest {}
+
+// Current message-search availability.
+message GetStatusResponse {
+  reserved 2, 3;
+  reserved "indexed_event_count", "target_event_count";
+
+  // Current feature/provider state.
+  MessageSearchState state = 1;
+  // Suggested delay before checking again while search is not ready.
+  google.protobuf.Duration retry_after = 4;
+}
+
+// Searches current message content visible to an authenticated user.
+service MessageSearchService {
+  // Reports whether the operator enabled Search and whether its provider can
+  // currently answer. This method remains available when Search is disabled.
+  rpc GetStatus(GetStatusRequest) returns (GetStatusResponse);
+  // Searches current message bodies in rooms where the caller is a member.
+  // Scope and grouping are independent, including for direct messages. THREAD
+  // grouping returns one row per matching root and its replies. Scope, grouping,
+  // and ordering are part of the cursor scope. Unsupported combinations return
+  // INVALID_ARGUMENT. Existing requests return individual messages by default.
+  // Results also require message.read or a matching thread relationship with
+  // message.read-interactions. Results are authorized and hydrated from current Chatto state
+  // after provider matching. Returns FAILED_PRECONDITION when Search is
+  // disabled and UNAVAILABLE when its provider is not ready.
+  rpc SearchMessages(SearchMessagesRequest) returns (SearchMessagesResponse);
+}

+ 1 - 1
scripts/generate_pb.sh

@@ -92,7 +92,7 @@ echo "Fetching proto sources ..."
 for f in \
     account.proto attachments.proto asset_uploads.proto common.proto \
     external_identities.proto link_previews.proto member_directory.proto \
-    messages.proto message_types.proto \
+    message_search.proto messages.proto message_types.proto \
     notifications.proto pagination.proto permissions.proto presence.proto \
     push_notifications.proto reactions.proto read_state.proto \
     room_directory.proto rooms.proto room_timeline.proto roles.proto \

+ 16 - 0
src/chattolib/__init__.py

@@ -36,6 +36,13 @@ from chattolib.types import (
     Message,
     MessageAttachment,
     MessageReaction,
+    MessageSearchGroupBy,
+    MessageSearchOrder,
+    MessageSearchPage,
+    MessageSearchResult,
+    MessageSearchScope,
+    MessageSearchState,
+    MessageSearchStatus,
     MessageViewerState,
     Neighbor,
     NeighborhoodServer,
@@ -70,6 +77,7 @@ from chattolib.types import (
     ServerRuntimeConfig,
     SidebarLink,
     ThreadRootAuthorFilter,
+    ThreadSearchContext,
     ThreadSummary,
     TimeFormat,
     TimelineEvent,
@@ -112,6 +120,13 @@ __all__ = [
     "ImageTransformOptions",
     "LinkPreview",
     "Message",
+    "MessageSearchGroupBy",
+    "MessageSearchOrder",
+    "MessageSearchPage",
+    "MessageSearchResult",
+    "MessageSearchScope",
+    "MessageSearchState",
+    "MessageSearchStatus",
     "MessageAttachment",
     "MessageReaction",
     "MessageViewerState",
@@ -148,6 +163,7 @@ __all__ = [
     "ServerRuntimeConfig",
     "SidebarLink",
     "ThreadRootAuthorFilter",
+    "ThreadSearchContext",
     "ThreadSummary",
     "TimeFormat",
     "TimelineEvent",

+ 57 - 0
src/chattolib/_pb/chatto/api/v1/message_search_connect.py

@@ -0,0 +1,57 @@
+# Generated by chattolib's protoc plugin (protoc-gen-chattolib). DO NOT EDIT!
+
+# source: chatto/api/v1/message_search.proto
+
+import chatto.api.v1.message_search_pb2 as chatto_api_v1_message_search_pb2
+from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo
+
+class MessageSearchServiceClient(ConnectClient):
+    async def get_status(self, request: chatto_api_v1_message_search_pb2.GetStatusRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_message_search_pb2.GetStatusResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetStatus",
+                service_name="chatto.api.v1.MessageSearchService",
+                input=chatto_api_v1_message_search_pb2.GetStatusRequest,
+                output=chatto_api_v1_message_search_pb2.GetStatusResponse,
+            ),
+            headers=headers,
+        )
+
+    async def search_messages(self, request: chatto_api_v1_message_search_pb2.SearchMessagesRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_message_search_pb2.SearchMessagesResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="SearchMessages",
+                service_name="chatto.api.v1.MessageSearchService",
+                input=chatto_api_v1_message_search_pb2.SearchMessagesRequest,
+                output=chatto_api_v1_message_search_pb2.SearchMessagesResponse,
+            ),
+            headers=headers,
+        )
+
+class MessageSearchServiceClientSync(ConnectClientSync):
+
+    def get_status(self, request: chatto_api_v1_message_search_pb2.GetStatusRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_message_search_pb2.GetStatusResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetStatus",
+                service_name="chatto.api.v1.MessageSearchService",
+                input=chatto_api_v1_message_search_pb2.GetStatusRequest,
+                output=chatto_api_v1_message_search_pb2.GetStatusResponse,
+            ),
+            headers=headers,
+        )
+
+    def search_messages(self, request: chatto_api_v1_message_search_pb2.SearchMessagesRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_message_search_pb2.SearchMessagesResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="SearchMessages",
+                service_name="chatto.api.v1.MessageSearchService",
+                input=chatto_api_v1_message_search_pb2.SearchMessagesRequest,
+                output=chatto_api_v1_message_search_pb2.SearchMessagesResponse,
+            ),
+            headers=headers,
+        )

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 33 - 0
src/chattolib/_pb/chatto/api/v1/message_search_pb2.py


+ 5 - 0
src/chattolib/_transport.py

@@ -46,6 +46,9 @@ from chattolib._pb.chatto.admin.v1.server_connect import AdminServerServiceClien
 from chattolib._pb.chatto.api.v1.account_connect import MyAccountServiceClient
 from chattolib._pb.chatto.api.v1.asset_uploads_connect import AssetUploadServiceClient
 from chattolib._pb.chatto.api.v1.attachments_connect import AssetServiceClient
+from chattolib._pb.chatto.api.v1.message_search_connect import (
+    MessageSearchServiceClient,
+)
 from chattolib._pb.chatto.api.v1.messages_connect import MessageServiceClient
 from chattolib._pb.chatto.api.v1.notification_preferences_connect import (
     NotificationPreferencesServiceClient,
@@ -88,6 +91,7 @@ class ServiceClients:
     room_directory: RoomDirectoryServiceClient
     rooms: RoomServiceClient
     messages: MessageServiceClient
+    message_search: MessageSearchServiceClient
     threads: ThreadServiceClient
     user: UserServiceClient
     notifications: NotificationServiceClient
@@ -133,6 +137,7 @@ def build_service_clients(base_url: str) -> ServiceClients:
         room_directory=make(RoomDirectoryServiceClient),
         rooms=make(RoomServiceClient),
         messages=make(MessageServiceClient),
+        message_search=make(MessageSearchServiceClient),
         threads=make(ThreadServiceClient),
         user=make(UserServiceClient),
         notifications=make(NotificationServiceClient),

+ 103 - 0
src/chattolib/client.py

@@ -47,6 +47,7 @@ from chattolib._pb.chatto.api.v1 import (
     common_pb2,
     link_previews_pb2,
     member_directory_pb2,
+    message_search_pb2,
     messages_pb2,
     notification_preferences_pb2,
     notifications_pb2,
@@ -88,6 +89,11 @@ from chattolib.types import (
     ImageTransformOptions,
     LinkPreview,
     Message,
+    MessageSearchGroupBy,
+    MessageSearchOrder,
+    MessageSearchPage,
+    MessageSearchScope,
+    MessageSearchStatus,
     Neighbor,
     NeighborhoodServersPage,
     NotificationLevel,
@@ -1063,6 +1069,103 @@ class ChattoClient:
             m for m in (Message.parse(row) for row in data.get("messages") or []) if m is not None
         ]
 
+    async def get_search_status(self) -> MessageSearchStatus:
+        """Whether Search can answer on this server right now.
+
+        Search is **off by default**: an operator enables it
+        (``search.enabled``) and runs a provider that builds the index. That
+        makes a search failure a *server state* rather than a bug, and this is
+        the call that tells the two apart — ``DISABLED`` is an operator
+        decision that will not change by retrying, ``INDEXING`` is transient,
+        and ``retry_after_seconds`` says how long to wait.
+
+        Unlike ``search_messages``, this RPC answers on a server where Search
+        is disabled, so it is safe to call before deciding what to do.
+        """
+        resp = await self._rpc(
+            self._svc.message_search.get_status(
+                message_search_pb2.GetStatusRequest(), headers=self._headers()
+            )
+        )
+        return MessageSearchStatus.parse(pb_to_dict(resp))
+
+    async def search_messages(
+        self,
+        query: str,
+        *,
+        room_id: str | None = None,
+        author_id: str | None = None,
+        created_after: datetime | None = None,
+        created_before: datetime | None = None,
+        has_attachments: bool | None = None,
+        order: MessageSearchOrder | None = None,
+        page_size: int | None = None,
+        cursor: str | None = None,
+        scope: MessageSearchScope | None = None,
+        group_by: MessageSearchGroupBy | None = None,
+        root_author_filter: ThreadRootAuthorFilter | None = None,
+    ) -> MessageSearchPage:
+        """Search current message bodies the caller may read.
+
+        ``query`` is the server's query language, not a substring: words are
+        required terms, ``"quoted text"`` is an exact phrase, ``AND`` separates
+        terms, and the filters ``in:``, ``from:``, ``before:``, ``after:`` and
+        ``has:attachment`` are accepted inside it (a recognised filter may stand
+        alone). The structured arguments below narrow the same query and are
+        intersected with it by the server, so they never widen it.
+
+        Continue a result set by passing the previous page's ``next_cursor``
+        unchanged. It is bound to the caller and to every other query field —
+        changing one field invalidates it. Pagination reads a live index, so
+        results may move, repeat or disappear between pages while the index
+        advances; that is the server's contract, not something to work around.
+
+        ``group_by=THREAD`` returns one row per matching thread instead of one
+        per message, with ``thread_context`` holding the root and latest reply.
+        ``THREAD_ACTIVITY`` and ``THREAD_CREATED`` orders require that grouping;
+        unsupported combinations come back as ``INVALID_ARGUMENT``.
+
+        The server raises ``FAILED_PRECONDITION`` when Search is disabled and
+        ``UNAVAILABLE`` when its provider cannot answer yet — call
+        :meth:`get_search_status` first when you need to tell those apart from a
+        real query error.
+        """
+        req = message_search_pb2.SearchMessagesRequest(query=query)
+        if room_id is not None:
+            req.room_id = room_id
+        if author_id is not None:
+            req.author_id = author_id
+        if has_attachments is not None:
+            req.has_attachments = has_attachments
+        if page_size is not None:
+            req.page_size = page_size
+        if cursor is not None:
+            req.cursor = cursor
+        # Enums go through the pb2 Value() lookup so a StrEnum member and its
+        # protobuf integer cannot drift apart.
+        if order is not None:
+            req.order = message_search_pb2.MessageSearchOrder.Value(order.value)
+        if scope is not None:
+            req.scope = message_search_pb2.MessageSearchScope.Value(scope.value)
+        if group_by is not None:
+            req.group_by = message_search_pb2.MessageSearchGroupBy.Value(group_by.value)
+        if root_author_filter is not None:
+            req.root_author_filter = threads_pb2.ThreadRootAuthorFilter.Value(
+                root_author_filter.value
+            )
+        # Timestamps are message fields, so they are set after construction:
+        # a Timestamp message cannot be passed as a constructor keyword.
+        for field, value in (
+            ("created_after", created_after),
+            ("created_before", created_before),
+        ):
+            if value is not None:
+                getattr(req, field).CopyFrom(_timestamp_pb(value))
+        resp = await self._rpc(
+            self._svc.message_search.search_messages(req, headers=self._headers())
+        )
+        return MessageSearchPage.parse(pb_to_dict(resp))
+
     async def add_reaction(self, room_id: str, message_event_id: str, emoji: str) -> bool:
         resp = await self._rpc(
             self._svc.messages.add_reaction(

+ 176 - 0
src/chattolib/types.py

@@ -89,6 +89,43 @@ class ThreadRootAuthorFilter(StrEnum):
     OTHERS = "THREAD_ROOT_AUTHOR_FILTER_OTHERS"
 
 
+class MessageSearchOrder(StrEnum):
+    UNSPECIFIED = "MESSAGE_SEARCH_ORDER_UNSPECIFIED"
+    RELEVANCE = "MESSAGE_SEARCH_ORDER_RELEVANCE"
+    NEWEST = "MESSAGE_SEARCH_ORDER_NEWEST"
+    THREAD_ACTIVITY = "MESSAGE_SEARCH_ORDER_THREAD_ACTIVITY"
+    THREAD_CREATED = "MESSAGE_SEARCH_ORDER_THREAD_CREATED"
+
+
+class MessageSearchScope(StrEnum):
+    UNSPECIFIED = "MESSAGE_SEARCH_SCOPE_UNSPECIFIED"
+    FOLLOWED_THREADS = "MESSAGE_SEARCH_SCOPE_FOLLOWED_THREADS"
+
+
+class MessageSearchGroupBy(StrEnum):
+    UNSPECIFIED = "MESSAGE_SEARCH_GROUP_BY_UNSPECIFIED"
+    THREAD = "MESSAGE_SEARCH_GROUP_BY_THREAD"
+
+
+class MessageSearchState(StrEnum):
+    """Whether Search can answer on this server right now.
+
+    Search is off by default: an operator enables it and runs a provider that
+    builds the index. A client therefore has to distinguish "disabled" (an
+    operator decision, permanent) from "indexing" (transient) — and both from
+    ``READY``. Call :meth:`ChattoClient.get_search_status` before a search to
+    find out which one it is, rather than parsing a FAILED_PRECONDITION.
+    """
+
+    UNSPECIFIED = "MESSAGE_SEARCH_STATE_UNSPECIFIED"
+    DISABLED = "MESSAGE_SEARCH_STATE_DISABLED"
+    STARTING = "MESSAGE_SEARCH_STATE_STARTING"
+    INDEXING = "MESSAGE_SEARCH_STATE_INDEXING"
+    READY = "MESSAGE_SEARCH_STATE_READY"
+    DEGRADED = "MESSAGE_SEARCH_STATE_DEGRADED"
+    UNAVAILABLE = "MESSAGE_SEARCH_STATE_UNAVAILABLE"
+
+
 class ImageFitMode(StrEnum):
     UNSPECIFIED = "IMAGE_FIT_MODE_UNSPECIFIED"
     CONTAIN = "IMAGE_FIT_MODE_CONTAIN"
@@ -112,6 +149,30 @@ class AssetUploadStatus(StrEnum):
 # --- Time helpers --------------------------------------------------------
 
 
+def parse_duration_seconds(value: Any) -> float | None:
+    """Parse a protobuf ``Duration`` in proto-JSON form (``"1.5s"``).
+
+    Connect JSON encodes a Duration as a decimal number of seconds with an
+    ``s`` suffix, so the wire shape is a string rather than the two numeric
+    fields the binary codec uses. Returns ``None`` for an absent or unreadable
+    value, so a caller can treat "no delay suggested" as "no delay".
+    """
+    if value is None or value == "":
+        return None
+    if isinstance(value, (int, float)):
+        return float(value)
+    if isinstance(value, dict):
+        seconds = value.get("seconds")
+        return float(seconds) if seconds is not None else None
+    text = str(value).strip()
+    if text.endswith("s"):
+        text = text[:-1]
+    try:
+        return float(text)
+    except ValueError:
+        return None
+
+
 def parse_datetime(value: Any) -> datetime | None:
     """Parse an RFC 3339 timestamp string (proto JSON encoding for Timestamp)."""
     if not value:
@@ -1097,6 +1158,121 @@ class FollowedThreadsPage:
     users_by_id: dict[str, User] = field(default_factory=dict)
 
 
+@dataclass
+class ThreadSearchContext:
+    """Context for one grouped search hit.
+
+    Structurally identical to :class:`FollowedThread` today, but a distinct
+    type on purpose: it comes from a different RPC, its fields are documented
+    as "when still visible" rather than "current", and the two are free to
+    diverge without a shared dataclass turning that into a silent breakage.
+    """
+
+    root_message: Message | None = None
+    room: RoomSummary | None = None
+    thread: ThreadSummary | None = None
+    latest_reply: Message | None = None
+    direct_message_participant_user_ids: list[str] = field(default_factory=list)
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> ThreadSearchContext | None:
+        if not data:
+            return None
+        return cls(
+            root_message=Message.parse(data.get("rootMessage")),
+            room=RoomSummary.parse(data.get("room")),
+            thread=ThreadSummary.parse(data.get("thread")),
+            latest_reply=Message.parse(data.get("latestReply")),
+            direct_message_participant_user_ids=list(
+                data.get("directMessageParticipantUserIds") or []
+            ),
+        )
+
+
+@dataclass
+class MessageSearchResult:
+    """One current, authorized match.
+
+    With ``group_by=THREAD`` a result stands for a whole thread and
+    ``message`` is its best match, not necessarily its root — the root and the
+    latest reply live in :attr:`thread_context` instead.
+    """
+
+    message: Message | None = None
+    relevance_score: float = 0.0
+    thread_context: ThreadSearchContext | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any]) -> MessageSearchResult:
+        return cls(
+            message=Message.parse(data.get("message")),
+            relevance_score=float(data.get("relevanceScore") or 0.0),
+            thread_context=ThreadSearchContext.parse(data.get("threadContext")),
+        )
+
+
+@dataclass
+class MessageSearchStatus:
+    """Whether Search can answer on this server, and how long to wait if not."""
+
+    state: MessageSearchState = MessageSearchState.UNSPECIFIED
+    retry_after_seconds: float | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> MessageSearchStatus:
+        data = data or {}
+        return cls(
+            state=_parse_enum(
+                MessageSearchState,
+                data.get("state"),
+                MessageSearchState.UNSPECIFIED,
+            ),  # type: ignore[arg-type]
+            retry_after_seconds=parse_duration_seconds(data.get("retryAfter")),
+        )
+
+    @property
+    def is_ready(self) -> bool:
+        """True when a search will be answered rather than refused.
+
+        ``DEGRADED`` counts as ready: the server says it can answer with a
+        known non-fatal limitation.
+        """
+        return self.state in (MessageSearchState.READY, MessageSearchState.DEGRADED)
+
+
+@dataclass
+class MessageSearchPage:
+    """One page of search results.
+
+    ``next_cursor`` is opaque and bound to the caller and to every other query
+    field — pass it back unchanged to continue. Pagination reads a live index,
+    so results can move or repeat between pages; that is the server's contract,
+    not a client-side defect.
+    """
+
+    results: list[MessageSearchResult] = field(default_factory=list)
+    next_cursor: str = ""
+    thread_total_count: int | None = None
+    users_by_id: dict[str, User] = field(default_factory=dict)
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> MessageSearchPage:
+        data = data or {}
+        includes = data.get("includes") or {}
+        users: dict[str, User] = {}
+        for uid, user_data in (includes.get("users") or {}).items():
+            parsed_user = User.parse(user_data)
+            if parsed_user is not None:
+                users[uid] = parsed_user
+        thread_total = data.get("threadTotalCount")
+        return cls(
+            results=[MessageSearchResult.parse(r) for r in data.get("results") or []],
+            next_cursor=data.get("nextCursor", ""),
+            thread_total_count=int(thread_total) if thread_total is not None else None,
+            users_by_id=users,
+        )
+
+
 @dataclass
 @dataclass
 class NotificationMessageReference:

+ 173 - 0
tests/test_client.py

@@ -24,6 +24,7 @@ from chattolib._pb.chatto.api.v1 import (
     account_pb2,
     asset_uploads_pb2,
     attachments_pb2,
+    message_search_pb2,
     messages_pb2,
     notification_preferences_pb2,
     notifications_pb2,
@@ -45,6 +46,10 @@ from chattolib.client import ChattoClient
 from chattolib.types import (
     ImageFitMode,
     ImageTransformOptions,
+    MessageSearchGroupBy,
+    MessageSearchOrder,
+    MessageSearchScope,
+    MessageSearchState,
     NotificationLevel,
     PresenceStatus,
     RoomKind,
@@ -1048,3 +1053,171 @@ def test_snake_case_is_acronym_aware():
     # hypothetical acronym field: DM stays whole, boundary before the word after it
     assert _snake_case("startDMReceived") == "start_dm_received"
     assert _snake_case("getDMStats") == "get_dm_stats"
+
+
+# --- Message search -----------------------------------------------------
+
+
+async def test_get_search_status_reports_disabled_with_no_delay(client):
+    """Search is off by default, so DISABLED is a state a client has to be
+    able to read — not an error it has to decode from an exception."""
+    resp = message_search_pb2.GetStatusResponse()
+    resp.state = message_search_pb2.MessageSearchState.MESSAGE_SEARCH_STATE_DISABLED
+    _mock_method(client, "message_search", "get_status", resp)
+
+    async with client:
+        status = await client.get_search_status()
+
+    assert status.state is MessageSearchState.DISABLED
+    assert status.retry_after_seconds is None
+    assert status.is_ready is False
+
+
+async def test_get_search_status_carries_the_servers_retry_delay(client):
+    """INDEXING is transient and the server says how long to wait; dropping
+    that number would leave the caller to invent a backoff of its own."""
+    resp = message_search_pb2.GetStatusResponse()
+    resp.state = message_search_pb2.MessageSearchState.MESSAGE_SEARCH_STATE_INDEXING
+    resp.retry_after.FromJsonString("3.5s")
+    _mock_method(client, "message_search", "get_status", resp)
+
+    async with client:
+        status = await client.get_search_status()
+
+    assert status.state is MessageSearchState.INDEXING
+    assert status.retry_after_seconds == pytest.approx(3.5)
+    assert status.is_ready is False
+
+
+async def test_degraded_search_still_counts_as_ready(client):
+    """DEGRADED is documented as "can answer with a known non-fatal
+    limitation", so a readiness check that excludes it refuses working search."""
+    resp = message_search_pb2.GetStatusResponse()
+    resp.state = message_search_pb2.MessageSearchState.MESSAGE_SEARCH_STATE_DEGRADED
+    _mock_method(client, "message_search", "get_status", resp)
+
+    async with client:
+        status = await client.get_search_status()
+
+    assert status.is_ready is True
+
+
+async def test_search_messages_parses_results_and_page_state(client):
+    resp = message_search_pb2.SearchMessagesResponse()
+    result = resp.results.add()
+    result.message.id = "e1"
+    result.message.room_id = "r1"
+    result.message.body = "the migration runs on friday"
+    result.relevance_score = 0.8
+    resp.next_cursor = "cursor-2"
+    resp.includes.users["u1"].login = "alice"
+    _mock_method(client, "message_search", "search_messages", resp)
+
+    async with client:
+        page = await client.search_messages("migration")
+
+    assert len(page.results) == 1
+    assert page.results[0].message is not None
+    assert page.results[0].message.body == "the migration runs on friday"
+    assert page.results[0].relevance_score == pytest.approx(0.8)
+    assert page.results[0].thread_context is None
+    assert page.next_cursor == "cursor-2"
+    assert page.thread_total_count is None
+    assert page.users_by_id["u1"].login == "alice"
+
+
+async def test_search_messages_parses_thread_grouping(client):
+    """With THREAD grouping a result stands for a whole thread: the root and
+    the latest reply live in thread_context, and the message is only its best
+    match. Reading them as if they were one message misreports the thread."""
+    resp = message_search_pb2.SearchMessagesResponse()
+    result = resp.results.add()
+    result.message.id = "e2"
+    result.message.body = "a reply that matched"
+    result.relevance_score = 0.5
+    result.thread_context.root_message.id = "e1"
+    result.thread_context.root_message.body = "the root, which did not match"
+    result.thread_context.room.id = "r1"
+    result.thread_context.thread.thread_root_event_id = "e1"
+    result.thread_context.thread.reply_count = 4
+    result.thread_context.latest_reply.id = "e9"
+    result.thread_context.latest_reply.body = "newest reply"
+    resp.thread_total_count = 1
+    _mock_method(client, "message_search", "search_messages", resp)
+
+    async with client:
+        page = await client.search_messages("reply", group_by=MessageSearchGroupBy.THREAD)
+
+    context = page.results[0].thread_context
+    assert context is not None
+    assert context.root_message is not None
+    assert context.root_message.id == "e1"
+    assert context.room is not None
+    assert context.room.id == "r1"
+    assert context.thread is not None
+    assert context.thread.reply_count == 4
+    assert context.latest_reply is not None
+    assert context.latest_reply.id == "e9"
+    assert page.thread_total_count == 1
+
+
+async def test_search_messages_sends_the_full_query_shape(client):
+    """Every optional field has to reach the wire as the protobuf value the
+    server validates — enums as their pb2 number, timestamps as Timestamp
+    messages. A silently dropped scope or filter turns a search into a
+    different, broader one."""
+    resp = message_search_pb2.SearchMessagesResponse()
+    mocked = _mock_method(client, "message_search", "search_messages", resp)
+
+    async with client:
+        await client.search_messages(
+            "deploy plan",
+            room_id="r1",
+            author_id="u1",
+            created_after=dt.datetime(2026, 1, 1, tzinfo=dt.UTC),
+            created_before=dt.datetime(2026, 2, 1, tzinfo=dt.UTC),
+            has_attachments=True,
+            order=MessageSearchOrder.NEWEST,
+            page_size=25,
+            cursor="cursor-1",
+            scope=MessageSearchScope.FOLLOWED_THREADS,
+            group_by=MessageSearchGroupBy.THREAD,
+            root_author_filter=ThreadRootAuthorFilter.VIEWER,
+        )
+
+    req = mocked.call_args.args[0]
+    assert req.query == "deploy plan"
+    assert req.room_id == "r1"
+    assert req.author_id == "u1"
+    assert req.created_after.ToJsonString() == "2026-01-01T00:00:00Z"
+    assert req.created_before.ToJsonString() == "2026-02-01T00:00:00Z"
+    assert req.has_attachments is True
+    assert req.order == message_search_pb2.MessageSearchOrder.MESSAGE_SEARCH_ORDER_NEWEST
+    assert req.page_size == 25
+    assert req.cursor == "cursor-1"
+    assert req.scope == message_search_pb2.MessageSearchScope.MESSAGE_SEARCH_SCOPE_FOLLOWED_THREADS
+    assert req.group_by == message_search_pb2.MessageSearchGroupBy.MESSAGE_SEARCH_GROUP_BY_THREAD
+    assert req.root_author_filter == threads_pb2.ThreadRootAuthorFilter.Value(
+        "THREAD_ROOT_AUTHOR_FILTER_VIEWER"
+    )
+
+
+async def test_search_messages_leaves_unset_fields_at_their_defaults(client):
+    """Optional filters must stay *unset*, not be written as empty strings or
+    zero timestamps: the server intersects what it receives with what the caller
+    can read, and an empty filter is not the same as no filter."""
+    resp = message_search_pb2.SearchMessagesResponse()
+    mocked = _mock_method(client, "message_search", "search_messages", resp)
+
+    async with client:
+        await client.search_messages("anything")
+
+    req = mocked.call_args.args[0]
+    assert req.query == "anything"
+    assert req.room_id == ""
+    assert req.author_id == ""
+    assert req.has_attachments is False
+    assert req.page_size == 0
+    assert req.cursor == ""
+    assert req.order == message_search_pb2.MessageSearchOrder.MESSAGE_SEARCH_ORDER_UNSPECIFIED
+    assert req.HasField("created_after") is False

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov