geek-cafe-saas-sdk 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of geek-cafe-saas-sdk might be problematic. Click here for more details.

Files changed (194) hide show
  1. geek_cafe_saas_sdk/__init__.py +9 -0
  2. geek_cafe_saas_sdk/core/__init__.py +11 -0
  3. geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
  4. geek_cafe_saas_sdk/core/error_codes.py +132 -0
  5. geek_cafe_saas_sdk/core/service_errors.py +19 -0
  6. geek_cafe_saas_sdk/core/service_result.py +121 -0
  7. geek_cafe_saas_sdk/decorators/__init__.py +64 -0
  8. geek_cafe_saas_sdk/decorators/auth.py +373 -0
  9. geek_cafe_saas_sdk/decorators/core.py +358 -0
  10. geek_cafe_saas_sdk/domains/__init__.py +0 -0
  11. geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
  12. geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
  13. geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
  14. geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
  15. geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
  16. geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
  17. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
  18. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
  19. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
  20. geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
  21. geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
  22. geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
  23. geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
  24. geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
  25. geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
  26. geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
  27. geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
  28. geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
  29. geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
  30. geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
  31. geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
  32. geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
  33. geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
  34. geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
  35. geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
  36. geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
  37. geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
  38. geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
  39. geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
  40. geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
  41. geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
  42. geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
  43. geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
  44. geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
  45. geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
  46. geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
  47. geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
  48. geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
  49. geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
  50. geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
  51. geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
  52. geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
  53. geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
  54. geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
  55. geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
  56. geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
  57. geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
  58. geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
  59. geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
  60. geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
  61. geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
  62. geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
  63. geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
  64. geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
  65. geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
  66. geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
  67. geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
  68. geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
  69. geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
  70. geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
  71. geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
  72. geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
  73. geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
  74. geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
  75. geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
  76. geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
  77. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
  78. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
  79. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
  80. geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
  81. geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
  82. geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
  83. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
  84. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
  85. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
  86. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
  87. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
  88. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
  89. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
  90. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
  91. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
  92. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
  93. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
  94. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
  95. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
  96. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
  97. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
  98. geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
  99. geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
  100. geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
  101. geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
  102. geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
  103. geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
  104. geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
  105. geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
  106. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
  107. geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
  108. geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
  109. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
  110. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
  111. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
  112. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
  113. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
  114. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
  115. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
  116. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
  117. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
  118. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
  119. geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
  120. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
  121. geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
  122. geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
  123. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
  124. geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
  125. geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
  126. geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
  127. geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
  128. geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
  129. geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
  130. geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
  131. geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
  132. geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
  133. geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
  134. geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
  135. geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
  136. geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
  137. geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
  138. geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
  139. geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
  140. geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
  141. geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
  142. geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
  143. geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
  144. geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
  145. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
  146. geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
  147. geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
  148. geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
  149. geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
  150. geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
  151. geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
  152. geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
  153. geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
  154. geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
  155. geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
  156. geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
  157. geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
  158. geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
  159. geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
  160. geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
  161. geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
  162. geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
  163. geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
  164. geek_cafe_saas_sdk/middleware/__init__.py +36 -0
  165. geek_cafe_saas_sdk/middleware/auth.py +85 -0
  166. geek_cafe_saas_sdk/middleware/authorization.py +523 -0
  167. geek_cafe_saas_sdk/middleware/cors.py +63 -0
  168. geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
  169. geek_cafe_saas_sdk/middleware/validation.py +80 -0
  170. geek_cafe_saas_sdk/models/__init__.py +20 -0
  171. geek_cafe_saas_sdk/models/base_model.py +233 -0
  172. geek_cafe_saas_sdk/services/__init__.py +18 -0
  173. geek_cafe_saas_sdk/services/database_service.py +441 -0
  174. geek_cafe_saas_sdk/utilities/__init__.py +88 -0
  175. geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
  176. geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
  177. geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
  178. geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
  179. geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
  180. geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
  181. geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
  182. geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
  183. geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
  184. geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
  185. geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
  186. geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
  187. geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
  188. geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
  189. geek_cafe_saas_sdk/utilities/response.py +209 -0
  190. geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
  191. geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
  192. geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
  193. geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
  194. geek_cafe_saas_sdk-0.6.0.dist-info/licenses/LICENSE +47 -0
@@ -0,0 +1,426 @@
1
+ """
2
+ Geek Cafe, LLC
3
+ MIT License. See Project Root for the license information.
4
+
5
+ ChatMessage model for individual messages in chat channels.
6
+ """
7
+
8
+ from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
9
+ from boto3_assist.utilities.string_utility import StringUtility
10
+ import datetime as dt
11
+ from typing import List, Optional, Dict, Any
12
+ from geek_cafe_saas_sdk.models.base_model import BaseModel
13
+
14
+
15
+ class ChatMessage(BaseModel):
16
+ """
17
+ ChatMessage model for individual messages in chat channels.
18
+
19
+ Stored separately from ChatChannel to avoid document size limits and enable:
20
+ - Efficient pagination
21
+ - Concurrent message posting
22
+ - Message-level operations (edit, delete, react)
23
+ - Threaded replies
24
+
25
+ Features:
26
+ - Message content with rich text support
27
+ - Sender information
28
+ - Threading support (parent message ID)
29
+ - Reactions (emoji reactions)
30
+ - Mentions (@user)
31
+ - Attachments
32
+ - Edit history
33
+ - Read receipts (via separate model)
34
+ """
35
+
36
+ def __init__(self):
37
+ super().__init__()
38
+ self._channel_id: str | None = None
39
+ self._content: str | None = None
40
+
41
+ # Sender information
42
+ self._sender_id: str | None = None
43
+ self._sender_name: str | None = None
44
+
45
+ # Threading support
46
+ self._parent_message_id: str | None = None # For threaded replies
47
+ self._thread_count: int = 0 # Number of replies to this message
48
+
49
+ # Reactions {emoji: [user_ids]}
50
+ self._reactions: Dict[str, List[str]] = {}
51
+
52
+ # Rich content
53
+ self._attachments: List[Dict[str, Any]] = [] # URLs, images, files
54
+ self._mentions: List[str] = [] # User IDs mentioned
55
+
56
+ # Edit tracking
57
+ self._edited_at: float | None = None
58
+ self._edit_count: int = 0
59
+
60
+ # Message type
61
+ self._message_type: str = "message" # message, system, announcement
62
+
63
+ # Sharding configuration (runtime attribute, passed from channel)
64
+ # Not persisted separately - computed from channel config at write time
65
+ self._sharding_config: Dict[str, Any] | None = None
66
+
67
+ self._setup_indexes()
68
+
69
+ def _setup_indexes(self):
70
+ """Setup DynamoDB indexes for efficient querying."""
71
+
72
+ # Primary index: messages by ID
73
+ primary: DynamoDBIndex = DynamoDBIndex()
74
+ primary.name = "primary"
75
+ primary.partition_key.attribute_name = "pk"
76
+ primary.partition_key.value = lambda: DynamoDBKey.build_key(("msg", self.id))
77
+ primary.sort_key.attribute_name = "sk"
78
+ primary.sort_key.value = lambda: DynamoDBKey.build_key(("msg", self.id))
79
+ self.indexes.add_primary(primary)
80
+
81
+ # GSI1: Query messages by channel (most common - pagination)
82
+ # Allows: "Show me messages in this channel, paginated by time"
83
+ # Supports optional sharding for high-traffic channels
84
+ gsi: DynamoDBIndex = DynamoDBIndex()
85
+ gsi.name = "gsi1"
86
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
87
+ gsi.partition_key.value = self._compute_gsi1_pk # Computed with optional sharding
88
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
89
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
90
+ ("ts", self.created_utc_ts)
91
+ )
92
+ self.indexes.add_secondary(gsi)
93
+
94
+ # GSI2: Query threaded replies by parent message
95
+ # Allows: "Show me all replies to this message"
96
+ gsi: DynamoDBIndex = DynamoDBIndex()
97
+ gsi.name = "gsi2"
98
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
99
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
100
+ ("parent", self.parent_message_id)
101
+ )
102
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
103
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
104
+ ("ts", self.created_utc_ts)
105
+ )
106
+ self.indexes.add_secondary(gsi)
107
+
108
+ # GSI3: Query messages by sender (user's message history)
109
+ # Allows: "Show me all messages from this user"
110
+ gsi: DynamoDBIndex = DynamoDBIndex()
111
+ gsi.name = "gsi3"
112
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
113
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
114
+ ("sender", self.sender_id)
115
+ )
116
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
117
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
118
+ ("ts", self.created_utc_ts)
119
+ )
120
+ self.indexes.add_secondary(gsi)
121
+
122
+ # GSI4: Query messages by tenant (for admin/analytics)
123
+ gsi: DynamoDBIndex = DynamoDBIndex()
124
+ gsi.name = "gsi4"
125
+ gsi.partition_key.attribute_name = f"{gsi.name}_pk"
126
+ gsi.partition_key.value = lambda: DynamoDBKey.build_key(
127
+ ("tenant", self.tenant_id)
128
+ )
129
+ gsi.sort_key.attribute_name = f"{gsi.name}_sk"
130
+ gsi.sort_key.value = lambda: DynamoDBKey.build_key(
131
+ ("model", "message"),
132
+ ("ts", self.created_utc_ts)
133
+ )
134
+ self.indexes.add_secondary(gsi)
135
+
136
+ # Channel ID
137
+ @property
138
+ def channel_id(self) -> str | None:
139
+ return self._channel_id
140
+
141
+ @channel_id.setter
142
+ def channel_id(self, value: str | None):
143
+ self._channel_id = value
144
+
145
+ # Content
146
+ @property
147
+ def content(self) -> str | None:
148
+ return self._content
149
+
150
+ @content.setter
151
+ def content(self, value: str | None):
152
+ self._content = value
153
+
154
+ # Sender ID
155
+ @property
156
+ def sender_id(self) -> str | None:
157
+ return self._sender_id
158
+
159
+ @sender_id.setter
160
+ def sender_id(self, value: str | None):
161
+ self._sender_id = value
162
+
163
+ # Sender Name
164
+ @property
165
+ def sender_name(self) -> str | None:
166
+ return self._sender_name
167
+
168
+ @sender_name.setter
169
+ def sender_name(self, value: str | None):
170
+ self._sender_name = value
171
+
172
+ # Parent Message ID
173
+ @property
174
+ def parent_message_id(self) -> str | None:
175
+ return self._parent_message_id
176
+
177
+ @parent_message_id.setter
178
+ def parent_message_id(self, value: str | None):
179
+ self._parent_message_id = value
180
+
181
+ # Thread Count
182
+ @property
183
+ def thread_count(self) -> int:
184
+ return self._thread_count
185
+
186
+ @thread_count.setter
187
+ def thread_count(self, value: int | None):
188
+ self._thread_count = value if value is not None else 0
189
+
190
+ # Reactions
191
+ @property
192
+ def reactions(self) -> Dict[str, List[str]]:
193
+ return self._reactions
194
+
195
+ @reactions.setter
196
+ def reactions(self, value: Dict[str, List[str]] | None):
197
+ if value is None:
198
+ self._reactions = {}
199
+ elif isinstance(value, dict):
200
+ self._reactions = value
201
+ else:
202
+ self._reactions = {}
203
+
204
+ # Attachments
205
+ @property
206
+ def attachments(self) -> List[Dict[str, Any]]:
207
+ return self._attachments
208
+
209
+ @attachments.setter
210
+ def attachments(self, value: List[Dict[str, Any]] | None):
211
+ if value is None:
212
+ self._attachments = []
213
+ elif isinstance(value, list):
214
+ self._attachments = value
215
+ else:
216
+ self._attachments = []
217
+
218
+ # Mentions
219
+ @property
220
+ def mentions(self) -> List[str]:
221
+ return self._mentions
222
+
223
+ @mentions.setter
224
+ def mentions(self, value: List[str] | None):
225
+ if value is None:
226
+ self._mentions = []
227
+ elif isinstance(value, list):
228
+ self._mentions = value
229
+ else:
230
+ self._mentions = []
231
+
232
+ # Edited At
233
+ @property
234
+ def edited_at(self) -> float | None:
235
+ return self._edited_at
236
+
237
+ @edited_at.setter
238
+ def edited_at(self, value: float | None):
239
+ self._edited_at = value
240
+
241
+ # Edit Count
242
+ @property
243
+ def edit_count(self) -> int:
244
+ return self._edit_count
245
+
246
+ @edit_count.setter
247
+ def edit_count(self, value: int | None):
248
+ self._edit_count = value if value is not None else 0
249
+
250
+ # Message Type
251
+ @property
252
+ def message_type(self) -> str:
253
+ return self._message_type
254
+
255
+ @message_type.setter
256
+ def message_type(self, value: str | None):
257
+ valid_types = ["message", "system", "announcement"]
258
+ if value in valid_types:
259
+ self._message_type = value
260
+ else:
261
+ self._message_type = "message" # default
262
+
263
+ # Helper Methods
264
+
265
+ def add_reaction(self, emoji: str, user_id: str):
266
+ """
267
+ Add a reaction to this message.
268
+
269
+ Args:
270
+ emoji: Emoji to add (e.g., "👍", "❤️")
271
+ user_id: User ID adding the reaction
272
+ """
273
+ if emoji not in self._reactions:
274
+ self._reactions[emoji] = []
275
+
276
+ if user_id not in self._reactions[emoji]:
277
+ self._reactions[emoji].append(user_id)
278
+
279
+ def remove_reaction(self, emoji: str, user_id: str):
280
+ """
281
+ Remove a reaction from this message.
282
+
283
+ Args:
284
+ emoji: Emoji to remove
285
+ user_id: User ID removing the reaction
286
+ """
287
+ if emoji in self._reactions and user_id in self._reactions[emoji]:
288
+ self._reactions[emoji].remove(user_id)
289
+
290
+ # Clean up empty emoji lists
291
+ if not self._reactions[emoji]:
292
+ del self._reactions[emoji]
293
+
294
+ def get_reaction_count(self, emoji: str) -> int:
295
+ """
296
+ Get the count of reactions for a specific emoji.
297
+
298
+ Args:
299
+ emoji: Emoji to count
300
+
301
+ Returns:
302
+ Number of users who reacted with this emoji
303
+ """
304
+ return len(self._reactions.get(emoji, []))
305
+
306
+ def has_user_reacted(self, emoji: str, user_id: str) -> bool:
307
+ """
308
+ Check if a user has reacted with a specific emoji.
309
+
310
+ Args:
311
+ emoji: Emoji to check
312
+ user_id: User ID to check
313
+
314
+ Returns:
315
+ True if user has reacted, False otherwise
316
+ """
317
+ return user_id in self._reactions.get(emoji, [])
318
+
319
+ def add_mention(self, user_id: str):
320
+ """
321
+ Add a user mention to this message.
322
+
323
+ Args:
324
+ user_id: User ID to mention
325
+ """
326
+ if user_id not in self._mentions:
327
+ self._mentions.append(user_id)
328
+
329
+ def is_thread_parent(self) -> bool:
330
+ """
331
+ Check if this message is a parent of a thread.
332
+
333
+ Returns:
334
+ True if this message has replies, False otherwise
335
+ """
336
+ return self._thread_count > 0
337
+
338
+ def is_thread_reply(self) -> bool:
339
+ """
340
+ Check if this message is a reply in a thread.
341
+
342
+ Returns:
343
+ True if this is a reply, False otherwise
344
+ """
345
+ return self._parent_message_id is not None
346
+
347
+ def mark_as_edited(self):
348
+ """Mark this message as edited."""
349
+ self._edited_at = dt.datetime.now(dt.UTC).timestamp()
350
+ self._edit_count += 1
351
+
352
+ def increment_thread_count(self):
353
+ """Increment the thread reply count."""
354
+ self._thread_count += 1
355
+
356
+ # Sharding Helper Methods
357
+
358
+ def _compute_gsi1_pk(self) -> str:
359
+ """
360
+ Compute GSI1 partition key with optional bucketing/sharding.
361
+
362
+ Strategy:
363
+ - Normal channel: "channel#<id>"
364
+ - Sharded channel: "channel#<id>#bucket#<yyyyMMdd>#shard#<n>"
365
+
366
+ Returns:
367
+ Partition key string for GSI1
368
+ """
369
+ base_key = ("channel", self.channel_id)
370
+
371
+ # Check if sharding is enabled (requires channel config)
372
+ if self._sharding_config and self._sharding_config.get("enabled"):
373
+ bucket = self._get_time_bucket(
374
+ self.created_utc_ts,
375
+ self._sharding_config.get("bucket_span", "day")
376
+ )
377
+ shard = self._get_shard_index(
378
+ self.id,
379
+ self._sharding_config.get("shard_count", 1)
380
+ )
381
+
382
+ return DynamoDBKey.build_key(
383
+ base_key,
384
+ ("bucket", bucket),
385
+ ("shard", str(shard))
386
+ )
387
+
388
+ # Default: no sharding
389
+ return DynamoDBKey.build_key(base_key)
390
+
391
+ @staticmethod
392
+ def _get_time_bucket(timestamp: float, span: str) -> str:
393
+ """
394
+ Get time bucket string for partitioning messages.
395
+
396
+ Args:
397
+ timestamp: UTC timestamp
398
+ span: "day" or "hour"
399
+
400
+ Returns:
401
+ Bucket string (yyyyMMdd or yyyyMMddHH)
402
+ """
403
+ from datetime import datetime, timezone
404
+ dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
405
+ return dt.strftime("%Y%m%d" if span == "day" else "%Y%m%d%H")
406
+
407
+ @staticmethod
408
+ def _get_shard_index(message_id: str, shard_count: int) -> int:
409
+ """
410
+ Compute consistent shard index for message distribution.
411
+
412
+ Uses MD5 hash of message_id for consistent distribution.
413
+
414
+ Args:
415
+ message_id: Message ID
416
+ shard_count: Number of shards (1-8)
417
+
418
+ Returns:
419
+ Shard index (0 to shard_count-1)
420
+ """
421
+ if shard_count <= 1:
422
+ return 0
423
+
424
+ import hashlib
425
+ h = hashlib.md5(message_id.encode()).hexdigest()
426
+ return int(h[:8], 16) % shard_count