letschatty 0.4.342__py3-none-any.whl → 0.4.344__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.
Files changed (88) hide show
  1. letschatty/models/ai_microservices/__init__.py +3 -3
  2. letschatty/models/ai_microservices/expected_output.py +2 -29
  3. letschatty/models/ai_microservices/lambda_events.py +28 -137
  4. letschatty/models/ai_microservices/lambda_invokation_types.py +1 -3
  5. letschatty/models/ai_microservices/n8n_ai_agents_payload.py +1 -3
  6. letschatty/models/analytics/events/__init__.py +2 -3
  7. letschatty/models/analytics/events/chat_based_events/chat_funnel.py +69 -13
  8. letschatty/models/analytics/events/company_based_events/asset_events.py +9 -2
  9. letschatty/models/analytics/events/event_type_to_classes.py +6 -3
  10. letschatty/models/analytics/events/event_types.py +9 -50
  11. letschatty/models/chat/chat.py +13 -2
  12. letschatty/models/chat/chat_with_assets.py +6 -1
  13. letschatty/models/chat/client.py +0 -2
  14. letschatty/models/chat/continuous_conversation.py +1 -1
  15. letschatty/models/company/CRM/funnel.py +365 -33
  16. letschatty/models/company/__init__.py +7 -1
  17. letschatty/models/company/assets/ai_agents_v2/ai_agents_decision_output.py +1 -1
  18. letschatty/models/company/assets/ai_agents_v2/chatty_ai_agent_in_chat.py +0 -4
  19. letschatty/models/company/assets/ai_agents_v2/chatty_ai_mode.py +2 -2
  20. letschatty/models/company/assets/ai_agents_v2/get_chat_with_prompt_response.py +0 -1
  21. letschatty/models/company/assets/ai_agents_v2/pre_qualify_config.py +2 -14
  22. letschatty/models/company/assets/automation.py +10 -19
  23. letschatty/models/company/assets/chat_assets.py +3 -2
  24. letschatty/models/company/assets/company_assets.py +2 -0
  25. letschatty/models/company/assets/sale.py +3 -3
  26. letschatty/models/company/empresa.py +2 -1
  27. letschatty/models/company/integrations/product_sync_status.py +28 -0
  28. letschatty/models/company/integrations/shopify/company_shopify_integration.py +62 -0
  29. letschatty/models/company/integrations/shopify/shopify_product_sync_status.py +12 -36
  30. letschatty/models/company/integrations/shopify/shopify_webhook_topics.py +40 -0
  31. letschatty/models/company/integrations/sync_status_enum.py +9 -0
  32. letschatty/models/data_base/collection_interface.py +29 -101
  33. letschatty/models/data_base/mongo_connection.py +9 -92
  34. letschatty/models/messages/chatty_messages/schema/chatty_content/content_document.py +4 -2
  35. letschatty/models/messages/chatty_messages/schema/chatty_content/content_media.py +4 -3
  36. letschatty/models/utils/custom_exceptions/custom_exceptions.py +1 -14
  37. letschatty/services/ai_agents/smart_follow_up_context_builder_v2.py +2 -5
  38. letschatty/services/chat/chat_service.py +67 -12
  39. letschatty/services/chatty_assets/__init__.py +0 -12
  40. letschatty/services/chatty_assets/asset_service.py +13 -190
  41. letschatty/services/chatty_assets/base_container.py +2 -3
  42. letschatty/services/chatty_assets/base_container_with_collection.py +26 -35
  43. letschatty/services/continuous_conversation_service/continuous_conversation_helper.py +0 -11
  44. letschatty/services/events/events_manager.py +1 -218
  45. letschatty/services/factories/analytics/events_factory.py +6 -66
  46. letschatty/services/factories/lambda_ai_orchestrartor/lambda_events_factory.py +8 -25
  47. letschatty/services/messages_helpers/get_caption_or_body_or_preview.py +4 -6
  48. letschatty/services/validators/analytics_validator.py +11 -0
  49. {letschatty-0.4.342.dist-info → letschatty-0.4.344.dist-info}/METADATA +1 -1
  50. {letschatty-0.4.342.dist-info → letschatty-0.4.344.dist-info}/RECORD +52 -84
  51. letschatty/models/analytics/events/chat_based_events/ai_agent_execution_event.py +0 -71
  52. letschatty/services/chatty_assets/assets_collections.py +0 -137
  53. letschatty/services/chatty_assets/collections/__init__.py +0 -38
  54. letschatty/services/chatty_assets/collections/ai_agent_collection.py +0 -19
  55. letschatty/services/chatty_assets/collections/ai_agent_in_chat_collection.py +0 -32
  56. letschatty/services/chatty_assets/collections/ai_component_collection.py +0 -21
  57. letschatty/services/chatty_assets/collections/chain_of_thought_collection.py +0 -30
  58. letschatty/services/chatty_assets/collections/chat_collection.py +0 -21
  59. letschatty/services/chatty_assets/collections/contact_point_collection.py +0 -21
  60. letschatty/services/chatty_assets/collections/fast_answer_collection.py +0 -21
  61. letschatty/services/chatty_assets/collections/filter_criteria_collection.py +0 -18
  62. letschatty/services/chatty_assets/collections/flow_collection.py +0 -20
  63. letschatty/services/chatty_assets/collections/product_collection.py +0 -20
  64. letschatty/services/chatty_assets/collections/sale_collection.py +0 -20
  65. letschatty/services/chatty_assets/collections/source_collection.py +0 -21
  66. letschatty/services/chatty_assets/collections/tag_collection.py +0 -19
  67. letschatty/services/chatty_assets/collections/topic_collection.py +0 -21
  68. letschatty/services/chatty_assets/collections/user_collection.py +0 -20
  69. letschatty/services/chatty_assets/example_usage.py +0 -44
  70. letschatty/services/chatty_assets/services/__init__.py +0 -37
  71. letschatty/services/chatty_assets/services/ai_agent_in_chat_service.py +0 -73
  72. letschatty/services/chatty_assets/services/ai_agent_service.py +0 -23
  73. letschatty/services/chatty_assets/services/chain_of_thought_service.py +0 -70
  74. letschatty/services/chatty_assets/services/chat_service.py +0 -25
  75. letschatty/services/chatty_assets/services/contact_point_service.py +0 -29
  76. letschatty/services/chatty_assets/services/fast_answer_service.py +0 -32
  77. letschatty/services/chatty_assets/services/filter_criteria_service.py +0 -30
  78. letschatty/services/chatty_assets/services/flow_service.py +0 -25
  79. letschatty/services/chatty_assets/services/product_service.py +0 -30
  80. letschatty/services/chatty_assets/services/sale_service.py +0 -25
  81. letschatty/services/chatty_assets/services/source_service.py +0 -28
  82. letschatty/services/chatty_assets/services/tag_service.py +0 -32
  83. letschatty/services/chatty_assets/services/topic_service.py +0 -31
  84. letschatty/services/chatty_assets/services/user_service.py +0 -32
  85. letschatty/services/events/__init__.py +0 -6
  86. letschatty/services/factories/analytics/ai_agent_event_factory.py +0 -161
  87. {letschatty-0.4.342.dist-info → letschatty-0.4.344.dist-info}/LICENSE +0 -0
  88. {letschatty-0.4.342.dist-info → letschatty-0.4.344.dist-info}/WHEEL +0 -0
@@ -1,3 +1,4 @@
1
+ from letschatty.models.company.integrations.shopify.company_shopify_integration import ShopifyIntegration
1
2
  from pydantic import Field, ConfigDict, field_validator, SecretStr, model_validator
2
3
  from typing import Optional, List, Dict
3
4
 
@@ -34,7 +35,7 @@ class EmpresaModel(ChattyAssetModel):
34
35
  continuous_conversation_template_name: Optional[str] = Field(default = None, description="The name of the continuous conversation template")
35
36
  default_follow_up_strategy_id: Optional[StrObjectId] = Field(default = None, description="The id of the default follow up strategy")
36
37
  messaging_settings: MessagingSettings = Field(default = MessagingSettings(), description="The messaging settings for the company")
37
-
38
+ shopify_integration: ShopifyIntegration = Field(default = ShopifyIntegration(), description="The Shopify integration for the company")
38
39
 
39
40
  model_config = ConfigDict(
40
41
  validate_by_name=True,
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import ClassVar, Optional
5
+
6
+ from pydantic import Field
7
+
8
+ from letschatty.models.company.integrations.sync_status_enum import SyncStatusEnum
9
+ from letschatty.models.base_models import CompanyAssetModel
10
+
11
+
12
+ class ProductSyncStatus(CompanyAssetModel):
13
+ """Generic product sync status for any e-commerce integration."""
14
+
15
+ COLLECTION: ClassVar[str] = "product_sync_statuses"
16
+
17
+ integration_type: str = Field(
18
+ description="Integration type (shopify, tiendanube, etc.)"
19
+ )
20
+ status: SyncStatusEnum = Field(description="Current sync status")
21
+
22
+ products_created: int = Field(default=0)
23
+ products_updated: int = Field(default=0)
24
+
25
+ name: str = Field(default="")
26
+
27
+ finished_at: Optional[datetime] = Field(default=None)
28
+
@@ -0,0 +1,62 @@
1
+ from typing import List, Optional
2
+ from datetime import datetime
3
+ from zoneinfo import ZoneInfo
4
+ from pydantic import BaseModel, Field
5
+
6
+ class ShopifyWebhookSubscription(BaseModel):
7
+ """Represents a single webhook subscription"""
8
+ topic: str = Field(description="Webhook topic (e.g., 'products/create')")
9
+ webhook_id: Optional[str] = Field(default=None, description="Shopify webhook ID")
10
+ subscribed_at: datetime = Field(default_factory=lambda: datetime.now(tz=ZoneInfo("UTC")))
11
+ is_active: bool = Field(default=True, description="Whether subscription is active")
12
+
13
+ class ShopifyIntegration(BaseModel):
14
+ """Shopify integration for the company"""
15
+ shopify_store_url: str = Field(default="")
16
+ oauth_state: str = Field(default="")
17
+ oauth_state_at: datetime = Field(default_factory=lambda: datetime.now(tz=ZoneInfo("UTC")))
18
+ access_token: Optional[str] = Field(default=None)
19
+ connected_at: Optional[datetime] = Field(default=None)
20
+ scope: Optional[str] = Field(default=None)
21
+
22
+ # Webhook subscriptions
23
+ webhook_subscriptions: List[ShopifyWebhookSubscription] = Field(
24
+ default_factory=list,
25
+ description="List of active webhook subscriptions"
26
+ )
27
+
28
+ # Scheduled sync settings
29
+ product_sync_enabled: bool = Field(
30
+ default=False,
31
+ description="Whether scheduled product sync is enabled"
32
+ )
33
+ product_sync_interval_hours: int = Field(
34
+ default=24,
35
+ description="Interval in hours for scheduled product sync"
36
+ )
37
+ last_product_sync_at: Optional[datetime] = Field(
38
+ default=None,
39
+ description="Timestamp of last product sync"
40
+ )
41
+
42
+ @property
43
+ def is_connected(self) -> bool:
44
+ """Check if the integration is fully connected"""
45
+ return bool(self.access_token and self.shopify_store_url)
46
+
47
+ def get_subscribed_topics(self) -> List[str]:
48
+ """Get list of currently subscribed webhook topics"""
49
+ return [sub.topic for sub in self.webhook_subscriptions if sub.is_active]
50
+
51
+ def reset(self) -> None:
52
+ """Reset integration to disconnected state"""
53
+ self.shopify_store_url = ""
54
+ self.oauth_state = ""
55
+ self.oauth_state_at = datetime.now(tz=ZoneInfo("UTC"))
56
+ self.access_token = None
57
+ self.connected_at = None
58
+ self.scope = None
59
+ self.webhook_subscriptions = []
60
+ self.product_sync_enabled = False
61
+ self.product_sync_interval_hours = 24
62
+ self.last_product_sync_at = None
@@ -1,42 +1,18 @@
1
- from enum import Enum
2
- from typing import Optional
3
- from datetime import datetime
4
- from zoneinfo import ZoneInfo
5
1
  from pydantic import Field
6
- from letschatty.models.base_models import CompanyAssetModel
7
2
 
3
+ from letschatty.models.company.integrations.product_sync_status import ProductSyncStatus
4
+ from letschatty.models.company.integrations.sync_status_enum import SyncStatusEnum
8
5
 
9
- class ShopifyProductSyncStatusEnum(str, Enum):
10
- """Status enum for Shopify product sync operations"""
11
- STARTED = "started"
12
- RUNNING = "running"
13
- FINISHED = "finished"
14
- FAILED = "failed"
15
6
 
7
+ # Backwards-compatible alias (Shopify-specific name, generic enum)
8
+ ShopifyProductSyncStatusEnum = SyncStatusEnum
16
9
 
17
- class ShopifyProductSyncStatus(CompanyAssetModel):
18
- """Tracks the status and results of Shopify product synchronization operations.
19
-
20
- Each sync operation creates a new document in the collection with its own status,
21
- timestamps, and product counts.
22
- """
23
- name: str = Field(
24
- default="",
25
- description="Name/identifier for this sync operation"
26
- )
27
- status: ShopifyProductSyncStatusEnum = Field(
28
- default=ShopifyProductSyncStatusEnum.STARTED,
29
- description="Current sync status"
30
- )
31
- finished_at: Optional[datetime] = Field(
32
- default=None,
33
- description="Timestamp when sync finished"
34
- )
35
- products_created: int = Field(
36
- default=0,
37
- description="Number of products created during sync"
38
- )
39
- products_updated: int = Field(
40
- default=0,
41
- description="Number of products updated during sync"
10
+
11
+ class ShopifyProductSyncStatus(ProductSyncStatus):
12
+ """Shopify-flavored wrapper for the generic ProductSyncStatus."""
13
+
14
+ integration_type: str = Field(
15
+ default="shopify",
16
+ frozen=True,
17
+ description="Integration type for this sync status"
42
18
  )
@@ -0,0 +1,40 @@
1
+ from enum import Enum
2
+
3
+ class ShopifyWebhookTopic(str, Enum):
4
+ """Shopify webhook topics for products and orders"""
5
+
6
+ # Product webhooks
7
+ PRODUCTS_CREATE = "products/create"
8
+ PRODUCTS_UPDATE = "products/update"
9
+ PRODUCTS_DELETE = "products/delete"
10
+
11
+ # Order webhooks
12
+ ORDERS_CREATE = "orders/create"
13
+ ORDERS_UPDATE = "orders/updated"
14
+ ORDERS_DELETE = "orders/delete"
15
+ ORDERS_FULFILLED = "orders/fulfilled"
16
+ ORDERS_PARTIALLY_FULFILLED = "orders/partially_fulfilled"
17
+ ORDERS_PAID = "orders/paid"
18
+ ORDERS_CANCELLED = "orders/cancelled"
19
+
20
+ @classmethod
21
+ def get_product_topics(cls) -> list[str]:
22
+ """Get all product-related webhook topics"""
23
+ return [
24
+ cls.PRODUCTS_CREATE.value,
25
+ cls.PRODUCTS_UPDATE.value,
26
+ cls.PRODUCTS_DELETE.value,
27
+ ]
28
+
29
+ @classmethod
30
+ def get_order_topics(cls) -> list[str]:
31
+ """Get all order-related webhook topics"""
32
+ return [
33
+ cls.ORDERS_CREATE.value,
34
+ cls.ORDERS_UPDATE.value,
35
+ cls.ORDERS_DELETE.value,
36
+ cls.ORDERS_FULFILLED.value,
37
+ cls.ORDERS_PARTIALLY_FULFILLED.value,
38
+ cls.ORDERS_PAID.value,
39
+ cls.ORDERS_CANCELLED.value,
40
+ ]
@@ -0,0 +1,9 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SyncStatusEnum(str, Enum):
5
+ STARTED = "STARTED"
6
+ RUNNING = "RUNNING"
7
+ FINISHED = "FINISHED"
8
+ FAILED = "FAILED"
9
+
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Dict, List, Generic, TypeVar, Type, Optional,
4
4
  from bson.objectid import ObjectId
5
5
  from pymongo.collection import Collection
6
6
  from pymongo.database import Database
7
- from motor.motor_asyncio import AsyncIOMotorDatabase, AsyncIOMotorCollection
8
7
 
9
8
  from letschatty.models.chat.chat import Chat
10
9
  from ...models.base_models.chatty_asset_model import ChattyAssetModel, CompanyAssetModel, ChattyAssetPreview
@@ -27,116 +26,58 @@ P = TypeVar('P', bound=ChattyAssetPreview)
27
26
  class ChattyAssetCollectionInterface(Generic[T, P], ABC):
28
27
  def __init__(self, database: str, collection: str, connection: MongoConnection, type: Type[T], preview_type: Optional[Type[P]] = None):
29
28
  logger.info(f"Initializing collection {collection} in database {database}")
30
- # Sync database and collection (existing)
31
29
  self.db: Database = connection.client[database]
32
30
  self.collection: Collection = connection.client[database][collection]
33
-
34
- # NEW: Async database and collection
35
- # Store connection reference to ensure we use current event loop
36
- self._connection = connection
37
- self._database_name = database
38
- self._collection_name = collection
39
- self._async_db: Optional[AsyncIOMotorDatabase] = None
40
- self._async_collection: Optional[AsyncIOMotorCollection] = None
41
-
42
31
  self.type = type
43
32
  self.preview_type = preview_type
44
-
45
- @property
46
- def async_db(self) -> AsyncIOMotorDatabase:
47
- """Get async database, ensuring it uses the current event loop"""
48
- # Always ensure connection's async client is using current loop (for Lambda compatibility)
49
- self._connection._ensure_async_client_loop()
50
- # Recreate database reference to ensure it uses the current client
51
- self._async_db = self._connection.async_client[self._database_name]
52
- return self._async_db
53
-
54
- @property
55
- def async_collection(self) -> AsyncIOMotorCollection:
56
- """Get async collection, ensuring it uses the current event loop"""
57
- # Always ensure connection's async client is using current loop (for Lambda compatibility)
58
- self._connection._ensure_async_client_loop()
59
- # Recreate collection reference to ensure it uses the current client
60
- self._async_collection = self._connection.async_client[self._database_name][self._collection_name]
61
- return self._async_collection
62
33
  @abstractmethod
63
34
  def create_instance(self, data: dict) -> T:
64
35
  """Factory method to create instance from data"""
65
36
  pass
66
37
 
67
- # All methods are now async-only for better performance
68
- async def insert(self, asset: T) -> StrObjectId:
69
- """Async insert operation"""
38
+ def insert(self, asset: T) -> StrObjectId:
70
39
  if not isinstance(asset, self.type):
71
40
  raise ValueError(f"Asset must be of type {self.type.__name__}")
72
41
  document = asset.model_dump_json(serializer=SerializerType.DATABASE)
73
42
  logger.debug(f"Inserting document: {document}")
74
- result = await self.async_collection.insert_one(document)
43
+ result = self.collection.insert_one(document)
75
44
  if not result.inserted_id:
76
45
  raise Exception("Failed to insert document")
77
46
  logger.debug(f"Inserted document with id {result.inserted_id}")
78
47
  return result.inserted_id
79
48
 
80
- async def update(self, asset: T) -> StrObjectId:
81
- """Async update operation"""
49
+ def update(self, asset: T) -> StrObjectId:
82
50
  logger.debug(f"Updating document with id {asset.id}")
83
51
  if not isinstance(asset, self.type):
84
52
  raise ValueError(f"Asset must be of type {self.type.__name__}")
85
53
  asset.update_now()
86
54
  document = asset.model_dump_json(serializer=SerializerType.DATABASE)
87
- document.pop('_id', None)
88
- result = await self.async_collection.update_one(
89
- {"_id": ObjectId(asset.id)},
90
- {"$set": document}
91
- )
55
+ document.pop('_id', None) # Still needed
56
+ result = self.collection.update_one({"_id": ObjectId(asset.id)}, {"$set": document})
92
57
  if result.matched_count == 0:
93
58
  raise NotFoundError(f"No document found with id {asset.id}")
94
59
  if result.modified_count == 0:
95
60
  logger.debug(f"No changes were made to the document with id {asset.id} probably because the values were the same")
96
61
  return asset.id
97
62
 
98
- async def get_by_id(self, doc_id: str) -> T:
99
- """Get by ID operation"""
100
- logger.debug(f"Getting document with id {doc_id} from collection {self.async_collection.name}")
101
- doc = await self.async_collection.find_one({"_id": ObjectId(doc_id)})
63
+ def get_by_id(self, doc_id: str) -> T:
64
+ logger.debug(f"Getting document with id {doc_id} from collection {self.collection.name} and db {self.db.name}")
65
+ doc = self.collection.find_one({"_id": ObjectId(doc_id)})
66
+
102
67
  if doc:
103
68
  return self.create_instance(doc)
104
69
  else:
105
- raise NotFoundError(f"No document found with id {doc_id} in collection")
70
+ raise NotFoundError(f"No document found with id {doc_id} in db collection {self.collection.name} and db {self.db.name}")
106
71
 
107
- async def get_docs(self, company_id: Optional[StrObjectId], query={}, limit=0) -> List[T]:
108
- """Get multiple documents operation"""
109
- logger.debug(f"Getting documents from collection with company_id {company_id} and query {query}")
72
+ def get_docs(self, company_id:Optional[StrObjectId], query = {}, limit = 0) -> List[T]:
73
+ logger.debug(f"Getting documents from collection {self.collection.name} with company_id {company_id} and query {query}")
110
74
  if company_id:
111
- query = query.copy()
75
+ query = query.copy() # Create a copy to avoid modifying the original
112
76
  query["company_id"] = company_id
113
- cursor = self.async_collection.find(filter=query)
114
- if limit:
115
- cursor = cursor.limit(limit)
116
- docs = await cursor.to_list(length=limit if limit > 0 else None)
117
- logger.debug(f"Found {len(docs)} documents")
77
+ docs = list(self.collection.find(filter=query).limit(limit))
78
+ logger.debug(f"Found {len(docs)} documents in collection {self.collection.name}")
118
79
  return [self.create_instance(doc) for doc in docs]
119
80
 
120
- async def delete(self, doc_id: str, deletion_type: DeletionType = DeletionType.LOGICAL) -> StrObjectId:
121
- """Delete operation"""
122
- logger.debug(f"Deleting document with id {doc_id} - deletion type: {deletion_type}")
123
- if deletion_type == DeletionType.LOGICAL:
124
- result = await self.async_collection.update_one(
125
- {"_id": ObjectId(doc_id)},
126
- {"$set": {"deleted_at": datetime.now(ZoneInfo("UTC")), "updated_at": datetime.now(ZoneInfo("UTC"))}}
127
- )
128
- if result.modified_count == 0:
129
- raise NotFoundError(f"No document found with id {doc_id}")
130
- return doc_id
131
- elif deletion_type == DeletionType.PHYSICAL:
132
- result = await self.async_collection.delete_one({"_id": ObjectId(doc_id)})
133
- if result.deleted_count == 0:
134
- raise NotFoundError(f"No document found with id {doc_id}")
135
- return doc_id
136
- else:
137
- raise ValueError(f"Invalid deletion type: {deletion_type}")
138
-
139
- # Additional methods - keeping these sync as they're less critical
140
81
  def get_preview_docs(self, projection = {}, all=True) -> List[P]:
141
82
  """We get the previews of all the documents in the collection for all companies"""
142
83
  if not self.preview_type:
@@ -157,31 +98,18 @@ class ChattyAssetCollectionInterface(Generic[T, P], ABC):
157
98
  docs = self.collection.find(query)
158
99
  return [self.create_instance(doc) for doc in docs]
159
100
 
160
- async def get_by_ids(self, ids: List[StrObjectId]) -> List[T]:
161
- """
162
- Get multiple assets by their IDs in a single query.
163
-
164
- Args:
165
- ids: List of asset IDs
166
-
167
- Returns:
168
- List of assets objects
169
- """
170
- if not ids:
171
- return []
172
-
173
- # Convert string IDs to ObjectIds
174
- object_ids = [ObjectId(id) for id in ids]
175
-
176
- # Query for all filter criteria with matching IDs
177
- query = {
178
- "_id": {"$in": object_ids},
179
- "deleted_at": None
180
- }
181
-
182
- # Use the sync collection directly (inherited from ChattyAssetCollectionInterface)
183
- docs = await self.async_collection.find(query).to_list(length=None)
184
-
185
- # Create FilterCriteria instances
186
- return [self.create_instance(doc) for doc in docs]
101
+ def delete(self, doc_id: str, deletion_type : DeletionType = DeletionType.LOGICAL) -> StrObjectId:
102
+ logger.debug(f"Deleting document with id {doc_id} - deletion type: {deletion_type}")
103
+ if deletion_type == DeletionType.LOGICAL:
104
+ result = self.collection.update_one({"_id": ObjectId(doc_id)}, {"$set": {"deleted_at": datetime.now(ZoneInfo("UTC")), "updated_at": datetime.now(ZoneInfo("UTC"))}})
105
+ if result.modified_count == 0:
106
+ raise NotFoundError(f"No document found with id {doc_id}")
107
+ return doc_id
108
+ elif deletion_type == DeletionType.PHYSICAL:
109
+ result = self.collection.delete_one({"_id": ObjectId(doc_id)})
110
+ if result.deleted_count == 0:
111
+ raise NotFoundError(f"No document found with id {doc_id}")
112
+ return doc_id
113
+ else:
114
+ raise ValueError(f"Invalid deletion type: {deletion_type}")
187
115
 
@@ -1,14 +1,8 @@
1
1
  from ..base_models.singleton import SingletonMeta
2
2
  from pymongo import MongoClient
3
- from motor.motor_asyncio import AsyncIOMotorClient
4
3
  from typing import Optional
5
- from urllib.parse import quote_plus
6
4
  import os
7
5
  import atexit
8
- import asyncio
9
- import logging
10
-
11
- logger = logging.getLogger(__name__)
12
6
 
13
7
  class MongoConnection(metaclass=SingletonMeta):
14
8
  def __init__(
@@ -16,103 +10,26 @@ class MongoConnection(metaclass=SingletonMeta):
16
10
  username: Optional[str] = None,
17
11
  password: Optional[str] = None,
18
12
  uri_base: Optional[str] = None,
19
- instance: Optional[str] = None,
20
- verify_on_init: bool = True
13
+ instance: Optional[str] = None
21
14
  ):
22
15
  self.username = username or os.getenv('MONGO_USERNAME')
23
16
  self.password = password or os.getenv('MONGO_PASSWORD')
24
17
  self.uri_base = uri_base or os.getenv('MONGO_URI_BASE')
25
18
  self.instance = instance or os.getenv('MONGO_INSTANCE_COMPONENT')
26
-
19
+
27
20
  if not all([self.username, self.password, self.uri_base, self.instance]):
28
21
  raise ValueError("Missing required MongoDB connection parameters")
29
-
30
- # URL-encode username and password to handle special characters per RFC 3986
31
- encoded_username = quote_plus(self.username)
32
- encoded_password = quote_plus(self.password)
33
-
34
- uri = f"{self.uri_base}://{encoded_username}:{encoded_password}@{self.instance}.mongodb.net"
35
-
36
- # Sync client (existing)
22
+
23
+ uri = f"{self.uri_base}://{self.username}:{self.password}@{self.instance}.mongodb.net"
37
24
  self.client = MongoClient(uri)
38
-
39
- # NEW: Async client for async operations
40
- # Don't pass io_loop - Motor will automatically use the current event loop
41
- # This is important for Lambda where the event loop changes between invocations
42
- self.async_client = AsyncIOMotorClient(uri)
43
-
44
- # Verify connections if requested
45
- if verify_on_init:
46
- try:
47
- # Try to get running loop
48
- loop = asyncio.get_running_loop()
49
- # If we get here, there's a running loop
50
- logger.warning(
51
- "Event loop is already running. Skipping connection verification in __init__. "
52
- "Call verify_connection_async() from async context to verify connection."
53
- )
54
- self._connection_verified = False
55
- except RuntimeError:
56
- # No running loop, safe to use run_until_complete
57
- try:
58
- # Test sync client
59
- self.client.admin.command('ping')
60
-
61
- # Test async client in sync context
62
- loop = asyncio.new_event_loop()
63
- asyncio.set_event_loop(loop)
64
- loop.run_until_complete(self.async_client.admin.command('ping'))
65
- self._connection_verified = True
66
- loop.close()
67
- except Exception as e:
68
- self.client.close()
69
- self.async_client.close()
70
- raise ConnectionError(f"Failed to connect to MongoDB: {str(e)}")
71
- else:
72
- self._connection_verified = False
73
-
74
- atexit.register(self.close)
75
-
76
- def _ensure_async_client_loop(self):
77
- """Ensure async client is using the current event loop (for Lambda compatibility)"""
78
25
  try:
79
- current_loop = asyncio.get_running_loop()
80
- # Check if client's loop is closed or different
81
- client_loop = getattr(self.async_client, '_io_loop', None)
82
- if client_loop is not None:
83
- try:
84
- # Try to check if the loop is closed
85
- if client_loop.is_closed():
86
- # Recreate client with current loop
87
- logger.warning("Async client's event loop is closed, recreating client")
88
- old_client = self.async_client
89
- uri = f"{self.uri_base}://{quote_plus(self.username)}:{quote_plus(self.password)}@{self.instance}.mongodb.net"
90
- self.async_client = AsyncIOMotorClient(uri)
91
- try:
92
- old_client.close()
93
- except:
94
- pass
95
- except AttributeError:
96
- # _io_loop might not exist in newer Motor versions
97
- pass
98
- except RuntimeError:
99
- # No running loop, which is fine - Motor will handle it
100
- pass
101
-
102
- async def verify_connection_async(self) -> bool:
103
- """Verify MongoDB connection asynchronously. Safe to call from async context."""
104
- try:
105
- # Ensure we're using the current event loop
106
- self._ensure_async_client_loop()
107
- await self.async_client.admin.command('ping')
108
- self._connection_verified = True
109
- return True
26
+ self.client.admin.command('ping')
110
27
  except Exception as e:
111
- logger.error(f"Failed to verify MongoDB connection: {e}")
112
- raise ConnectionError(f"Failed to verify MongoDB connection: {e}")
28
+ self.client.close()
29
+ raise ConnectionError(f"Failed to connect to MongoDB: {str(e)}")
113
30
 
31
+ atexit.register(self.close)
32
+
114
33
  def close(self) -> None:
115
34
  if hasattr(self, 'client'):
116
35
  self.client.close()
117
- if hasattr(self, 'async_client'):
118
- self.async_client.close()
@@ -1,5 +1,6 @@
1
1
  from pydantic import BaseModel, Field, model_validator, ValidationInfo
2
2
  from typing import Optional
3
+ from urllib.parse import urlparse, unquote
3
4
  from .content_media import ChattyContentMedia
4
5
 
5
6
  class ChattyContentDocument(ChattyContentMedia):
@@ -8,5 +9,6 @@ class ChattyContentDocument(ChattyContentMedia):
8
9
  @model_validator(mode='before')
9
10
  def validate_filename(cls, data: dict, info: ValidationInfo):
10
11
  if not data.get("filename") and data.get("url"):
11
- data["filename"] = data["url"].split("/")[-1]
12
- return data
12
+ parsed = urlparse(data["url"])
13
+ data["filename"] = unquote(parsed.path.split("/")[-1])
14
+ return data
@@ -1,5 +1,6 @@
1
1
  from pydantic import BaseModel, Field, field_validator, HttpUrl
2
2
  from typing import Optional
3
+ from urllib.parse import quote
3
4
  class ChattyContentMedia(BaseModel):
4
5
  id: Optional[str] = Field(description="Unique identifier for the image. Also known as media_id", default="")
5
6
  url: str = Field(description="URL of the media from S3")
@@ -11,9 +12,9 @@ class ChattyContentMedia(BaseModel):
11
12
  def validate_url(cls, v):
12
13
  if not v:
13
14
  raise ValueError("URL is required")
14
- HttpUrl(v)
15
- return v
15
+ encoded = quote(str(v), safe=":/?&=%#")
16
+ HttpUrl(encoded)
17
+ return encoded
16
18
 
17
19
  def get_body_or_caption(self) -> str:
18
20
  return self.caption
19
-
@@ -5,7 +5,6 @@ import json
5
5
  from datetime import timedelta
6
6
 
7
7
  from letschatty.models.utils.definitions import Area
8
- from pydantic_core.core_schema import custom_error_schema
9
8
  logger = logging.getLogger("logger")
10
9
 
11
10
  class Context(BaseModel):
@@ -230,16 +229,4 @@ class OpenAIError(CustomException):
230
229
 
231
230
  class NewerAvailableMessageToBeProcessedByAiAgent(CustomException):
232
231
  def __init__(self, message="Duplicated incoming message call for ai agent", status_code=400, **context_data):
233
- super().__init__(message, status_code=status_code, **context_data)
234
-
235
- class MissingAIAgentInChat(CustomException):
236
- def __init__(self, message="Missing AI agent in chat", status_code=400, **context_data):
237
- super().__init__(message, status_code=status_code, **context_data)
238
-
239
- class ChattyAIModeOff(CustomException):
240
- def __init__(self, message="Chatty AI agent is in OFF mode", status_code=400, **context_data):
241
- super().__init__(message, status_code=status_code, **context_data)
242
-
243
- class ChatWithActiveContinuousConversation(CustomException):
244
- def __init__(self, message="Chat has active continuous conversation", status_code=400, **context_data):
245
- super().__init__(message, status_code=status_code, **context_data)
232
+ super().__init__(message, status_code=status_code, **context_data)
@@ -14,16 +14,13 @@ class SmartFollowUpContextBuilder(ContextBuilder):
14
14
 
15
15
  @staticmethod
16
16
  def check_minimum_time_since_last_message(chat: Chat, follow_up_strategy: FollowUpStrategy,smart_follow_up_state: FlowStateAssignedToChat) -> bool:
17
- # consecutive_count is 0-indexed (0 = no follow-ups sent yet), but get_interval_for_followup expects 1-indexed
18
- # So we add 1 to get the interval for the follow-up we're about to send
19
- next_followup_number = smart_follow_up_state.consecutive_count + 1
20
- expected_interval_minutes = follow_up_strategy.get_interval_for_followup(next_followup_number)
17
+ expected_interval_minutes = follow_up_strategy.get_interval_for_followup(smart_follow_up_state.consecutive_count)
21
18
  last_message_timestamp = chat.last_message_timestamp
22
19
  if last_message_timestamp is None:
23
20
  raise HumanInterventionRequired("There's no last message in the chat, can't validate the minimum time since last message for the smart follow up")
24
21
  time_since_last_message = datetime.now(ZoneInfo('UTC')) - last_message_timestamp
25
22
  if time_since_last_message.total_seconds() < expected_interval_minutes * 60:
26
- raise PostponeFollowUp(time_delta= timedelta(seconds=expected_interval_minutes * 60 - time_since_last_message.total_seconds()), message=f"Se pospuso el Smart Follow Up porque no ha pasado el tiempo mínimo esperado de {expected_interval_minutes/60} horas para el seguimiento #{next_followup_number}")
23
+ raise PostponeFollowUp(time_delta= timedelta(seconds=expected_interval_minutes * 60 - time_since_last_message.total_seconds()), message=f"Se pospuso el Smart Follow Up porque no ha pasado el tiempo mínimo esperado de {expected_interval_minutes/60} horas para el seguimiento #{smart_follow_up_state.consecutive_count}")
27
24
  return True
28
25
 
29
26