Преглед на файлове

Track Chatto 0.5.0-beta.8

Purely additive wire delta. ServerDiscoveryService gains the public
ListNeighborhoodServers RPC (NeighborhoodServer { origin, profile,
direct_neighbor, recommended_by_origins } + refreshed_at; the profile
carries no welcome message and its logo/banner URLs are server-relative
paths of copies on the called server). ListFollowedThreadsRequest gains
bool unread_only = 5 (filter to followed threads with unread replies;
older servers ignore it). Client: new list_neighborhood_servers() wrapper
returning the new NeighborhoodServersPage dataclass, plus
include_direct_message_threads/unread_only kwargs on
list_followed_threads (None = unset, so default calls are byte-identical
to before). Realtime protocol unchanged (still v4).

Verification: ruff format+check clean (27 files); mypy zero new errors vs
baseline (same 6 pre-existing import-untyped protobuf notes); pytest
90 passed / 2 skipped; live GetServer round-trip against
chat.chatto.run returned version 0.5.0-beta.8; live
ListNeighborhoodServers returned 3 servers; live bot integration tests
2 passed (1 skipped, needs a second bot).

Bumped version 0.5.0b7 -> 0.5.0b8.
the-codemancer преди 1 месец
родител
ревизия
9cca71267b

+ 3 - 2
AGENTS.md

@@ -88,7 +88,7 @@ to its grants.
 
 | Domain | Service (`chatto.api.v1.…` unless noted) | Notable RPCs |
 |---|---|---|
-| Discovery | `chatto.discovery.v1.ServerDiscoveryService` | `GetServer` (public) |
+| Discovery | `chatto.discovery.v1.ServerDiscoveryService` | `GetServer` (public), `ListNeighbors` (public), `ListNeighborhoodServers` (public — the Neighborhood the server discovered in the background; `NeighborhoodServer { origin, profile, direct_neighbor, recommended_by_origins }` + `refreshed_at`) |
 | Server | `ServerService` | `GetMotd`, `GetRuntimeConfig` |
 | Viewer | `ViewerService` | `GetViewer` |
 | My account | `MyAccountService` | `UpdateProfile`, `UpdateSettings`, `SetPresence` (legacy live report), `GetPresencePreference`, `SetPresencePreference`, `RefreshPresence`, `SetCustomStatus`, `DeleteCustomStatus`, `ChangePassword`, `DeleteMyAccount` (avatar upload was removed in 0.5.0b4) |
@@ -96,7 +96,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` |
-| Threads | `ThreadService` | `FollowThread`, `UnfollowThread`, `ListFollowedThreads`, `GetThreadEvents`, `GetThreadEventsAround`, `MarkThreadAsRead` |
+| Threads | `ThreadService` | `FollowThread`, `UnfollowThread`, `ListFollowedThreads` (`unread_only` filters to threads with unread replies, since 0.5.0b8), `GetThreadEvents`, `GetThreadEventsAround`, `MarkThreadAsRead` |
 | Notifications | `NotificationService` | `ListNotifications`, `GetNotification`, `BatchGetNotifications`, `ListRoomNotifications`, `ListRoomNotificationCounts`, `HasNotifications`, `DismissNotification`, `DismissAllNotifications` |
 | Notification prefs | `NotificationPreferencesService` | `Get`/`Update` × `Server`/`Room` |
 | Notification policy | `NotificationPolicyService` | `GetNotificationPolicy`, `BatchGetNotificationPolicies`, `UpdateNotificationPolicy` — all scoped (server / room group / room) via `NotificationPolicyScope` |
@@ -136,6 +136,7 @@ to its grants.
 - Room groups can contain `SidebarLink` items (not just rooms). `RoomGroup.sidebar_links` exposes them.
 - **0.5.0b6 split presence into a *saved preference* and a *live report*.** `SetPresence` is now the legacy, transient live report (still rejects `OFFLINE`/`UNSPECIFIED`); a saved choice takes precedence over it, even a `user_selected` one. The new pair is `GetPresencePreference`/`SetPresencePreference` (choice for every device on the server; `OFFLINE` = "Appear Offline" and *is* allowed there; a stale `expected_revision` returns `ABORTED`) plus `RefreshPresence` for 30-second liveness. `Message` also gained `viewer_state` (`MessageViewerState.can_reply_in_thread`), resolved per viewer even before a thread exists.
 - **0.5.0b7 replaced the room ban triad with a *suspension* triad (breaking).** `BanMember`/`UnbanMember`/`ListBans` and the `RoomBan` message are gone; they are now `RemoveUser` (remove a member with a required reason; the optional `oneof suspension` is either `suspension_expires_at` or `suspend_indefinitely` — at most one, and Universal rooms require one; DM rooms are excluded), `LiftSuspension` (no-op success when no active suspension), and `ListSuspensions` (returns `repeated RoomSuspension suspensions`, page newest-first). The client methods are `remove_user(...)` / `lift_suspension(...)` / `list_suspensions(...)` and the dataclass is `RoomSuspension` (the ban's `expires_at` field name is kept). Both `RemoveUser` and `LiftSuspension` require `room.remove-member` at server scope.
+- **0.5.0b8 added the public `ServerDiscoveryService.ListNeighborhoodServers` RPC** (no session needed; the server caches background discovery, refreshed at least hourly). `NeighborhoodServer.profile` is a `ServerPublicProfile` **without** the welcome message, and its logo/banner URLs are server-relative paths of copies on the *called* server — resolve them against that server's origin, not the neighbor's. `ListFollowedThreadsRequest.unread_only` filters to followed threads with unread replies (older servers ignore the field and return everything; the page total then counts only the filtered set).
 - **Never regenerate protos from `chattocorp/chatto@main`.** `scripts/generate_pb.sh` defaults to a tag derived from `pyproject.toml`: `v<base>` for finals, and `v<base>-<kind>.<n>` for PEP 440 pre-releases (`0.5.0b6` → `v0.5.0-beta.6`), probing candidates with `git ls-remote` (exact ref match; the REST `/tags/{name}` endpoint 404s on lightweight tags). Override with `CHATTO_REF=<tag>` only when you know the deployed server has caught up. chattolib 0.4.19 shipped once from `main` and broke realtime for every downstream client because Chatto's main had a protocol-v2 rewrite that the deployed 0.4.19 server did not speak.
 
 ### Known bugs

+ 4 - 0
proto/chatto/api/v1/threads.proto

@@ -79,6 +79,10 @@ message ListFollowedThreadsRequest {
   // Include followed direct-message threads. Defaults to false so older
   // clients retain the channel-only result shape.
   bool include_direct_message_threads = 4;
+  // Return only threads with replies that the viewer has not read. The page
+  // total then counts only these threads. Older servers ignore this field and
+  // return all followed threads.
+  bool unread_only = 5;
 }
 
 // Response containing one followed-thread page.

+ 40 - 0
proto/chatto/discovery/v1/server.proto

@@ -3,6 +3,7 @@ syntax = "proto3";
 package chatto.discovery.v1;
 
 import "chatto/api/v1/server.proto";
+import "google/protobuf/timestamp.proto";
 
 // Request for public server metadata.
 message GetServerRequest {}
@@ -30,6 +31,36 @@ message ListNeighborsResponse {
   repeated string origins = 1;
 }
 
+// Request for the Neighborhood that this server has discovered.
+message ListNeighborhoodServersRequest {}
+
+// One server in the Neighborhood of the called server.
+message NeighborhoodServer {
+  // Canonical origin of the server.
+  string origin = 1;
+  // Public profile that the called server loaded. The welcome message is
+  // absent. The logo and banner URLs are server-relative paths of copies on
+  // the called server. Resolve them against the origin of the called server.
+  chatto.api.v1.ServerPublicProfile profile = 2;
+  // Whether the called server advertises this server as a Neighbor.
+  bool direct_neighbor = 3;
+  // Canonical origins of other servers in the same response that mutually
+  // recommend this server. The list does not include the called server.
+  repeated string recommended_by_origins = 4;
+}
+
+// Servers that the called server discovered through its Neighbors. The
+// called server lists each of its Neighbors whose public profile loaded. It
+// also lists servers that a mutually advertising Neighbor recommends when the
+// recommendation is also mutual. The response has no ordering contract.
+message ListNeighborhoodServersResponse {
+  // Servers in the Neighborhood.
+  repeated NeighborhoodServer servers = 1;
+  // Time when the called server last completed Neighborhood discovery.
+  // Absent before the first completed discovery.
+  google.protobuf.Timestamp refreshed_at = 2;
+}
+
 // Provides unauthenticated server discovery metadata.
 service ServerDiscoveryService {
   // Returns public server metadata, branding, registration status, and login
@@ -42,4 +73,13 @@ service ServerDiscoveryService {
   rpc ListNeighbors(ListNeighborsRequest) returns (ListNeighborsResponse) {
     option idempotency_level = NO_SIDE_EFFECTS;
   }
+  // Returns the Neighborhood without requiring a session. The server
+  // contacts other servers in the background and caches the result. A call
+  // does not contact other servers, so a client can show the Neighborhood
+  // without revealing the user's network address to those servers. The
+  // server refreshes the Neighborhood at least once per hour and soon after
+  // its Neighbors change.
+  rpc ListNeighborhoodServers(ListNeighborhoodServersRequest) returns (ListNeighborhoodServersResponse) {
+    option idempotency_level = NO_SIDE_EFFECTS;
+  }
 }

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "chattolib"
-version = "0.5.0b7"
+version = "0.5.0b8"
 description = "Async Python client library for the Chatto webchat Connect API"
 requires-python = ">=3.11"
 license = "MPL-2.0 AND Apache-2.0"

+ 4 - 0
src/chattolib/__init__.py

@@ -38,6 +38,8 @@ from chattolib.types import (
     MessageReaction,
     MessageViewerState,
     Neighbor,
+    NeighborhoodServer,
+    NeighborhoodServersPage,
     NotificationLevel,
     NotificationMessageReference,
     NotificationOccurrence,
@@ -113,6 +115,8 @@ __all__ = [
     "MessageReaction",
     "MessageViewerState",
     "Neighbor",
+    "NeighborhoodServer",
+    "NeighborhoodServersPage",
     "NotificationLevel",
     "NotificationMessageReference",
     "NotificationOccurrence",

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
src/chattolib/_pb/chatto/api/v1/threads_pb2.py


+ 24 - 0
src/chattolib/_pb/chatto/discovery/v1/server_connect.py

@@ -30,6 +30,18 @@ class ServerDiscoveryServiceClient(ConnectClient):
             headers=headers,
         )
 
+    async def list_neighborhood_servers(self, request: chatto_discovery_v1_server_pb2.ListNeighborhoodServersRequest, *, headers: dict[str, str] | None = None) -> chatto_discovery_v1_server_pb2.ListNeighborhoodServersResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="ListNeighborhoodServers",
+                service_name="chatto.discovery.v1.ServerDiscoveryService",
+                input=chatto_discovery_v1_server_pb2.ListNeighborhoodServersRequest,
+                output=chatto_discovery_v1_server_pb2.ListNeighborhoodServersResponse,
+            ),
+            headers=headers,
+        )
+
 class ServerDiscoveryServiceClientSync(ConnectClientSync):
 
     def get_server(self, request: chatto_discovery_v1_server_pb2.GetServerRequest, *, headers: dict[str, str] | None = None) -> chatto_discovery_v1_server_pb2.GetServerResponse:
@@ -55,3 +67,15 @@ class ServerDiscoveryServiceClientSync(ConnectClientSync):
             ),
             headers=headers,
         )
+
+    def list_neighborhood_servers(self, request: chatto_discovery_v1_server_pb2.ListNeighborhoodServersRequest, *, headers: dict[str, str] | None = None) -> chatto_discovery_v1_server_pb2.ListNeighborhoodServersResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="ListNeighborhoodServers",
+                service_name="chatto.discovery.v1.ServerDiscoveryService",
+                input=chatto_discovery_v1_server_pb2.ListNeighborhoodServersRequest,
+                output=chatto_discovery_v1_server_pb2.ListNeighborhoodServersResponse,
+            ),
+            headers=headers,
+        )

+ 20 - 11
src/chattolib/_pb/chatto/discovery/v1/server_pb2.py

@@ -23,9 +23,10 @@ _sym_db = _symbol_database.Default()
 
 
 from chatto.api.v1 import server_pb2 as chatto_dot_api_dot_v1_dot_server__pb2
+from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n chatto/discovery/v1/server.proto\x12\x13\x63hatto.discovery.v1\x1a\x1a\x63hatto/api/v1/server.proto\"\x12\n\x10GetServerRequest\"\x8b\x01\n\x11GetServerResponse\x12\x33\n\x07profile\x18\x01 \x01(\x0b\x32\".chatto.api.v1.ServerPublicProfile\x12)\n\x05login\x18\x02 \x01(\x0b\x32\x1a.chatto.api.v1.ServerLogin\x12\x16\n\x0esetup_required\x18\x03 \x01(\x08\"\x16\n\x14ListNeighborsRequest\"9\n\x15ListNeighborsResponse\x12\x0f\n\x07origins\x18\x01 \x03(\tJ\x04\x08\x02\x10\x03R\tneighbors2\xe6\x01\n\x16ServerDiscoveryService\x12_\n\tGetServer\x12%.chatto.discovery.v1.GetServerRequest\x1a&.chatto.discovery.v1.GetServerResponse\"\x03\x90\x02\x01\x12k\n\rListNeighbors\x12).chatto.discovery.v1.ListNeighborsRequest\x1a*.chatto.discovery.v1.ListNeighborsResponse\"\x03\x90\x02\x01\x62\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n chatto/discovery/v1/server.proto\x12\x13\x63hatto.discovery.v1\x1a\x1a\x63hatto/api/v1/server.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x12\n\x10GetServerRequest\"\x8b\x01\n\x11GetServerResponse\x12\x33\n\x07profile\x18\x01 \x01(\x0b\x32\".chatto.api.v1.ServerPublicProfile\x12)\n\x05login\x18\x02 \x01(\x0b\x32\x1a.chatto.api.v1.ServerLogin\x12\x16\n\x0esetup_required\x18\x03 \x01(\x08\"\x16\n\x14ListNeighborsRequest\"9\n\x15ListNeighborsResponse\x12\x0f\n\x07origins\x18\x01 \x03(\tJ\x04\x08\x02\x10\x03R\tneighbors\" \n\x1eListNeighborhoodServersRequest\"\x92\x01\n\x12NeighborhoodServer\x12\x0e\n\x06origin\x18\x01 \x01(\t\x12\x33\n\x07profile\x18\x02 \x01(\x0b\x32\".chatto.api.v1.ServerPublicProfile\x12\x17\n\x0f\x64irect_neighbor\x18\x03 \x01(\x08\x12\x1e\n\x16recommended_by_origins\x18\x04 \x03(\t\"\x8d\x01\n\x1fListNeighborhoodServersResponse\x12\x38\n\x07servers\x18\x01 \x03(\x0b\x32\'.chatto.discovery.v1.NeighborhoodServer\x12\x30\n\x0crefreshed_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp2\xf2\x02\n\x16ServerDiscoveryService\x12_\n\tGetServer\x12%.chatto.discovery.v1.GetServerRequest\x1a&.chatto.discovery.v1.GetServerResponse\"\x03\x90\x02\x01\x12k\n\rListNeighbors\x12).chatto.discovery.v1.ListNeighborsRequest\x1a*.chatto.discovery.v1.ListNeighborsResponse\"\x03\x90\x02\x01\x12\x89\x01\n\x17ListNeighborhoodServers\x12\x33.chatto.discovery.v1.ListNeighborhoodServersRequest\x1a\x34.chatto.discovery.v1.ListNeighborhoodServersResponse\"\x03\x90\x02\x01\x62\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -36,14 +37,22 @@ if not _descriptor._USE_C_DESCRIPTORS:
   _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['GetServer']._serialized_options = b'\220\002\001'
   _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['ListNeighbors']._loaded_options = None
   _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['ListNeighbors']._serialized_options = b'\220\002\001'
-  _globals['_GETSERVERREQUEST']._serialized_start=85
-  _globals['_GETSERVERREQUEST']._serialized_end=103
-  _globals['_GETSERVERRESPONSE']._serialized_start=106
-  _globals['_GETSERVERRESPONSE']._serialized_end=245
-  _globals['_LISTNEIGHBORSREQUEST']._serialized_start=247
-  _globals['_LISTNEIGHBORSREQUEST']._serialized_end=269
-  _globals['_LISTNEIGHBORSRESPONSE']._serialized_start=271
-  _globals['_LISTNEIGHBORSRESPONSE']._serialized_end=328
-  _globals['_SERVERDISCOVERYSERVICE']._serialized_start=331
-  _globals['_SERVERDISCOVERYSERVICE']._serialized_end=561
+  _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['ListNeighborhoodServers']._loaded_options = None
+  _globals['_SERVERDISCOVERYSERVICE'].methods_by_name['ListNeighborhoodServers']._serialized_options = b'\220\002\001'
+  _globals['_GETSERVERREQUEST']._serialized_start=118
+  _globals['_GETSERVERREQUEST']._serialized_end=136
+  _globals['_GETSERVERRESPONSE']._serialized_start=139
+  _globals['_GETSERVERRESPONSE']._serialized_end=278
+  _globals['_LISTNEIGHBORSREQUEST']._serialized_start=280
+  _globals['_LISTNEIGHBORSREQUEST']._serialized_end=302
+  _globals['_LISTNEIGHBORSRESPONSE']._serialized_start=304
+  _globals['_LISTNEIGHBORSRESPONSE']._serialized_end=361
+  _globals['_LISTNEIGHBORHOODSERVERSREQUEST']._serialized_start=363
+  _globals['_LISTNEIGHBORHOODSERVERSREQUEST']._serialized_end=395
+  _globals['_NEIGHBORHOODSERVER']._serialized_start=398
+  _globals['_NEIGHBORHOODSERVER']._serialized_end=544
+  _globals['_LISTNEIGHBORHOODSERVERSRESPONSE']._serialized_start=547
+  _globals['_LISTNEIGHBORHOODSERVERSRESPONSE']._serialized_end=688
+  _globals['_SERVERDISCOVERYSERVICE']._serialized_start=691
+  _globals['_SERVERDISCOVERYSERVICE']._serialized_end=1061
 # @@protoc_insertion_point(module_scope)

+ 34 - 1
src/chattolib/client.py

@@ -88,6 +88,7 @@ from chattolib.types import (
     LinkPreview,
     Message,
     Neighbor,
+    NeighborhoodServersPage,
     NotificationLevel,
     NotificationOccurrence,
     NotificationOccurrencesPage,
@@ -276,6 +277,23 @@ class ChattoClient:
         )
         return list(resp.origins)
 
+    async def list_neighborhood_servers(self) -> NeighborhoodServersPage:
+        """Servers in the Neighborhood the called server has discovered.
+
+        Does not require auth. The server loads other servers' public profiles
+        in the background and caches the result (refreshed at least hourly),
+        so the call itself contacts no other server. The response has no
+        ordering contract; ``refreshed_at`` is absent before the server's
+        first completed discovery.
+        """
+        resp = await self._rpc(
+            self._svc.server_discovery.list_neighborhood_servers(
+                discovery_server_pb2.ListNeighborhoodServersRequest(),
+                headers=self._headers(),
+            )
+        )
+        return NeighborhoodServersPage.parse(pb_to_dict(resp))
+
     async def get_motd(self) -> str | None:
         resp = await self._rpc(
             self._svc.server.get_motd(server_state_pb2.GetMotdRequest(), headers=self._headers())
@@ -1055,12 +1073,27 @@ class ChattoClient:
         )
 
     async def list_followed_threads(
-        self, *, limit: int | None = None, offset: int | None = None
+        self,
+        *,
+        limit: int | None = None,
+        offset: int | None = None,
+        include_direct_message_threads: bool | None = None,
+        unread_only: bool | None = None,
     ) -> FollowedThreadsPage:
+        """Followed threads, newest activity first.
+
+        ``unread_only`` returns only threads with unread replies (older
+        servers ignore it); ``include_direct_message_threads`` also lists
+        followed direct-message threads.
+        """
         req = threads_pb2.ListFollowedThreadsRequest()
         page = _page_pb(limit, offset)
         if page is not None:
             req.page.CopyFrom(page)
+        if include_direct_message_threads is not None:
+            req.include_direct_message_threads = include_direct_message_threads
+        if unread_only is not None:
+            req.unread_only = unread_only
         resp = await self._rpc(
             self._svc.threads.list_followed_threads(req, headers=self._headers())
         )

+ 52 - 0
src/chattolib/types.py

@@ -336,6 +336,58 @@ class ProviderMetadata:
         )
 
 
+@dataclass
+class NeighborhoodServer:
+    """One server in the Neighborhood discovered by the called server.
+
+    ``profile`` is the public profile the called server loaded (no welcome
+    message); its ``logoUrl``/``bannerUrl`` are server-relative paths of
+    copies on the called server — resolve them against that server's origin.
+    """
+
+    origin: str
+    profile: ServerProfile | None = None
+    direct_neighbor: bool = False
+    recommended_by_origins: list[str] = field(default_factory=list)
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> NeighborhoodServer | None:
+        if not data:
+            return None
+        return cls(
+            origin=data.get("origin", ""),
+            profile=ServerProfile.parse(data.get("profile")),
+            direct_neighbor=bool(data.get("directNeighbor", False)),
+            recommended_by_origins=list(data.get("recommendedByOrigins") or []),
+        )
+
+
+@dataclass
+class NeighborhoodServersPage:
+    """The Neighborhood of the called server, as it last discovered it.
+
+    The called server lists each Neighbor whose public profile loaded, plus
+    servers that a mutually advertising Neighbor recommends when the
+    recommendation is also mutual. No ordering contract. ``refreshed_at`` is
+    absent before the server's first completed discovery.
+    """
+
+    servers: list[NeighborhoodServer] = field(default_factory=list)
+    refreshed_at: datetime | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> NeighborhoodServersPage:
+        data = data or {}
+        return cls(
+            servers=[
+                s
+                for s in (NeighborhoodServer.parse(s) for s in data.get("servers") or [])
+                if s is not None
+            ],
+            refreshed_at=parse_datetime(data.get("refreshedAt")),
+        )
+
+
 @dataclass
 class ServerLogin:
     direct_registration_enabled: bool = False

+ 47 - 1
tests/test_client.py

@@ -84,6 +84,39 @@ async def test_get_server(client):
     assert login.authorize_url == "/oauth"
 
 
+async def test_list_neighborhood_servers(client):
+    resp = discovery_server_pb2.ListNeighborhoodServersResponse()
+    s1 = resp.servers.add()
+    s1.origin = "https://neighbor.example"
+    s1.profile.name = "Neighbor"
+    s1.profile.version = "0.5.0"
+    s1.profile.logo_url = "/logos/neighbor.png"
+    s1.direct_neighbor = True
+    s2 = resp.servers.add()
+    s2.origin = "https://referred.example"
+    s2.profile.name = "Referred"
+    s2.direct_neighbor = False
+    s2.recommended_by_origins.append("https://neighbor.example")
+    resp.refreshed_at.FromJsonString("2026-09-26T12:00:00Z")
+    mocked = _mock_method(client, "server_discovery", "list_neighborhood_servers", resp)
+
+    async with client:
+        page = await client.list_neighborhood_servers()
+
+    mocked.assert_awaited_once()
+    assert [s.origin for s in page.servers] == [
+        "https://neighbor.example",
+        "https://referred.example",
+    ]
+    assert page.servers[0].profile is not None
+    assert page.servers[0].profile.name == "Neighbor"
+    assert page.servers[0].profile.logo_url == "/logos/neighbor.png"
+    assert page.servers[0].direct_neighbor is True
+    assert page.servers[1].direct_neighbor is False
+    assert page.servers[1].recommended_by_origins == ["https://neighbor.example"]
+    assert page.refreshed_at == dt.datetime(2026, 9, 26, 12, 0, tzinfo=dt.UTC)
+
+
 # --- Viewer -------------------------------------------------------------
 
 
@@ -481,15 +514,28 @@ async def test_list_followed_threads(client):
     ft.thread.viewer_state.has_unread_replies = False
     resp.page.total_count = 1
     resp.page.has_more = False
-    _mock_method(client, "threads", "list_followed_threads", resp)
+    mocked = _mock_method(client, "threads", "list_followed_threads", resp)
 
     async with client:
         page = await client.list_followed_threads()
+        await client.list_followed_threads(
+            limit=5,
+            offset=5,
+            include_direct_message_threads=True,
+            unread_only=True,
+        )
 
     assert page.page.total_count == 1
     assert page.threads[0].thread is not None
     assert page.threads[0].thread.reply_count == 3
     assert page.threads[0].thread.is_following is True
+    calls = [c.args[0] for c in mocked.call_args_list]
+    assert calls[0].include_direct_message_threads is False
+    assert calls[0].unread_only is False
+    assert calls[1].include_direct_message_threads is True
+    assert calls[1].unread_only is True
+    assert calls[1].page.limit == 5
+    assert calls[1].page.offset == 5
 
 
 async def test_follow_and_unfollow_thread(client):

Някои файлове не бяха показани, защото твърде много файлове са промени