Skip to content

Commit 69ddea5

Browse files
committed
[AIT-316] feat: introduce support for message annotations
- Added `RealtimeAnnotations` class to manage annotation creation, deletion, and subscription on realtime channels. - Introduced `Annotation` and `AnnotationAction` types to encapsulate annotation details and actions. - Extended flags to include `ANNOTATION_PUBLISH` and `ANNOTATION_SUBSCRIBE`. - Refactored data encoding logic into `ably.util.encoding`. - Integrated annotation handling into `RealtimeChannel` and `RestChannel`.
1 parent 3d2c3c4 commit 69ddea5

File tree

9 files changed

+164
-199
lines changed

9 files changed

+164
-199
lines changed

ably/realtime/annotations.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def __init__(self, channel: RealtimeChannel, connection_manager: ConnectionManag
4040
self.__subscriptions = EventEmitter()
4141
self.__rest_annotations = RestAnnotations(channel)
4242

43-
async def publish(self, msg_or_serial, annotation: dict | Annotation, params: dict=None):
43+
async def publish(self, msg_or_serial, annotation: dict | Annotation, params: dict | None = None):
4444
"""
4545
Publish an annotation on a message via the realtime connection.
4646
@@ -84,7 +84,12 @@ async def publish(self, msg_or_serial, annotation: dict | Annotation, params: di
8484
# Send via WebSocket
8585
await self.__connection_manager.send_protocol_message(protocol_message)
8686

87-
async def delete(self, msg_or_serial, annotation: dict | Annotation, params=None, timeout=None):
87+
async def delete(
88+
self,
89+
msg_or_serial,
90+
annotation: dict | Annotation,
91+
params: dict | None = None,
92+
):
8893
"""
8994
Delete an annotation on a message.
9095
@@ -95,7 +100,6 @@ async def delete(self, msg_or_serial, annotation: dict | Annotation, params=None
95100
msg_or_serial: Either a message serial (string) or a Message object
96101
annotation: Dict containing annotation properties or Annotation object
97102
params: Optional dict of query parameters
98-
timeout: Optional timeout (not used for realtime, kept for compatibility)
99103
100104
Returns:
101105
None
@@ -161,13 +165,13 @@ async def subscribe(self, *args):
161165

162166
# Check if ANNOTATION_SUBSCRIBE mode is enabled
163167
if self.__channel.state == ChannelState.ATTACHED:
164-
if not Flag.ANNOTATION_SUBSCRIBE in self.__channel.modes:
168+
if Flag.ANNOTATION_SUBSCRIBE not in self.__channel.modes:
165169
raise AblyException(
166-
"You are trying to add an annotation listener, but you haven't requested the "
170+
message="You are trying to add an annotation listener, but you haven't requested the "
167171
"annotation_subscribe channel mode in ChannelOptions, so this won't do anything "
168172
"(we only deliver annotations to clients who have explicitly requested them)",
169-
93001,
170-
400
173+
code=93001,
174+
status_code=400,
171175
)
172176

173177
def unsubscribe(self, *args):
@@ -219,7 +223,7 @@ def _process_incoming(self, incoming_annotations):
219223
annotation_type = annotation.type or ''
220224
self.__subscriptions._emit(annotation_type, annotation)
221225

222-
async def get(self, msg_or_serial, params=None):
226+
async def get(self, msg_or_serial, params: dict | None = None):
223227
"""
224228
Retrieve annotations for a message with pagination support.
225229

ably/realtime/channel.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,10 @@ def presence(self):
911911
"""Get the RealtimePresence object for this channel"""
912912
return self.__presence
913913

914+
@property
915+
def annotations(self) -> RealtimeAnnotations:
916+
return self._Channel__annotations
917+
914918
@property
915919
def modes(self):
916920
"""Get the list of channel modes"""

ably/rest/annotations.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ably.http.paginatedresult import PaginatedResult, format_params
1010
from ably.types.annotation import (
1111
Annotation,
12+
AnnotationAction,
1213
make_annotation_response_handler,
1314
)
1415
from ably.types.message import Message
@@ -74,7 +75,7 @@ def construct_validate_annotation(msg_or_serial, annotation: dict | Annotation):
7475
elif isinstance(annotation, Annotation):
7576
annotation_values = annotation.as_dict()
7677
else:
77-
annotation_values = annotation
78+
annotation_values = annotation.copy()
7879

7980
annotation_values['message_serial'] = message_serial
8081

@@ -108,23 +109,27 @@ def __base_path_for_serial(self, serial):
108109
channel_path = '/channels/{}/'.format(parse.quote_plus(self.__channel.name, safe=':'))
109110
return channel_path + 'messages/' + parse.quote_plus(serial, safe=':') + '/annotations'
110111

111-
async def publish(self, msg_or_serial, annotation_values, params=None, timeout=None):
112+
async def publish(
113+
self,
114+
msg_or_serial,
115+
annotation: dict | Annotation,
116+
params: dict | None = None,
117+
):
112118
"""
113119
Publish an annotation on a message.
114120
115121
Args:
116122
msg_or_serial: Either a message serial (string) or a Message object
117-
annotation_values: Dict containing annotation properties (type, name, data, etc.)
123+
annotation: Dict containing annotation properties (type, name, data, etc.) or Annotation object
118124
params: Optional dict of query parameters
119-
timeout: Optional timeout for the HTTP request
120125
121126
Returns:
122127
None
123128
124129
Raises:
125130
AblyException: If the request fails or inputs are invalid
126131
"""
127-
annotation = construct_validate_annotation(msg_or_serial, annotation_values)
132+
annotation = construct_validate_annotation(msg_or_serial, annotation)
128133

129134
# Convert to wire format
130135
request_body = annotation.as_dict(binary=self.__channel.ably.options.use_binary_protocol)
@@ -145,9 +150,14 @@ async def publish(self, msg_or_serial, annotation_values, params=None, timeout=N
145150
path += '?' + parse.urlencode(params)
146151

147152
# Send request
148-
await self.__channel.ably.http.post(path, body=request_body, timeout=timeout)
149-
150-
async def delete(self, msg_or_serial, annotation_values, params=None, timeout=None):
153+
await self.__channel.ably.http.post(path, body=request_body)
154+
155+
async def delete(
156+
self,
157+
msg_or_serial,
158+
annotation: dict | Annotation,
159+
params: dict | None = None,
160+
):
151161
"""
152162
Delete an annotation on a message.
153163
@@ -156,9 +166,8 @@ async def delete(self, msg_or_serial, annotation_values, params=None, timeout=No
156166
157167
Args:
158168
msg_or_serial: Either a message serial (string) or a Message object
159-
annotation_values: Dict containing annotation properties
169+
annotation: Dict containing annotation properties or Annotation object
160170
params: Optional dict of query parameters
161-
timeout: Optional timeout for the HTTP request
162171
163172
Returns:
164173
None
@@ -167,11 +176,14 @@ async def delete(self, msg_or_serial, annotation_values, params=None, timeout=No
167176
AblyException: If the request fails or inputs are invalid
168177
"""
169178
# Set action to delete
170-
annotation_values = annotation_values.copy()
171-
annotation_values['action'] = 'annotation.delete'
172-
return await self.publish(msg_or_serial, annotation_values, params, timeout)
179+
if isinstance(annotation, Annotation):
180+
annotation_values = annotation.as_dict()
181+
else:
182+
annotation_values = annotation.copy()
183+
annotation_values['action'] = AnnotationAction.ANNOTATION_DELETE
184+
return await self.publish(msg_or_serial, annotation_values, params)
173185

174-
async def get(self, msg_or_serial, params=None):
186+
async def get(self, msg_or_serial, params: dict | None = None):
175187
"""
176188
Retrieve annotations for a message with pagination support.
177189

ably/rest/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def __init__(self, ably: AblyRest | AblyRealtime, options: Options):
9090
async def get_auth_transport_param(self):
9191
auth_credentials = {}
9292
if self.auth_options.client_id:
93-
auth_credentials["client_id"] = self.auth_options.client_id
93+
auth_credentials["clientId"] = self.auth_options.client_id
9494
if self.__auth_mechanism == Auth.Method.BASIC:
9595
key_name = self.__auth_options.key_name
9696
key_secret = self.__auth_options.key_secret

ably/rest/channel.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
IncompatibleClientIdException,
2727
catch_all,
2828
)
29+
from ably.rest.annotations import RestAnnotations
2930

3031
log = logging.getLogger(__name__)
3132

3233

3334
class Channel:
35+
__annotations: RestAnnotations
36+
3437
def __init__(self, ably, name, options):
3538
self.__ably = ably
3639
self.__name = name
@@ -366,7 +369,7 @@ def presence(self):
366369
return self.__presence
367370

368371
@property
369-
def annotations(self):
372+
def annotations(self) -> RestAnnotations:
370373
return self.__annotations
371374

372375
@options.setter

ably/transport/websockettransport.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ async def on_protocol_message(self, msg):
189189
ProtocolMessageAction.DETACHED,
190190
ProtocolMessageAction.MESSAGE,
191191
ProtocolMessageAction.PRESENCE,
192+
ProtocolMessageAction.ANNOTATION,
192193
ProtocolMessageAction.SYNC
193194
):
194195
self.connection_manager.on_channel_message(msg)

ably/types/annotation.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,22 +122,17 @@ def as_dict(self, binary=False):
122122
123123
Note: Annotations are not encrypted as they need to be parsed by the server.
124124
"""
125-
# Encode data
126-
encoded = encode_data(self.data, self._encoding_array, binary)
127-
128125
request_body = {
129126
'action': int(self.action) if self.action is not None else None,
130127
'serial': self.serial,
131128
'messageSerial': self.message_serial,
132129
'type': self.type, # Annotation type (not data type)
133130
'name': self.name,
134131
'count': self.count,
135-
'data': encoded.get('data'),
136-
'encoding': encoded.get('encoding', ''),
137-
'dataType': encoded.get('type'), # Data type (not annotation type)
138132
'clientId': self.client_id or None,
139133
'timestamp': self.timestamp or None,
140134
'extras': self.extras,
135+
**encode_data(self.data, self._encoding_array, binary)
141136
}
142137

143138
# None values aren't included

0 commit comments

Comments
 (0)