|
1 |
| -from hiero_sdk_python.hapi.mirror import consensus_service_pb2 as mirror_proto |
2 | 1 | from datetime import datetime
|
| 2 | +from typing import Optional, List, Union |
| 3 | +from hiero_sdk_python.hapi.mirror import consensus_service_pb2 as mirror_proto |
| 4 | + |
| 5 | +def _to_datetime(ts_proto) -> datetime: |
| 6 | + """ |
| 7 | + Convert a protobuf Timestamp to a Python datetime (UTC). |
| 8 | + """ |
| 9 | + return datetime.utcfromtimestamp(ts_proto.seconds + ts_proto.nanos / 1e9) |
| 10 | + |
| 11 | + |
| 12 | +class TopicMessageChunk: |
| 13 | + """ |
| 14 | + Represents a single chunk within a chunked topic message. |
| 15 | + Mirrors the Java 'TopicMessageChunk'. |
| 16 | + """ |
| 17 | + |
| 18 | + def __init__(self, response: mirror_proto.ConsensusTopicResponse): |
| 19 | + self.consensus_timestamp = _to_datetime(response.consensusTimestamp) |
| 20 | + self.content_size = len(response.message) |
| 21 | + self.running_hash = response.runningHash |
| 22 | + self.sequence_number = response.sequenceNumber |
| 23 | + |
3 | 24 |
|
4 | 25 | class TopicMessage:
|
5 | 26 | """
|
6 |
| - Represents a single message returned from a Hedera Mirror Node subscription. |
| 27 | + Represents a Hedera TopicMessage, possibly composed of multiple chunks. |
7 | 28 | """
|
8 | 29 |
|
9 |
| - def __init__(self, consensus_timestamp, message, running_hash, sequence_number, |
10 |
| - running_hash_version=None, chunk_info=None, chunks=None, transaction_id=None): |
| 30 | + def __init__( |
| 31 | + self, |
| 32 | + consensus_timestamp: datetime, |
| 33 | + contents: bytes, |
| 34 | + running_hash: bytes, |
| 35 | + sequence_number: int, |
| 36 | + chunks: List[TopicMessageChunk], |
| 37 | + transaction_id: Optional[str] = None, |
| 38 | + ): |
11 | 39 | self.consensus_timestamp = consensus_timestamp
|
12 |
| - self.message = message or b"" |
13 |
| - self.running_hash = running_hash or b"" |
14 |
| - self.sequence_number = sequence_number or 0 |
15 |
| - self.running_hash_version = running_hash_version |
16 |
| - self.chunk_info = chunk_info |
| 40 | + self.contents = contents |
| 41 | + self.running_hash = running_hash |
| 42 | + self.sequence_number = sequence_number |
17 | 43 | self.chunks = chunks
|
18 | 44 | self.transaction_id = transaction_id
|
19 | 45 |
|
20 | 46 | @classmethod
|
21 |
| - def from_proto(cls, response: mirror_proto.ConsensusTopicResponse) -> "TopicMessage": |
| 47 | + def of_single(cls, response: mirror_proto.ConsensusTopicResponse) -> "TopicMessage": |
22 | 48 | """
|
23 |
| - Parse a Mirror Node response into a simpler object. |
| 49 | + Build a TopicMessage from a single-chunk response. |
24 | 50 | """
|
25 |
| - transaction_id = ( |
26 |
| - response.chunkInfo.initialTransactionID |
27 |
| - if response.HasField("chunkInfo") and response.chunkInfo.HasField("initialTransactionID") |
28 |
| - else None |
| 51 | + chunk = TopicMessageChunk(response) |
| 52 | + consensus_timestamp = chunk.consensus_timestamp |
| 53 | + contents = response.message |
| 54 | + running_hash = response.runningHash |
| 55 | + sequence_number = response.sequence_number |
| 56 | + |
| 57 | + transaction_id = None |
| 58 | + if response.HasField("chunkInfo") and response.chunkInfo.HasField("initialTransactionID"): |
| 59 | + tx_id = response.chunkInfo.initialTransactionID |
| 60 | + transaction_id = ( |
| 61 | + f"{tx_id.shardNum}.{tx_id.realmNum}.{tx_id.accountNum}-" |
| 62 | + f"{tx_id.transactionValidStart.seconds}.{tx_id.transactionValidStart.nanos}" |
| 63 | + ) |
| 64 | + |
| 65 | + return cls( |
| 66 | + consensus_timestamp, |
| 67 | + contents, |
| 68 | + running_hash, |
| 69 | + sequence_number, |
| 70 | + [chunk], |
| 71 | + transaction_id |
29 | 72 | )
|
| 73 | + |
| 74 | + @classmethod |
| 75 | + def of_many(cls, responses: List[mirror_proto.ConsensusTopicResponse]) -> "TopicMessage": |
| 76 | + """ |
| 77 | + Reassemble multiple chunk responses into a single TopicMessage. |
| 78 | + """ |
| 79 | + sorted_responses = sorted(responses, key=lambda r: r.chunkInfo.number) |
| 80 | + |
| 81 | + chunks = [] |
| 82 | + total_size = 0 |
| 83 | + transaction_id = None |
| 84 | + |
| 85 | + for r in sorted_responses: |
| 86 | + c = TopicMessageChunk(r) |
| 87 | + chunks.append(c) |
| 88 | + total_size += len(r.message) |
| 89 | + |
| 90 | + if (transaction_id is None |
| 91 | + and r.HasField("chunkInfo") |
| 92 | + and r.chunkInfo.HasField("initialTransactionID")): |
| 93 | + tx_id = r.chunkInfo.initialTransactionID |
| 94 | + transaction_id = ( |
| 95 | + f"{tx_id.shardNum}.{tx_id.realmNum}.{tx_id.accountNum}-" |
| 96 | + f"{tx_id.transactionValidStart.seconds}.{tx_id.transactionValidStart.nanos}" |
| 97 | + ) |
| 98 | + |
| 99 | + contents = bytearray(total_size) |
| 100 | + offset = 0 |
| 101 | + for r in sorted_responses: |
| 102 | + end = offset + len(r.message) |
| 103 | + contents[offset:end] = r.message |
| 104 | + offset = end |
| 105 | + |
| 106 | + last_r = sorted_responses[-1] |
| 107 | + consensus_timestamp = _to_datetime(last_r.consensusTimestamp) |
| 108 | + running_hash = last_r.runningHash |
| 109 | + sequence_number = last_r.sequenceNumber |
| 110 | + |
30 | 111 | return cls(
|
31 |
| - consensus_timestamp=response.consensusTimestamp, |
32 |
| - message=response.message, |
33 |
| - running_hash=response.runningHash, |
34 |
| - sequence_number=response.sequenceNumber, |
35 |
| - running_hash_version=response.runningHashVersion if response.runningHashVersion != 0 else None, |
36 |
| - chunk_info=response.chunkInfo if response.HasField("chunkInfo") else None, |
37 |
| - transaction_id=transaction_id, |
| 112 | + consensus_timestamp, |
| 113 | + bytes(contents), |
| 114 | + running_hash, |
| 115 | + sequence_number, |
| 116 | + chunks, |
| 117 | + transaction_id |
38 | 118 | )
|
39 | 119 |
|
40 |
| - def __str__(self): |
| 120 | + @classmethod |
| 121 | + def from_proto( |
| 122 | + cls, |
| 123 | + response_or_responses: Union[mirror_proto.ConsensusTopicResponse, List[mirror_proto.ConsensusTopicResponse]], |
| 124 | + chunking_enabled: bool = False |
| 125 | + ) -> "TopicMessage": |
41 | 126 | """
|
42 |
| - Returns a nicely formatted string representation of the topic message. |
| 127 | + Creates a TopicMessage from either: |
| 128 | + - A single ConsensusTopicResponse |
| 129 | + - A list of responses (for multi-chunk) |
| 130 | +
|
| 131 | + If chunking is enabled and multiple chunks are detected, they are reassembled |
| 132 | + into one combined TopicMessage. Otherwise, a single chunk is returned as-is. |
43 | 133 | """
|
44 |
| - timestamp = datetime.utcfromtimestamp(self.consensus_timestamp.seconds).strftime('%Y-%m-%d %H:%M:%S UTC') |
45 |
| - message = self.message.decode('utf-8', errors='ignore') |
46 |
| - running_hash = self.running_hash.hex() |
47 |
| - |
48 |
| - formatted_message = ( |
49 |
| - f"Received Topic Message:\n" |
50 |
| - f" - Timestamp: {timestamp}\n" |
51 |
| - f" - Sequence Number: {self.sequence_number}\n" |
52 |
| - f" - Message: {message}\n" |
53 |
| - f" - Running Hash: {running_hash}\n" |
| 134 | + if isinstance(response_or_responses, mirror_proto.ConsensusTopicResponse): |
| 135 | + response = response_or_responses |
| 136 | + if chunking_enabled and response.HasField("chunkInfo") and response.chunkInfo.total > 1: |
| 137 | + raise ValueError( |
| 138 | + "Cannot handle multi-chunk in a single response. Pass all chunk responses in a list." |
| 139 | + ) |
| 140 | + return cls.of_single(response) |
| 141 | + else: |
| 142 | + if not response_or_responses: |
| 143 | + raise ValueError("Empty response list provided to from_proto().") |
| 144 | + |
| 145 | + if not chunking_enabled and len(response_or_responses) == 1: |
| 146 | + return cls.of_single(response_or_responses[0]) |
| 147 | + |
| 148 | + return cls.of_many(response_or_responses) |
| 149 | + |
| 150 | + def __str__(self): |
| 151 | + contents_str = self.contents.decode("utf-8", errors="replace") |
| 152 | + return ( |
| 153 | + f"TopicMessage(" |
| 154 | + f"consensus_timestamp={self.consensus_timestamp}, " |
| 155 | + f"sequence_number={self.sequence_number}, " |
| 156 | + f"contents='{contents_str[:40]}{'...' if len(contents_str) > 40 else ''}', " |
| 157 | + f"chunk_count={len(self.chunks)}, " |
| 158 | + f"transaction_id={self.transaction_id}" |
| 159 | + f")" |
54 | 160 | )
|
55 |
| - if self.running_hash_version: |
56 |
| - formatted_message += f" - Running Hash Version: {self.running_hash_version}\n" |
57 |
| - if self.chunk_info: |
58 |
| - formatted_message += f" - Chunk Info: {self.chunk_info}\n" |
59 |
| - if self.transaction_id: |
60 |
| - formatted_message += f" - Transaction ID: {self.transaction_id}\n" |
61 |
| - return formatted_message |
|
0 commit comments