Sfoglia il codice sorgente

Track Chatto 0.5.0-beta.6

Regenerated all proto bindings from the v0.5.0-beta.6 tag. The wire delta
over 0.5.0b4 is small: MyAccountService splits presence into a saved private
choice and the legacy transient live report - new GetPresencePreference /
SetPresencePreference (a stored OFFLINE means 'Appear Offline' and is allowed;
SetPresence still rejects OFFLINE/UNSPECIFIED; a stale expected_revision
returns ABORTED) and RefreshPresence for 30-second liveness. Message gains
viewer_state (MessageViewerState.can_reply_in_thread), the viewer-resolved
capability for interaction-scoped posting that the beta's breaking
permissions change introduces; it is present even before a durable thread
exists. The realtime event oneof grows to 48 members with the new
viewer_presence_preference_changed hint (empty payload, delivered only to the
account whose own choice changed).

Client adaptations: get/set_presence_preference and refresh_presence on
ChattoClient, PresencePreference and MessageViewerState dataclasses in
types.py and the public API, and Bot dispatches the new realtime event as a
presence_preference event and mirrors the three preference verbs. The protoc
generator's tag pinning now understands PEP 440 pre-releases (0.5.0b6 ->
v0.5.0-beta.6) via exact git ls-remote probes.

Verification: ruff + mypy clean (no new errors vs the b4 baseline), 86 unit
tests passing (8 new covering the preference RPCs, the new event and its bot
dispatch), live GetServer round-trip and live bot integration tests passing
against Chatto HQ.

Bumped version 0.5.0b4 -> 0.5.0b6.
the-codemancer 1 mese fa
parent
commit
e52ae34162
58 ha cambiato i file con 589 aggiunte e 132 eliminazioni
  1. 5 4
      AGENTS.md
  2. 20 11
      proto/buf/validate/validate.proto
  3. 14 4
      proto/chatto/api/v1/account.proto
  4. 12 0
      proto/chatto/api/v1/message_types.proto
  5. 5 3
      proto/chatto/api/v1/messages.proto
  6. 48 12
      proto/chatto/api/v1/presence.proto
  7. 4 0
      proto/chatto/realtime/v1/events.proto
  8. 1 0
      proto/chatto/realtime/v1/realtime.proto
  9. 1 1
      pyproject.toml
  10. 39 9
      scripts/generate_pb.sh
  11. 4 0
      src/chattolib/__init__.py
  12. 2 2
      src/chattolib/_pb/buf/validate/validate_pb2.py
  13. 2 2
      src/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py
  14. 2 2
      src/chattolib/_pb/chatto/admin/v1/event_log_pb2.py
  15. 2 2
      src/chattolib/_pb/chatto/admin/v1/members_pb2.py
  16. 2 2
      src/chattolib/_pb/chatto/admin/v1/permissions_pb2.py
  17. 2 2
      src/chattolib/_pb/chatto/admin/v1/roles_pb2.py
  18. 2 2
      src/chattolib/_pb/chatto/admin/v1/room_layout_pb2.py
  19. 2 2
      src/chattolib/_pb/chatto/admin/v1/server_pb2.py
  20. 72 0
      src/chattolib/_pb/chatto/api/v1/account_connect.py
  21. 2 2
      src/chattolib/_pb/chatto/api/v1/account_pb2.py
  22. 2 2
      src/chattolib/_pb/chatto/api/v1/asset_uploads_pb2.py
  23. 2 2
      src/chattolib/_pb/chatto/api/v1/attachments_pb2.py
  24. 2 2
      src/chattolib/_pb/chatto/api/v1/common_pb2.py
  25. 2 2
      src/chattolib/_pb/chatto/api/v1/external_identities_pb2.py
  26. 2 2
      src/chattolib/_pb/chatto/api/v1/link_previews_pb2.py
  27. 2 2
      src/chattolib/_pb/chatto/api/v1/member_directory_pb2.py
  28. 2 2
      src/chattolib/_pb/chatto/api/v1/message_types_pb2.py
  29. 2 2
      src/chattolib/_pb/chatto/api/v1/messages_pb2.py
  30. 2 2
      src/chattolib/_pb/chatto/api/v1/notification_preferences_pb2.py
  31. 2 2
      src/chattolib/_pb/chatto/api/v1/notifications_pb2.py
  32. 2 2
      src/chattolib/_pb/chatto/api/v1/pagination_pb2.py
  33. 2 2
      src/chattolib/_pb/chatto/api/v1/permissions_pb2.py
  34. 25 9
      src/chattolib/_pb/chatto/api/v1/presence_pb2.py
  35. 2 2
      src/chattolib/_pb/chatto/api/v1/push_notifications_pb2.py
  36. 2 2
      src/chattolib/_pb/chatto/api/v1/reactions_pb2.py
  37. 2 2
      src/chattolib/_pb/chatto/api/v1/read_state_pb2.py
  38. 2 2
      src/chattolib/_pb/chatto/api/v1/roles_pb2.py
  39. 2 2
      src/chattolib/_pb/chatto/api/v1/room_directory_pb2.py
  40. 2 2
      src/chattolib/_pb/chatto/api/v1/room_timeline_pb2.py
  41. 2 2
      src/chattolib/_pb/chatto/api/v1/rooms_pb2.py
  42. 2 2
      src/chattolib/_pb/chatto/api/v1/server_pb2.py
  43. 2 2
      src/chattolib/_pb/chatto/api/v1/server_state_pb2.py
  44. 2 2
      src/chattolib/_pb/chatto/api/v1/threads_pb2.py
  45. 2 2
      src/chattolib/_pb/chatto/api/v1/user_status_pb2.py
  46. 2 2
      src/chattolib/_pb/chatto/api/v1/users_pb2.py
  47. 2 2
      src/chattolib/_pb/chatto/api/v1/viewer_pb2.py
  48. 2 2
      src/chattolib/_pb/chatto/api/v1/voice_calls_pb2.py
  49. 2 2
      src/chattolib/_pb/chatto/auth/v1/external_identity_auth_pb2.py
  50. 2 2
      src/chattolib/_pb/chatto/discovery/v1/server_pb2.py
  51. 2 2
      src/chattolib/_pb/chatto/realtime/v1/events_pb2.py
  52. 2 2
      src/chattolib/_pb/chatto/realtime/v1/realtime_pb2.py
  53. 39 1
      src/chattolib/bot.py
  54. 65 0
      src/chattolib/client.py
  55. 45 0
      src/chattolib/types.py
  56. 21 0
      tests/test_bot.py
  57. 74 0
      tests/test_client.py
  58. 17 0
      tests/test_realtime.py

+ 5 - 4
AGENTS.md

@@ -91,7 +91,7 @@ to its grants.
 | Discovery | `chatto.discovery.v1.ServerDiscoveryService` | `GetServer` (public) |
 | Server | `ServerService` | `GetMotd`, `GetRuntimeConfig` |
 | Viewer | `ViewerService` | `GetViewer` |
-| My account | `MyAccountService` | `UpdateProfile`, `UpdateSettings`, `SetPresence`, `SetCustomStatus`, `DeleteCustomStatus`, `ChangePassword`, `DeleteMyAccount` (avatar upload was removed in 0.5.0b4) |
+| My account | `MyAccountService` | `UpdateProfile`, `UpdateSettings`, `SetPresence` (legacy live report), `GetPresencePreference`, `SetPresencePreference`, `RefreshPresence`, `SetCustomStatus`, `DeleteCustomStatus`, `ChangePassword`, `DeleteMyAccount` (avatar upload was removed in 0.5.0b4) |
 | Roles | `RoleService` | `ListRoles`, `GetRole`, `BatchGetRoles` |
 | Room directory | `RoomDirectoryService` | `ListRooms`, `ListRoomGroups`, `GetRoomGroup`, `BatchGetRoomGroups`, `GetRoom`, `BatchGetRooms` |
 | Rooms | `RoomService` | `CreateRoom`, `UpdateRoom`, `ArchiveRoom`, `UnarchiveRoom`, `JoinRoom`, `JoinRoomGroup`, `StartDM`, `LeaveRoom`, `AddMember`, `RemoveMember`, `ListMembers`, `GetMember`, `BatchGetMembers`, `BanMember`, `UnbanMember`, `ListBans`, `RefreshTypingIndicator`, `GetRoomEvents`, `GetRoomEventsAround`, `MarkRoomAsRead`, `ListRoomAttachments` |
@@ -112,7 +112,7 @@ to its grants.
 | Admin: diagnostics | `chatto.admin.v1.AdminDiagnosticsService` | `GetSystemInfo` (raw response) |
 | Admin: permissions | `chatto.admin.v1.AdminPermissionService` | `GetRole/UserPermissionMatrix`, `ListRole/UserPermissionDecisions`, `ExplainPermissions`, `SetRolePermission`, `SetUserPermission` (raw responses where the permission shape is server-version-dependent) |
 | Voice calls | `VoiceCallService` | `ListActiveCalls`, `GetActiveCall`, `BatchGetActiveCalls`, `JoinCall`, `LeaveCall`, `CreateCallToken` |
-| Realtime (WS) | `chatto.realtime.v1` protobuf WS (protocol v4) | `stream_events(client)` / `RealtimeConnection` — the client sends one `RealtimeSubscribe` handshake; the server streams `event` (a 48-member oneof of thin, caller-scoped hints), `snapshot`, `caught_up`, `heartbeat`, and `close` (with a `RealtimeCloseCode` and `retry_after`) |
+| Realtime (WS) | `chatto.realtime.v1` protobuf WS (protocol v4) | `stream_events(client)` / `RealtimeConnection` — the client sends one `RealtimeSubscribe` handshake; the server streams `event` (a 48-member oneof of thin, caller-scoped hints, incl. `viewer_presence_preference_changed` added in 0.5.0b6), `snapshot`, `caught_up`, `heartbeat`, and `close` (with a `RealtimeCloseCode` and `retry_after`) |
 
 ### Naming conventions
 
@@ -134,9 +134,10 @@ to its grants.
 - Server profile fields are no longer inside `server.profile`; the shape is now `ServerPublicProfile` returned by `ServerDiscoveryService.GetServer`.
 - No more `motd` on the public profile; it is a separate authenticated RPC (`ServerService.GetMotd`).
 - Room groups can contain `SidebarLink` items (not just rooms). `RoomGroup.sidebar_links` exposes them.
-- **Never regenerate protos from `chattocorp/chatto@main`.** `scripts/generate_pb.sh` now defaults to `v<version>` (from `pyproject.toml`); 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.
+- **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.
+- **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
 
-- **Realtime is now protocol v4 — there are no projection frames.** The v2/v3 "projection" frames (`RealtimeProjectionEvent` / per-op `RealtimeProjectionOperation`) no longer exist. Instead the client sends a single `RealtimeSubscribe` handshake and the server streams `event` frames (a 48-member `oneof` of *thin, caller-scoped hints* — identifiers, not full resources), plus `snapshot` (one initial state), `caught_up` (recovery-to-live boundary), `heartbeat`, and `close` (with a `RealtimeCloseCode` and a `retry_after` duration). Live events carry IDs, so clients hydrate the referenced resource via the matching ConnectRPC (e.g. `get_message`) using the event's `cursor`. `Bot._handle_live` re-derives `BotMessageEvent` by hydrating the posted message.
+- **Realtime is now protocol v4 — there are no projection frames.** The v2/v3 "projection" frames (`RealtimeProjectionEvent` / per-op `RealtimeProjectionOperation`) no longer exist. Instead the client sends a single `RealtimeSubscribe` handshake and the server streams `event` frames (a 48-member `oneof` of *thin, caller-scoped hints* — identifiers, not full resources), plus `snapshot` (one initial state), `caught_up` (recovery-to-live boundary), `heartbeat`, and `close` (with a `RealtimeCloseCode` and `retry_after`). Live events carry IDs, so clients hydrate the referenced resource via the matching ConnectRPC (e.g. `get_message`) using the event's `cursor`. `Bot._handle_live` re-derives `BotMessageEvent` by hydrating the posted message. Since 0.5.0b6 the oneof has a 48th member, `viewer_presence_preference_changed` (an empty payload delivered only to the account whose own presence *preference* changed; `Bot` dispatches it as `kind="presence_preference"` and the new choice is read back via `get_presence_preference`).
 - `RealtimeClose.code` is the enum `RealtimeCloseCode` (e.g. `SESSION_TERMINATED`, `RESYNC_REQUIRED`, `UNSUPPORTED_PROTOCOL`); `chattolib.realtime` maps it to its short name for `ChattoRealtimeCloseError.code`. A non-`reconnect` close (e.g. `SESSION_TERMINATED`) means the bot should stop, not reconnect.

+ 20 - 11
proto/buf/validate/validate.proto

@@ -3476,9 +3476,13 @@ message StringRules {
       }
     ];
 
-    // `uuid` specifies that the field value must be a valid UUID as defined by
-    // [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). If the
-    // field value isn't a valid UUID, an error message will be generated.
+    // `uuid` specifies that the field value must have the textual shape of a
+    // UUID as defined by
+    // [RFC 9562](https://datatracker.ietf.org/doc/html/rfc9562#section-4): 32
+    // hexadecimal digits in five dash-separated groups. Only the shape is
+    // enforced, so that UUID versions defined in the future are not rejected:
+    // the version and variant fields are not checked. If the field value isn't
+    // shaped like a UUID, an error message will be generated.
     //
     // ```proto
     // message MyString {
@@ -3499,10 +3503,13 @@ message StringRules {
       }
     ];
 
-    // `tuuid` (trimmed UUID) specifies that the field value must be a valid UUID as
-    // defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2) with all dashes
-    // omitted. If the field value isn't a valid UUID without dashes, an error message
-    // will be generated.
+    // `tuuid` (trimmed UUID) specifies that the field value must have the
+    // textual shape of a UUID as defined by
+    // [RFC 9562](https://datatracker.ietf.org/doc/html/rfc9562#section-4) with all
+    // dashes omitted: 32 hexadecimal digits. Only the shape is enforced, so
+    // that UUID versions defined in the future are not rejected: the version
+    // and variant fields are not checked. If the field value isn't shaped like
+    // a UUID without dashes, an error message will be generated.
     //
     // ```proto
     // message MyString {
@@ -4117,10 +4124,12 @@ message BytesRules {
       }
     ];
 
-    // `uuid` ensures that the field value encodes 128-bit UUID data as defined
-    // by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2).
-    // The field must contain exactly 16 bytes representing the UUID. If the
-    // field value isn't a valid UUID, an error message will be generated.
+    // `uuid` ensures that the field `value` is exactly 16 bytes long,
+    // the size of a UUID in binary form as defined by
+    // [RFC 9562](https://datatracker.ietf.org/doc/html/rfc9562#section-4).
+    // Only the length is enforced: the version and variant fields
+    // are not checked, and any 16 bytes are accepted.
+    // If the field value doesn't meet this rule, an error message is generated.
     //
     // ```proto
     // message MyBytes {

+ 14 - 4
proto/chatto/api/v1/account.proto

@@ -221,11 +221,21 @@ service MyAccountService {
   rpc StartExternalIdentityLink(StartExternalIdentityLinkRequest) returns (StartExternalIdentityLinkResponse);
   // Disconnects a provider identity from the authenticated account.
   rpc DisconnectExternalIdentity(DisconnectExternalIdentityRequest) returns (DisconnectExternalIdentityResponse);
-  // Updates the current user's live presence status. This state is transient:
-  // refresh every 30 seconds while visible. The server expires presence after
-  // 60 seconds without a refresh. Stop calling this RPC to appear offline;
-  // another client for the same account can keep presence active.
+  // Legacy live presence report, expiring after 60 seconds without refresh.
+  // A saved private choice overrides this report, including user_selected.
+  // New clients use SetPresencePreference for choices and RefreshPresence
+  // every 30 seconds for liveness.
   rpc SetPresence(SetPresenceRequest) returns (SetPresenceResponse);
+
+  // Reads the authenticated account's private saved availability choice.
+  rpc GetPresencePreference(GetPresencePreferenceRequest) returns (GetPresencePreferenceResponse);
+  // Saves a choice for all devices on this server. A stale revision returns ABORTED.
+  // OFFLINE suppresses public presence and typing; the choice survives disconnects.
+  rpc SetPresencePreference(SetPresencePreferenceRequest) returns (SetPresencePreferenceResponse);
+  // Refreshes connection liveness without changing the saved choice. Clients call
+  // every 30 seconds; liveness expires after 60 seconds without a refresh.
+  // Accounts with a saved OFFLINE choice may refresh without public presence signals.
+  rpc RefreshPresence(RefreshPresenceRequest) returns (RefreshPresenceResponse);
   // Sets the current user's complete custom status. Emoji and text are required.
   // Omit expires_at for no expiry, or supply a future time.
   rpc SetCustomStatus(SetCustomStatusRequest) returns (SetCustomStatusResponse);

+ 12 - 0
proto/chatto/api/v1/message_types.proto

@@ -147,6 +147,15 @@ message ThreadSummary {
   ThreadViewerState viewer_state = 6;
 }
 
+// Viewer-specific capabilities for one message, independent of whether its
+// canonical thread has been established.
+message MessageViewerState {
+  // Whether the viewer can reply in this message's canonical thread, including
+  // starting the first reply to a root. Includes read access, room policy, and
+  // broad or interaction-scoped posting authority. Absent when not resolved.
+  optional bool can_reply_in_thread = 1;
+}
+
 // Renderable message data.
 //
 // The same shape is used for top-level room messages, thread replies, and
@@ -196,4 +205,7 @@ message Message {
   // True when this message's canonical message is currently pinned in its
   // channel room. Always false for direct-message rooms.
   bool pinned = 22;
+  // State resolved for the current viewer. Available even before a durable
+  // thread exists. Absent when viewer state has not been resolved.
+  MessageViewerState viewer_state = 23;
 }

+ 5 - 3
proto/chatto/api/v1/messages.proto

@@ -63,7 +63,7 @@ message CreateMessageRequest {
   // server resolves the token to cached, server-fetched metadata during post.
   string link_preview_token = 10;
   // True to establish the new root message as a thread immediately. Requires
-  // both message.post and message.post-in-thread. Cannot be combined with
+  // message.post (which includes thread posting). Cannot be combined with
   // thread_root_event_id.
   bool create_thread = 11;
   // Optional descriptions for assets in attachment_asset_ids. Asset IDs must
@@ -255,8 +255,10 @@ service MessageService {
   // Successful responses include a short-lived token accepted by CreateMessage.
   rpc FetchLinkPreview(FetchLinkPreviewRequest) returns (FetchLinkPreviewResponse);
   // Creates a message for the current user. The user must be a room member and
-  // must have message.post for room messages or message.post-in-thread for
-  // thread replies. Echoing a thread reply also requires message.echo and
+  // must have message.post for room messages. Thread replies require read
+  // access and either message.post, message.post-in-thread, or
+  // message.post-in-interactions with an existing interaction relationship.
+  // Echoing a thread reply also requires message.echo and
   // message.post. The room's Threading Mode may automatically establish a root
   // thread or reject a thread placement that the mode does not allow.
   rpc CreateMessage(CreateMessageRequest) returns (CreateMessageResponse);

+ 48 - 12
proto/chatto/api/v1/presence.proto

@@ -4,34 +4,70 @@ package chatto.api.v1;
 
 import "buf/validate/validate.proto";
 
-// Live presence status returned by public read APIs.
+// Saved choice visible only to the authenticated account itself.
+message PresencePreference {
+  // Saved choice, independent of liveness. OFFLINE means "Appear Offline";
+  // DO_NOT_DISTURB suppresses alerts even while disconnected.
+  PresenceStatus status = 1;
+  // Opaque revision required when replacing this choice.
+  string revision = 2;
+}
+
+// Read the authenticated account's own choice; accepts no other user ID.
+message GetPresencePreferenceRequest {}
+// Current private choice, or absence before first initialization.
+message GetPresencePreferenceResponse {
+  // Absent until the account first saves a choice.
+  PresencePreference preference = 1;
+}
+
+// An explicit selection. Heartbeats must not call this operation.
+message SetPresencePreferenceRequest {
+  // Explicit new choice for every device on this server.
+  PresenceStatus status = 1 [(buf.validate.field).enum = {defined_only: true, not_in: [0]}];
+  // Revision from the last read; empty only when initializing an absent choice.
+  string expected_revision = 2;
+}
+// Acknowledged current choice after the selection commits.
+message SetPresencePreferenceResponse {
+  PresencePreference preference = 1;
+}
+
+// Refresh the authenticated account's liveness without supplying a choice.
+message RefreshPresenceRequest {}
+// Current private choice after refreshing liveness.
+message RefreshPresenceResponse {
+  // Private saved choice; clients use this to recover missed device updates.
+  PresencePreference preference = 1;
+}
+
+// Availability used for private choices and effective public presence.
 //
-// Offline is a read-side state only. Clients cannot update their presence to
-// Offline through the account presence RPC; they should stop refreshing and let
-// the server's live presence record expire.
+// Public Offline reveals neither connection liveness nor whether the account
+// selected "Appear Offline". A saved Online, Away, or Do Not Disturb choice
+// becomes public Offline when no device refreshes liveness.
 enum PresenceStatus {
   // No presence status was specified.
   PRESENCE_STATUS_UNSPECIFIED = 0;
-  // The user is actively available.
+  // Online while at least one device refreshes liveness.
   PRESENCE_STATUS_ONLINE = 1;
-  // The user is connected but away or idle.
+  // Away while at least one device refreshes liveness.
   PRESENCE_STATUS_AWAY = 2;
-  // The user does not want notifications while this live status is active.
+  // Do Not Disturb. A saved choice suppresses alerts even while disconnected.
   PRESENCE_STATUS_DO_NOT_DISTURB = 3;
-  // The user has no active live presence record.
+  // No public live presence. As a saved choice, suppresses presence and typing.
   PRESENCE_STATUS_OFFLINE = 4;
 }
 
-// Request to update the current user's live presence status.
+// Legacy live report. A saved private choice takes precedence over this report.
 message SetPresenceRequest {
   // Live status to store for the authenticated user. Offline is rejected.
   PresenceStatus status = 1 [(buf.validate.field).enum = {
     defined_only: true
     not_in: [0, 4]
   }];
-  // True when this update comes from a deliberate user selection rather than
-  // automatic idle/refresh updates. Automatic updates do not overwrite an
-  // active manually selected Away or Do Not Disturb status from another client.
+  // Legacy manual-selection flag. It cannot replace a saved private choice.
+  // New clients use SetPresencePreference for explicit selections.
   bool user_selected = 2;
 }
 

+ 4 - 0
proto/chatto/realtime/v1/events.proto

@@ -351,6 +351,10 @@ message PresenceChangedEvent {
   chatto.api.v1.PresenceStatus status = 1;
 }
 
+// Delivered only to the account whose private availability choice changed.
+// Refetch MyAccountService.GetPresencePreference to obtain the current choice.
+message ViewerPresencePreferenceChangedEvent {}
+
 // NotificationOccurrencesChangedEvent reports that the caller's current
 // notification occurrences changed and requests an authoritative resource read.
 message NotificationOccurrencesChangedEvent {

+ 1 - 0
proto/chatto/realtime/v1/realtime.proto

@@ -166,6 +166,7 @@ message RealtimeEvent {
     RoleRevokedEvent role_revoked = 65;
     RolePermissionsChangedEvent role_permissions_changed = 66;
     ViewerPermissionsChangedEvent viewer_permissions_changed = 67;
+    ViewerPresencePreferenceChangedEvent viewer_presence_preference_changed = 68;
   }
 }
 

+ 1 - 1
pyproject.toml

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

+ 39 - 9
scripts/generate_pb.sh

@@ -2,9 +2,9 @@
 # Regenerate the vendored Python protobuf + ConnectRPC bindings under
 # src/chattolib/_pb.
 #
-# Fetches the .proto sources for a pinned Chatto ref (default: the version
-# recorded in pyproject.toml, as a `v<version>` tag) and runs protoc with
-# the built-in Python generator and the connect-python plugin.
+# Fetches the .proto sources for a pinned Chatto ref (default: a tag derived
+# from the version recorded in pyproject.toml) and runs protoc with the
+# built-in Python generator and the connect-python plugin.
 #
 # ALWAYS pin to a released tag. Fetching from `main` risks shipping
 # unreleased wire changes that the deployed Chatto server does not speak
@@ -33,18 +33,48 @@ fi
 repo_root=$(cd "$(dirname "$0")/.." && pwd)
 cd "$repo_root"
 
-# Default the Chatto ref to `v<base-version-in-pyproject.toml>`. Strip any
-# post/pre-release suffix — those are chattolib-only fixes against the same
-# server release. Callers can override with CHATTO_REF.
-default_version=$(sed -nE 's/^version *= *"([^"]+)".*/\1/p' pyproject.toml | head -1)
+# Default the Chatto ref to a tag derived from `version` in pyproject.toml.
+# Strip any post-release suffix — those are chattolib-only fixes against the
+# same server release. Pre-releases keep their kind and number, because
+# Chatto tags them with SemVer dashes (PEP 440 0.5.0b6 -> v0.5.0-beta.6)
+# rather than the plain `v<base>` tag a final release uses. Candidates are
+# probed in preference order and the first existing tag wins. Callers can
+# override with CHATTO_REF.
+default_version=$(sed -nE 's/^version *= *\"([^\"]+)\".*/\1/p' pyproject.toml | head -1)
 default_base=${default_version%%.post*}
 default_base=${default_base%%.dev*}
 default_base=${default_base%%a*}
 default_base=${default_base%%b*}
 default_base=${default_base%%rc*}
-default_ref="v${default_base}"
+candidates="v${default_base}"
+# Map the PEP 440 pre-release onto the SemVer tag Chatto actually publishes.
+case ${default_version%%.post*} in
+    *a[0-9]*)  candidates="$candidates v${default_base}-alpha.${default_version##*a}" ;;
+    *b[0-9]*)  candidates="$candidates v${default_base}-beta.${default_version##*b}" ;;
+    *rc[0-9]*) candidates="$candidates v${default_base}-rc.${default_version##*rc}" ;;
+esac
+
+# The REST /tags/{name} endpoint 404s on lightweight tags, so probe with
+# ls-remote instead: it sees annotated and lightweight tags alike, costs no
+# API quota, and does exact ref-name matching (no prefix false-positives).
+# Note: ls-remote exits 0 even when nothing matches, so test the output.
+tag_exists() {
+    [ -n "$(git ls-remote --tags "https://github.com/chattocorp/chatto" "refs/tags/$1" 2>/dev/null)" ]
+}
 
-chatto_ref=${CHATTO_REF:-$default_ref}
+chatto_ref=${CHATTO_REF:-}
+if [ -z "$chatto_ref" ]; then
+    for candidate in $candidates; do
+        if tag_exists "$candidate"; then
+            chatto_ref=$candidate
+            break
+        fi
+    done
+    if [ -z "$chatto_ref" ]; then
+        echo "Could not resolve an existing chattocorp/chatto tag for version '$default_version' (tried: $candidates). Set CHATTO_REF explicitly." >&2
+        exit 1
+    fi
+fi
 echo "Fetching proto sources from chattocorp/chatto@${chatto_ref}"
 
 mkdir -p proto/chatto/{api,admin,auth,discovery,realtime}/v1 proto/buf/validate

+ 4 - 0
src/chattolib/__init__.py

@@ -36,6 +36,7 @@ from chattolib.types import (
     Message,
     MessageAttachment,
     MessageReaction,
+    MessageViewerState,
     Neighbor,
     NotificationLevel,
     NotificationMessageReference,
@@ -48,6 +49,7 @@ from chattolib.types import (
     Page,
     PinnedMessage,
     PinnedMessagesPage,
+    PresencePreference,
     PresenceStatus,
     ProviderMetadata,
     Role,
@@ -109,6 +111,7 @@ __all__ = [
     "Message",
     "MessageAttachment",
     "MessageReaction",
+    "MessageViewerState",
     "Neighbor",
     "NotificationLevel",
     "NotificationMessageReference",
@@ -121,6 +124,7 @@ __all__ = [
     "Page",
     "PinnedMessage",
     "PinnedMessagesPage",
+    "PresencePreference",
     "PresenceStatus",
     "ProviderMetadata",
     "Role",

+ 2 - 2
src/chattolib/_pb/buf/validate/validate_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: buf/validate/validate.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'buf/validate/validate.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/diagnostics_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/diagnostics.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/diagnostics.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/event_log_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/event_log.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/event_log.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/members_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/members.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/members.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/permissions_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/permissions.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/permissions.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/roles_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/roles.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/roles.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/room_layout_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/room_layout.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/room_layout.proto'

+ 2 - 2
src/chattolib/_pb/chatto/admin/v1/server_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/admin/v1/server.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/admin/v1/server.proto'

+ 72 - 0
src/chattolib/_pb/chatto/api/v1/account_connect.py

@@ -153,6 +153,42 @@ class MyAccountServiceClient(ConnectClient):
             headers=headers,
         )
 
+    async def get_presence_preference(self, request: chatto_api_v1_presence_pb2.GetPresencePreferenceRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_presence_pb2.GetPresencePreferenceResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetPresencePreference",
+                service_name="chatto.api.v1.MyAccountService",
+                input=chatto_api_v1_presence_pb2.GetPresencePreferenceRequest,
+                output=chatto_api_v1_presence_pb2.GetPresencePreferenceResponse,
+            ),
+            headers=headers,
+        )
+
+    async def set_presence_preference(self, request: chatto_api_v1_presence_pb2.SetPresencePreferenceRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_presence_pb2.SetPresencePreferenceResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="SetPresencePreference",
+                service_name="chatto.api.v1.MyAccountService",
+                input=chatto_api_v1_presence_pb2.SetPresencePreferenceRequest,
+                output=chatto_api_v1_presence_pb2.SetPresencePreferenceResponse,
+            ),
+            headers=headers,
+        )
+
+    async def refresh_presence(self, request: chatto_api_v1_presence_pb2.RefreshPresenceRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_presence_pb2.RefreshPresenceResponse:
+        return await self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="RefreshPresence",
+                service_name="chatto.api.v1.MyAccountService",
+                input=chatto_api_v1_presence_pb2.RefreshPresenceRequest,
+                output=chatto_api_v1_presence_pb2.RefreshPresenceResponse,
+            ),
+            headers=headers,
+        )
+
     async def set_custom_status(self, request: chatto_api_v1_user_status_pb2.SetCustomStatusRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_status_pb2.SetCustomStatusResponse:
         return await self.execute_unary(
             request=request,
@@ -347,6 +383,42 @@ class MyAccountServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
+    def get_presence_preference(self, request: chatto_api_v1_presence_pb2.GetPresencePreferenceRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_presence_pb2.GetPresencePreferenceResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="GetPresencePreference",
+                service_name="chatto.api.v1.MyAccountService",
+                input=chatto_api_v1_presence_pb2.GetPresencePreferenceRequest,
+                output=chatto_api_v1_presence_pb2.GetPresencePreferenceResponse,
+            ),
+            headers=headers,
+        )
+
+    def set_presence_preference(self, request: chatto_api_v1_presence_pb2.SetPresencePreferenceRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_presence_pb2.SetPresencePreferenceResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="SetPresencePreference",
+                service_name="chatto.api.v1.MyAccountService",
+                input=chatto_api_v1_presence_pb2.SetPresencePreferenceRequest,
+                output=chatto_api_v1_presence_pb2.SetPresencePreferenceResponse,
+            ),
+            headers=headers,
+        )
+
+    def refresh_presence(self, request: chatto_api_v1_presence_pb2.RefreshPresenceRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_presence_pb2.RefreshPresenceResponse:
+        return self.execute_unary(
+            request=request,
+            method=MethodInfo(
+                name="RefreshPresence",
+                service_name="chatto.api.v1.MyAccountService",
+                input=chatto_api_v1_presence_pb2.RefreshPresenceRequest,
+                output=chatto_api_v1_presence_pb2.RefreshPresenceResponse,
+            ),
+            headers=headers,
+        )
+
     def set_custom_status(self, request: chatto_api_v1_user_status_pb2.SetCustomStatusRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_user_status_pb2.SetCustomStatusResponse:
         return self.execute_unary(
             request=request,

File diff suppressed because it is too large
+ 2 - 2
src/chattolib/_pb/chatto/api/v1/account_pb2.py


+ 2 - 2
src/chattolib/_pb/chatto/api/v1/asset_uploads_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/asset_uploads.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/asset_uploads.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/attachments_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/attachments.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/attachments.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/common_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/common.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/common.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/external_identities_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/external_identities.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/external_identities.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/link_previews_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/link_previews.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/link_previews.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/member_directory_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/member_directory.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/member_directory.proto'

File diff suppressed because it is too large
+ 2 - 2
src/chattolib/_pb/chatto/api/v1/message_types_pb2.py


+ 2 - 2
src/chattolib/_pb/chatto/api/v1/messages_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/messages.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/messages.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/notification_preferences_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/notification_preferences.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/notification_preferences.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/notifications_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/notifications.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/notifications.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/pagination_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/pagination.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/pagination.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/permissions_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/permissions.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/permissions.proto'

+ 25 - 9
src/chattolib/_pb/chatto/api/v1/presence_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/presence.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/presence.proto'
@@ -25,19 +25,35 @@ _sym_db = _symbol_database.Default()
 from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2
 
 
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1c\x63hatto/api/v1/presence.proto\x12\rchatto.api.v1\x1a\x1b\x62uf/validate/validate.proto\"h\n\x12SetPresenceRequest\x12;\n\x06status\x18\x01 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatusB\x0c\xbaH\t\x82\x01\x06\x10\x01 \x00 \x04\x12\x15\n\ruser_selected\x18\x02 \x01(\x08\"D\n\x13SetPresenceResponse\x12-\n\x06status\x18\x01 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatus*\xa8\x01\n\x0ePresenceStatus\x12\x1f\n\x1bPRESENCE_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n\x16PRESENCE_STATUS_ONLINE\x10\x01\x12\x18\n\x14PRESENCE_STATUS_AWAY\x10\x02\x12\"\n\x1ePRESENCE_STATUS_DO_NOT_DISTURB\x10\x03\x12\x1b\n\x17PRESENCE_STATUS_OFFLINE\x10\x04\x62\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1c\x63hatto/api/v1/presence.proto\x12\rchatto.api.v1\x1a\x1b\x62uf/validate/validate.proto\"U\n\x12PresencePreference\x12-\n\x06status\x18\x01 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatus\x12\x10\n\x08revision\x18\x02 \x01(\t\"\x1e\n\x1cGetPresencePreferenceRequest\"V\n\x1dGetPresencePreferenceResponse\x12\x35\n\npreference\x18\x01 \x01(\x0b\x32!.chatto.api.v1.PresencePreference\"t\n\x1cSetPresencePreferenceRequest\x12\x39\n\x06status\x18\x01 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatusB\n\xbaH\x07\x82\x01\x04\x10\x01 \x00\x12\x19\n\x11\x65xpected_revision\x18\x02 \x01(\t\"V\n\x1dSetPresencePreferenceResponse\x12\x35\n\npreference\x18\x01 \x01(\x0b\x32!.chatto.api.v1.PresencePreference\"\x18\n\x16RefreshPresenceRequest\"P\n\x17RefreshPresenceResponse\x12\x35\n\npreference\x18\x01 \x01(\x0b\x32!.chatto.api.v1.PresencePreference\"h\n\x12SetPresenceRequest\x12;\n\x06status\x18\x01 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatusB\x0c\xbaH\t\x82\x01\x06\x10\x01 \x00 \x04\x12\x15\n\ruser_selected\x18\x02 \x01(\x08\"D\n\x13SetPresenceResponse\x12-\n\x06status\x18\x01 \x01(\x0e\x32\x1d.chatto.api.v1.PresenceStatus*\xa8\x01\n\x0ePresenceStatus\x12\x1f\n\x1bPRESENCE_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n\x16PRESENCE_STATUS_ONLINE\x10\x01\x12\x18\n\x14PRESENCE_STATUS_AWAY\x10\x02\x12\"\n\x1ePRESENCE_STATUS_DO_NOT_DISTURB\x10\x03\x12\x1b\n\x17PRESENCE_STATUS_OFFLINE\x10\x04\x62\x06proto3')
 
 _globals = globals()
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
 _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'chatto.api.v1.presence_pb2', _globals)
 if not _descriptor._USE_C_DESCRIPTORS:
   DESCRIPTOR._loaded_options = None
+  _globals['_SETPRESENCEPREFERENCEREQUEST'].fields_by_name['status']._loaded_options = None
+  _globals['_SETPRESENCEPREFERENCEREQUEST'].fields_by_name['status']._serialized_options = b'\272H\007\202\001\004\020\001 \000'
   _globals['_SETPRESENCEREQUEST'].fields_by_name['status']._loaded_options = None
   _globals['_SETPRESENCEREQUEST'].fields_by_name['status']._serialized_options = b'\272H\t\202\001\006\020\001 \000 \004'
-  _globals['_PRESENCESTATUS']._serialized_start=253
-  _globals['_PRESENCESTATUS']._serialized_end=421
-  _globals['_SETPRESENCEREQUEST']._serialized_start=76
-  _globals['_SETPRESENCEREQUEST']._serialized_end=180
-  _globals['_SETPRESENCERESPONSE']._serialized_start=182
-  _globals['_SETPRESENCERESPONSE']._serialized_end=250
+  _globals['_PRESENCESTATUS']._serialized_start=774
+  _globals['_PRESENCESTATUS']._serialized_end=942
+  _globals['_PRESENCEPREFERENCE']._serialized_start=76
+  _globals['_PRESENCEPREFERENCE']._serialized_end=161
+  _globals['_GETPRESENCEPREFERENCEREQUEST']._serialized_start=163
+  _globals['_GETPRESENCEPREFERENCEREQUEST']._serialized_end=193
+  _globals['_GETPRESENCEPREFERENCERESPONSE']._serialized_start=195
+  _globals['_GETPRESENCEPREFERENCERESPONSE']._serialized_end=281
+  _globals['_SETPRESENCEPREFERENCEREQUEST']._serialized_start=283
+  _globals['_SETPRESENCEPREFERENCEREQUEST']._serialized_end=399
+  _globals['_SETPRESENCEPREFERENCERESPONSE']._serialized_start=401
+  _globals['_SETPRESENCEPREFERENCERESPONSE']._serialized_end=487
+  _globals['_REFRESHPRESENCEREQUEST']._serialized_start=489
+  _globals['_REFRESHPRESENCEREQUEST']._serialized_end=513
+  _globals['_REFRESHPRESENCERESPONSE']._serialized_start=515
+  _globals['_REFRESHPRESENCERESPONSE']._serialized_end=595
+  _globals['_SETPRESENCEREQUEST']._serialized_start=597
+  _globals['_SETPRESENCEREQUEST']._serialized_end=701
+  _globals['_SETPRESENCERESPONSE']._serialized_start=703
+  _globals['_SETPRESENCERESPONSE']._serialized_end=771
 # @@protoc_insertion_point(module_scope)

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/push_notifications_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/push_notifications.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/push_notifications.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/reactions_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/reactions.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/reactions.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/read_state_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/read_state.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/read_state.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/roles_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/roles.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/roles.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/room_directory_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/room_directory.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/room_directory.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/room_timeline_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/room_timeline.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/room_timeline.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/rooms_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/rooms.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/rooms.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/server_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/server.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/server.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/server_state_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/server_state.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/server_state.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/threads_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/threads.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/threads.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/user_status_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/user_status.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/user_status.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/users_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/users.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/users.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/viewer_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/viewer.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/viewer.proto'

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/voice_calls_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/api/v1/voice_calls.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/api/v1/voice_calls.proto'

+ 2 - 2
src/chattolib/_pb/chatto/auth/v1/external_identity_auth_pb2.py

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/auth/v1/external_identity_auth.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/auth/v1/external_identity_auth.proto'

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

@@ -2,7 +2,7 @@
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # NO CHECKED-IN PROTOBUF GENCODE
 # source: chatto/discovery/v1/server.proto
-# Protobuf Python Version: 7.36.1
+# Protobuf Python Version: 7.35.1
 """Generated protocol buffer code."""
 from google.protobuf import descriptor as _descriptor
 from google.protobuf import descriptor_pool as _descriptor_pool
@@ -12,7 +12,7 @@ from google.protobuf.internal import builder as _builder
 _runtime_version.ValidateProtobufRuntimeVersion(
     _runtime_version.Domain.PUBLIC,
     7,
-    36,
+    35,
     1,
     '',
     'chatto/discovery/v1/server.proto'

File diff suppressed because it is too large
+ 2 - 2
src/chattolib/_pb/chatto/realtime/v1/events_pb2.py


File diff suppressed because it is too large
+ 2 - 2
src/chattolib/_pb/chatto/realtime/v1/realtime_pb2.py


+ 39 - 1
src/chattolib/bot.py

@@ -64,6 +64,7 @@ from chattolib.realtime import (
 )
 from chattolib.types import (
     Message,
+    PresencePreference,
     PresenceStatus,
     Room,
     RoomGroup,
@@ -89,6 +90,7 @@ __all__ = [
     "BotEvent",
     "BotMessageEvent",
     "BotPresenceEvent",
+    "BotPresencePreferenceEvent",
     "BotReactionEvent",
     "BotRoomEvent",
     "BotTypingEvent",
@@ -189,6 +191,17 @@ class BotPresenceEvent(BotEvent):
     status: PresenceStatus
 
 
+@dataclass
+class BotPresencePreferenceEvent(BotEvent):
+    """The bot's own private availability *preference* changed (0.5.0b6).
+
+    Delivered only to the account whose own choice changed. The wire payload is
+    an empty hint: to read the new choice, hydrate it from the bot via
+    ``await event.bot.get_presence_preference()`` (the current
+    :class:`~chattolib.types.PresencePreference`).
+    """
+
+
 @dataclass
 class BotTypingEvent(BotEvent):
     """A user started (or stopped) typing in a room/thread."""
@@ -334,7 +347,8 @@ class Bot:
         """Register ``handler`` for events of ``kind``.
 
         ``kind`` is one of: ``message``, ``reaction``, ``presence``,
-        ``typing``, ``room``, ``user``, ``*`` (all events). ``handler`` is an
+        ``presence_preference``, ``typing``, ``room``, ``user``, ``*`` (all
+        events). ``handler`` is an
         ``async def`` taking a single :class:`BotEvent`. Returns the handler
         so ``on`` can be used as a decorator.
         """
@@ -406,6 +420,28 @@ class Bot:
         """Set the bot's presence (``ONLINE`` / ``AWAY`` / ``DO_NOT_DISTURB``)."""
         return await self._client.set_presence(status)
 
+    async def get_presence_preference(self) -> PresencePreference | None:
+        """Read the bot's saved availability choice (or ``None`` if unset)."""
+        return await self._client.get_presence_preference()
+
+    async def set_presence_preference(
+        self, status: PresenceStatus, *, expected_revision: str = ""
+    ) -> PresencePreference:
+        """Save the bot's availability choice for every device on the server.
+
+        Unlike :meth:`set_presence` this persists across disconnects and
+        accepts ``OFFLINE`` (meaning "Appear Offline"). Echo a prior
+        ``revision`` back as ``expected_revision`` to guard against a
+        concurrent change.
+        """
+        return await self._client.set_presence_preference(
+            status, expected_revision=expected_revision
+        )
+
+    async def refresh_presence(self) -> PresencePreference | None:
+        """Refresh the bot's liveness (call every ~30 s to stay "live")."""
+        return await self._client.refresh_presence()
+
     async def set_status(self, emoji: str, text: str) -> dict[str, Any]:
         """Set the bot's custom status (e.g. a "working on X" note)."""
         return await self._client.set_custom_status(emoji, text)
@@ -540,6 +576,8 @@ class Bot:
                     status=_presence(frame.payload.status),
                 )
             )
+        elif kind == "viewer_presence_preference_changed":
+            await self._dispatch(BotPresencePreferenceEvent(bot=self, kind="presence_preference"))
         elif kind == "user_typing":
             payload = frame.payload
             await self._dispatch(

+ 65 - 0
src/chattolib/client.py

@@ -96,6 +96,7 @@ from chattolib.types import (
     Page,
     PinnedMessage,
     PinnedMessagesPage,
+    PresencePreference,
     PresenceStatus,
     Role,
     Room,
@@ -358,6 +359,14 @@ class ChattoClient:
         *,
         user_selected: bool = True,
     ) -> PresenceStatus:
+        """Report a *live* presence status (the pre-0.5.0b6 mechanism).
+
+        This is the legacy, transient report: it expires after 60 seconds
+        without a refresh and a saved private choice (see
+        :meth:`set_presence_preference`) overrides it, including when
+        ``user_selected`` is set. New clients should prefer the
+        :meth:`set_presence_preference` / :meth:`refresh_presence` pair.
+        """
         if status in (PresenceStatus.UNSPECIFIED, PresenceStatus.OFFLINE):
             raise ValueError(
                 "UNSPECIFIED and OFFLINE cannot be set as presence status; "
@@ -368,6 +377,62 @@ class ChattoClient:
         name = presence_pb2.PresenceStatus.Name(resp.status)
         return PresenceStatus(name)
 
+    async def get_presence_preference(self) -> PresencePreference | None:
+        """Read the authenticated account's private saved availability choice.
+
+        Returns ``None`` until the account has saved a choice for the first
+        time. This is the read side of the 0.5.0b6 preference pair; use
+        :meth:`set_presence_preference` to change it.
+        """
+        resp = await self._rpc(
+            self._svc.account.get_presence_preference(
+                presence_pb2.GetPresencePreferenceRequest(), headers=self._headers()
+            )
+        )
+        return PresencePreference.parse(pb_to_dict(resp.preference))
+
+    async def set_presence_preference(
+        self,
+        status: PresenceStatus,
+        *,
+        expected_revision: str = "",
+    ) -> PresencePreference:
+        """Save an availability choice for every device on this server.
+
+        Unlike :meth:`set_presence`, the choice persists across disconnects
+        and ``OFFLINE`` is a valid value (it means "Appear Offline" and
+        suppresses public presence and typing). Pass ``expected_revision``
+        from a prior :meth:`get_presence_preference` to guard against a
+        concurrent change; a stale revision is rejected with ``ABORTED``.
+        Leave it empty only when initialising an absent choice.
+        """
+        if status is PresenceStatus.UNSPECIFIED:
+            raise ValueError("UNSPECIFIED cannot be saved as a presence preference")
+        req = presence_pb2.SetPresencePreferenceRequest(
+            status=status.value, expected_revision=expected_revision
+        )
+        resp = await self._rpc(
+            self._svc.account.set_presence_preference(req, headers=self._headers())
+        )
+        pref = PresencePreference.parse(pb_to_dict(resp.preference))
+        assert pref is not None
+        return pref
+
+    async def refresh_presence(self) -> PresencePreference | None:
+        """Refresh connection liveness without changing the saved choice.
+
+        Call roughly every 30 seconds to stay "live" (liveness expires after
+        60 seconds). Accounts with a saved ``OFFLINE`` choice may refresh
+        without emitting public presence signals. Returns the current private
+        choice so a client can recover a choice it missed while disconnected.
+        """
+        resp = await self._rpc(
+            self._svc.account.refresh_presence(
+                presence_pb2.RefreshPresenceRequest(), headers=self._headers()
+            )
+        )
+        return PresencePreference.parse(pb_to_dict(resp.preference))
+
     async def set_custom_status(
         self,
         emoji: str,

+ 45 - 0
src/chattolib/types.py

@@ -203,6 +203,30 @@ class User:
         )
 
 
+@dataclass
+class PresencePreference:
+    """The authenticated account's private, saved availability choice.
+
+    This is distinct from :class:`PresenceStatus` as a *live* report: the
+    preference survives disconnects and applies across every device on the
+    server. ``revision`` is an opaque token that must be echoed back (as
+    ``expected_revision``) when replacing the choice; a stale one yields
+    ``ABORTED``.
+    """
+
+    status: PresenceStatus = PresenceStatus.UNSPECIFIED
+    revision: str = ""
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> PresencePreference | None:
+        if not data:
+            return None
+        return cls(
+            status=_parse_enum(PresenceStatus, data.get("status"), PresenceStatus.UNSPECIFIED),  # type: ignore[arg-type]
+            revision=data.get("revision", ""),
+        )
+
+
 @dataclass
 class UserSettings:
     timezone: str | None = None
@@ -826,6 +850,25 @@ class ThreadSummary:
         )
 
 
+@dataclass
+class MessageViewerState:
+    """Viewer-specific capability for one message, independent of whether its
+    canonical thread has been established. ``can_reply_in_thread`` reflects
+    read access, room policy, and broad or interaction-scoped posting
+    authority; it is ``None`` when the server has not resolved viewer state."""
+
+    can_reply_in_thread: bool | None = None
+
+    @classmethod
+    def parse(cls, data: dict[str, Any] | None) -> MessageViewerState:
+        data = data or {}
+        return cls(
+            can_reply_in_thread=(
+                data.get("canReplyInThread") if "canReplyInThread" in data else None
+            ),
+        )
+
+
 @dataclass
 class Message:
     id: str
@@ -845,6 +888,7 @@ class Message:
     thread: ThreadSummary | None = None
     deleted_at: datetime | None = None
     pinned: bool = False
+    viewer_state: MessageViewerState | None = None
 
     @classmethod
     def parse(cls, data: dict[str, Any] | None) -> Message | None:
@@ -868,6 +912,7 @@ class Message:
             thread=ThreadSummary.parse(data.get("thread")),
             deleted_at=parse_datetime(data.get("deletedAt")),
             pinned=bool(data.get("pinned", False)),
+            viewer_state=MessageViewerState.parse(data.get("viewerState")),
         )
 
 

+ 21 - 0
tests/test_bot.py

@@ -16,6 +16,7 @@ from chattolib.bot import (
     Bot,
     BotMessageEvent,
     BotPresenceEvent,
+    BotPresencePreferenceEvent,
     BotReactionEvent,
     BotRoomEvent,
     BotTypingEvent,
@@ -198,6 +199,26 @@ async def test_dispatch_presence_event():
     assert seen[0].status is PresenceStatus.ONLINE
 
 
+async def test_dispatch_presence_preference_event():
+    """0.5.0b6: the empty viewer_presence_preference_changed hint dispatches as
+    a ``presence_preference`` event; handlers hydrate the choice themselves."""
+    bot = _bot()
+    seen: list[BotPresencePreferenceEvent] = []
+
+    async def on_pref(event: BotPresencePreferenceEvent) -> None:
+        seen.append(event)
+
+    bot.on("presence_preference", on_pref)
+
+    await bot._handle_live(
+        _live(
+            "viewer_presence_preference_changed", events_pb2.ViewerPresencePreferenceChangedEvent()
+        )
+    )
+    assert len(seen) == 1
+    assert seen[0].kind == "presence_preference"
+
+
 async def test_dispatch_reaction_event():
     bot = _bot()
     seen: list[BotReactionEvent] = []

+ 74 - 0
tests/test_client.py

@@ -326,6 +326,80 @@ async def test_set_presence_offline_rejected(client):
             await client.set_presence(PresenceStatus.OFFLINE)
 
 
+async def test_get_presence_preference(client):
+    resp = presence_pb2.GetPresencePreferenceResponse()
+    resp.preference.status = "PRESENCE_STATUS_OFFLINE"
+    resp.preference.revision = "rev-1"
+    _mock_method(client, "account", "get_presence_preference", resp)
+
+    async with client:
+        pref = await client.get_presence_preference()
+
+    assert pref is not None
+    assert pref.status is PresenceStatus.OFFLINE
+    assert pref.revision == "rev-1"
+
+
+async def test_get_presence_preference_absent(client):
+    # No preference saved yet: the response carries no sub-message.
+    _mock_method(
+        client, "account", "get_presence_preference", presence_pb2.GetPresencePreferenceResponse()
+    )
+
+    async with client:
+        assert await client.get_presence_preference() is None
+
+
+async def test_set_presence_preference(client):
+    resp = presence_pb2.SetPresencePreferenceResponse()
+    resp.preference.status = "PRESENCE_STATUS_DO_NOT_DISTURB"
+    resp.preference.revision = "rev-2"
+    m = _mock_method(client, "account", "set_presence_preference", resp)
+
+    async with client:
+        pref = await client.set_presence_preference(
+            PresenceStatus.DO_NOT_DISTURB, expected_revision="rev-1"
+        )
+
+    assert pref.status is PresenceStatus.DO_NOT_DISTURB
+    assert pref.revision == "rev-2"
+    req = m.await_args.args[0]
+    assert req.status == presence_pb2.PresenceStatus.Value("PRESENCE_STATUS_DO_NOT_DISTURB")
+    assert req.expected_revision == "rev-1"
+
+
+async def test_set_presence_preference_offline_allowed(client):
+    # Unlike set_presence, OFFLINE is a valid saved choice ("Appear Offline").
+    resp = presence_pb2.SetPresencePreferenceResponse()
+    resp.preference.status = "PRESENCE_STATUS_OFFLINE"
+    _mock_method(client, "account", "set_presence_preference", resp)
+
+    async with client:
+        assert (await client.set_presence_preference(PresenceStatus.OFFLINE)).status is (
+            PresenceStatus.OFFLINE
+        )
+
+
+async def test_set_presence_preference_unspecified_rejected(client):
+    async with client:
+        with pytest.raises(ValueError):
+            await client.set_presence_preference(PresenceStatus.UNSPECIFIED)
+
+
+async def test_refresh_presence(client):
+    resp = presence_pb2.RefreshPresenceResponse()
+    resp.preference.status = "PRESENCE_STATUS_ONLINE"
+    resp.preference.revision = "rev-3"
+    _mock_method(client, "account", "refresh_presence", resp)
+
+    async with client:
+        pref = await client.refresh_presence()
+
+    assert pref is not None
+    assert pref.status is PresenceStatus.ONLINE
+    assert pref.revision == "rev-3"
+
+
 async def test_update_settings(client):
     resp = account_pb2.UpdateSettingsResponse()
     resp.settings.timezone = "Europe/Rome"

+ 17 - 0
tests/test_realtime.py

@@ -65,6 +65,23 @@ def test_wrap_event_presence_changed():
     assert wrapped.payload.status == presence_pb2.PresenceStatus.PRESENCE_STATUS_ONLINE
 
 
+def test_wrap_event_viewer_presence_preference_changed():
+    """0.5.0b6: the 48th oneof member is an empty hint delivered only to the
+    account whose own presence preference changed."""
+    from chattolib._pb.chatto.realtime.v1 import events_pb2, realtime_pb2
+
+    envelope = realtime_pb2.RealtimeEvent()
+    envelope.id = "evt_9"
+    envelope.actor_id = "u_me"
+    # Setting the empty message sets the oneof case.
+    envelope.viewer_presence_preference_changed.SetInParent()
+
+    wrapped = _wrap_event(envelope)
+    assert wrapped.kind == "viewer_presence_preference_changed"
+    assert wrapped.actor_id == "u_me"
+    assert isinstance(wrapped.payload, events_pb2.ViewerPresencePreferenceChangedEvent)
+
+
 def test_wrap_event_without_variant():
     """Envelope with no oneof set (server bug or truncation) should still wrap."""
     from chattolib._pb.chatto.realtime.v1 import realtime_pb2

Some files were not shown because too many files changed in this diff