fusesell 1.2.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 fusesell might be problematic. Click here for more details.
- fusesell-1.2.0.dist-info/METADATA +872 -0
- fusesell-1.2.0.dist-info/RECORD +31 -0
- fusesell-1.2.0.dist-info/WHEEL +5 -0
- fusesell-1.2.0.dist-info/entry_points.txt +2 -0
- fusesell-1.2.0.dist-info/licenses/LICENSE +21 -0
- fusesell-1.2.0.dist-info/top_level.txt +2 -0
- fusesell.py +15 -0
- fusesell_local/__init__.py +37 -0
- fusesell_local/api.py +341 -0
- fusesell_local/cli.py +1450 -0
- fusesell_local/config/__init__.py +11 -0
- fusesell_local/config/prompts.py +245 -0
- fusesell_local/config/settings.py +277 -0
- fusesell_local/pipeline.py +932 -0
- fusesell_local/stages/__init__.py +19 -0
- fusesell_local/stages/base_stage.py +602 -0
- fusesell_local/stages/data_acquisition.py +1820 -0
- fusesell_local/stages/data_preparation.py +1231 -0
- fusesell_local/stages/follow_up.py +1590 -0
- fusesell_local/stages/initial_outreach.py +2337 -0
- fusesell_local/stages/lead_scoring.py +1452 -0
- fusesell_local/tests/test_api.py +65 -0
- fusesell_local/tests/test_cli.py +37 -0
- fusesell_local/utils/__init__.py +15 -0
- fusesell_local/utils/birthday_email_manager.py +467 -0
- fusesell_local/utils/data_manager.py +4050 -0
- fusesell_local/utils/event_scheduler.py +618 -0
- fusesell_local/utils/llm_client.py +283 -0
- fusesell_local/utils/logger.py +203 -0
- fusesell_local/utils/timezone_detector.py +914 -0
- fusesell_local/utils/validators.py +416 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event Scheduler - Database-based event scheduling system
|
|
3
|
+
Creates scheduled events in database for external app to handle
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Dict, Any, Optional, List
|
|
9
|
+
import pytz
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventScheduler:
|
|
17
|
+
"""
|
|
18
|
+
Database-based event scheduling system.
|
|
19
|
+
Creates scheduled events that external apps can process.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, data_dir: str = "./fusesell_data"):
|
|
23
|
+
"""
|
|
24
|
+
Initialize the event scheduler.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
data_dir: Directory for data storage
|
|
28
|
+
"""
|
|
29
|
+
self.data_dir = Path(data_dir)
|
|
30
|
+
self.data_dir.mkdir(exist_ok=True)
|
|
31
|
+
|
|
32
|
+
self.logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Database path
|
|
35
|
+
self.main_db_path = self.data_dir / "fusesell.db"
|
|
36
|
+
|
|
37
|
+
# Initialize scheduled events database
|
|
38
|
+
self._initialize_scheduled_events_db()
|
|
39
|
+
|
|
40
|
+
# Initialize scheduling rules database
|
|
41
|
+
self._initialize_scheduling_rules_db()
|
|
42
|
+
|
|
43
|
+
def _initialize_scheduled_events_db(self):
|
|
44
|
+
"""Initialize database table for scheduled events."""
|
|
45
|
+
try:
|
|
46
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
47
|
+
cursor = conn.cursor()
|
|
48
|
+
|
|
49
|
+
cursor.execute("""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS scheduled_events (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
event_id TEXT UNIQUE NOT NULL,
|
|
53
|
+
event_type TEXT NOT NULL,
|
|
54
|
+
scheduled_time TIMESTAMP NOT NULL,
|
|
55
|
+
status TEXT DEFAULT 'pending',
|
|
56
|
+
org_id TEXT NOT NULL,
|
|
57
|
+
team_id TEXT,
|
|
58
|
+
draft_id TEXT,
|
|
59
|
+
recipient_address TEXT NOT NULL,
|
|
60
|
+
recipient_name TEXT,
|
|
61
|
+
customer_timezone TEXT,
|
|
62
|
+
event_data TEXT,
|
|
63
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
64
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
65
|
+
executed_at TIMESTAMP,
|
|
66
|
+
error_message TEXT
|
|
67
|
+
)
|
|
68
|
+
""")
|
|
69
|
+
|
|
70
|
+
# Create index for efficient querying
|
|
71
|
+
cursor.execute("""
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_events_time_status
|
|
73
|
+
ON scheduled_events(scheduled_time, status)
|
|
74
|
+
""")
|
|
75
|
+
|
|
76
|
+
cursor.execute("""
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_events_org_team
|
|
78
|
+
ON scheduled_events(org_id, team_id)
|
|
79
|
+
""")
|
|
80
|
+
|
|
81
|
+
conn.commit()
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
self.logger.info("Scheduled events database initialized")
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
self.logger.error(f"Failed to initialize scheduled events DB: {str(e)}")
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
def _initialize_scheduling_rules_db(self):
|
|
91
|
+
"""Initialize database table for scheduling rules."""
|
|
92
|
+
try:
|
|
93
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
94
|
+
cursor = conn.cursor()
|
|
95
|
+
|
|
96
|
+
cursor.execute("""
|
|
97
|
+
CREATE TABLE IF NOT EXISTS scheduling_rules (
|
|
98
|
+
id TEXT PRIMARY KEY,
|
|
99
|
+
org_id TEXT NOT NULL,
|
|
100
|
+
team_id TEXT,
|
|
101
|
+
rule_name TEXT NOT NULL,
|
|
102
|
+
is_active BOOLEAN DEFAULT 1,
|
|
103
|
+
business_hours_start TEXT DEFAULT '08:00',
|
|
104
|
+
business_hours_end TEXT DEFAULT '20:00',
|
|
105
|
+
default_delay_hours INTEGER DEFAULT 2,
|
|
106
|
+
timezone TEXT DEFAULT 'Asia/Bangkok',
|
|
107
|
+
follow_up_delay_hours INTEGER DEFAULT 120,
|
|
108
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
109
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
110
|
+
UNIQUE(org_id, team_id, rule_name)
|
|
111
|
+
)
|
|
112
|
+
""")
|
|
113
|
+
|
|
114
|
+
# Create default rule if none exists
|
|
115
|
+
cursor.execute("""
|
|
116
|
+
INSERT OR IGNORE INTO scheduling_rules
|
|
117
|
+
(id, org_id, team_id, rule_name, business_hours_start, business_hours_end,
|
|
118
|
+
default_delay_hours, timezone, follow_up_delay_hours)
|
|
119
|
+
VALUES (?, 'default', 'default', 'default_rule', '08:00', '20:00', 2, 'Asia/Bangkok', 120)
|
|
120
|
+
""", (f"uuid:{str(uuid.uuid4())}",))
|
|
121
|
+
|
|
122
|
+
conn.commit()
|
|
123
|
+
conn.close()
|
|
124
|
+
|
|
125
|
+
self.logger.info("Scheduling rules database initialized")
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
self.logger.error(f"Failed to initialize scheduling rules DB: {str(e)}")
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
def schedule_email_event(self, draft_id: str, recipient_address: str, recipient_name: str,
|
|
132
|
+
org_id: str, team_id: str = None, customer_timezone: str = None,
|
|
133
|
+
email_type: str = 'initial', send_immediately: bool = False) -> Dict[str, Any]:
|
|
134
|
+
"""
|
|
135
|
+
Schedule an email event in the database for external app to handle.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
draft_id: ID of the email draft to send
|
|
139
|
+
recipient_address: Email address of recipient
|
|
140
|
+
recipient_name: Name of recipient
|
|
141
|
+
org_id: Organization ID
|
|
142
|
+
team_id: Team ID (optional)
|
|
143
|
+
customer_timezone: Customer's timezone (optional)
|
|
144
|
+
email_type: Type of email ('initial' or 'follow_up')
|
|
145
|
+
send_immediately: If True, schedule for immediate sending
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Event creation result with event ID and scheduled time
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
# Get scheduling rule for the team
|
|
152
|
+
rule = self._get_scheduling_rule(org_id, team_id)
|
|
153
|
+
|
|
154
|
+
# Determine customer timezone
|
|
155
|
+
if not customer_timezone:
|
|
156
|
+
customer_timezone = rule.get('timezone', 'Asia/Bangkok')
|
|
157
|
+
|
|
158
|
+
# Calculate optimal send time
|
|
159
|
+
if send_immediately:
|
|
160
|
+
send_time = datetime.utcnow()
|
|
161
|
+
else:
|
|
162
|
+
send_time = self._calculate_send_time(rule, customer_timezone)
|
|
163
|
+
|
|
164
|
+
# Create event ID
|
|
165
|
+
event_id = f"uuid:{str(uuid.uuid4())}"
|
|
166
|
+
event_type = "email"
|
|
167
|
+
related_draft_id = draft_id
|
|
168
|
+
|
|
169
|
+
# Prepare event data
|
|
170
|
+
event_data = {
|
|
171
|
+
'draft_id': draft_id,
|
|
172
|
+
'email_type': email_type,
|
|
173
|
+
'org_id': org_id,
|
|
174
|
+
'team_id': team_id,
|
|
175
|
+
'customer_timezone': customer_timezone,
|
|
176
|
+
'send_immediately': send_immediately
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Insert scheduled event into database
|
|
180
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
181
|
+
cursor = conn.cursor()
|
|
182
|
+
|
|
183
|
+
cursor.execute("""
|
|
184
|
+
INSERT INTO scheduled_events
|
|
185
|
+
(id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
|
|
186
|
+
recipient_address, recipient_name, customer_timezone, event_data)
|
|
187
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
188
|
+
""", (
|
|
189
|
+
f"uuid:{str(uuid.uuid4())}", event_id, 'email_send', send_time, org_id, team_id, draft_id,
|
|
190
|
+
recipient_address, recipient_name, customer_timezone, json.dumps(event_data)
|
|
191
|
+
))
|
|
192
|
+
|
|
193
|
+
conn.commit()
|
|
194
|
+
conn.close()
|
|
195
|
+
|
|
196
|
+
# Log the scheduling
|
|
197
|
+
self.logger.info(f"Scheduled email event {event_id} for {send_time} (draft: {draft_id})")
|
|
198
|
+
|
|
199
|
+
# Schedule follow-up if this is an initial email
|
|
200
|
+
follow_up_event_id = None
|
|
201
|
+
if email_type == 'initial' and not send_immediately:
|
|
202
|
+
follow_up_result = self._schedule_follow_up_event(
|
|
203
|
+
draft_id, recipient_address, recipient_name, org_id, team_id, customer_timezone
|
|
204
|
+
)
|
|
205
|
+
follow_up_event_id = follow_up_result.get('event_id')
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
'success': True,
|
|
209
|
+
'event_id': event_id,
|
|
210
|
+
'scheduled_time': send_time.isoformat(),
|
|
211
|
+
'recipient_address': recipient_address,
|
|
212
|
+
'recipient_name': recipient_name,
|
|
213
|
+
'draft_id': draft_id,
|
|
214
|
+
'email_type': email_type,
|
|
215
|
+
'follow_up_event_id': follow_up_event_id
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
self.logger.error(f"Failed to schedule email event: {str(e)}")
|
|
220
|
+
return {
|
|
221
|
+
'success': False,
|
|
222
|
+
'error': str(e)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def _schedule_follow_up_event(self, original_draft_id: str, recipient_address: str,
|
|
226
|
+
recipient_name: str, org_id: str, team_id: str = None,
|
|
227
|
+
customer_timezone: str = None) -> Dict[str, Any]:
|
|
228
|
+
"""
|
|
229
|
+
Schedule follow-up email event after initial email.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
original_draft_id: ID of the original draft
|
|
233
|
+
recipient_address: Email address of recipient
|
|
234
|
+
recipient_name: Name of recipient
|
|
235
|
+
org_id: Organization ID
|
|
236
|
+
team_id: Team ID (optional)
|
|
237
|
+
customer_timezone: Customer's timezone (optional)
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Follow-up event creation result
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
# Get scheduling rule
|
|
244
|
+
rule = self._get_scheduling_rule(org_id, team_id)
|
|
245
|
+
|
|
246
|
+
# Calculate follow-up time (default: 5 days after initial send)
|
|
247
|
+
follow_up_delay = rule.get('follow_up_delay_hours', 120) # 120 hours = 5 days
|
|
248
|
+
follow_up_time = datetime.utcnow() + timedelta(hours=follow_up_delay)
|
|
249
|
+
|
|
250
|
+
# Create follow-up event ID
|
|
251
|
+
followup_event_id = f"uuid:{str(uuid.uuid4())}"
|
|
252
|
+
followup_event_type = "followup"
|
|
253
|
+
related_original_draft_id = original_draft_id
|
|
254
|
+
|
|
255
|
+
# Prepare event data
|
|
256
|
+
event_data = {
|
|
257
|
+
'original_draft_id': original_draft_id,
|
|
258
|
+
'email_type': 'follow_up',
|
|
259
|
+
'org_id': org_id,
|
|
260
|
+
'team_id': team_id,
|
|
261
|
+
'customer_timezone': customer_timezone or rule.get('timezone', 'Asia/Bangkok')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Insert follow-up event into database
|
|
265
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
266
|
+
cursor = conn.cursor()
|
|
267
|
+
|
|
268
|
+
cursor.execute("""
|
|
269
|
+
INSERT INTO scheduled_events
|
|
270
|
+
(id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
|
|
271
|
+
recipient_address, recipient_name, customer_timezone, event_data)
|
|
272
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
273
|
+
""", (
|
|
274
|
+
f"uuid:{str(uuid.uuid4())}", followup_event_id, 'email_follow_up', follow_up_time, org_id, team_id,
|
|
275
|
+
original_draft_id, recipient_address, recipient_name,
|
|
276
|
+
customer_timezone, json.dumps(event_data)
|
|
277
|
+
))
|
|
278
|
+
|
|
279
|
+
conn.commit()
|
|
280
|
+
conn.close()
|
|
281
|
+
|
|
282
|
+
self.logger.info(f"Scheduled follow-up event {followup_event_id} for {follow_up_time}")
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
'success': True,
|
|
286
|
+
'event_id': followup_event_id,
|
|
287
|
+
'scheduled_time': follow_up_time.isoformat()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
self.logger.error(f"Failed to schedule follow-up event: {str(e)}")
|
|
292
|
+
return {
|
|
293
|
+
'success': False,
|
|
294
|
+
'error': str(e)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def _get_scheduling_rule(self, org_id: str, team_id: str = None) -> Dict[str, Any]:
|
|
298
|
+
"""
|
|
299
|
+
Get scheduling rule for organization/team.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
org_id: Organization ID
|
|
303
|
+
team_id: Team ID (optional)
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Scheduling rule dictionary
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
310
|
+
cursor = conn.cursor()
|
|
311
|
+
|
|
312
|
+
# Try to get team-specific settings from team_settings table first
|
|
313
|
+
if team_id:
|
|
314
|
+
cursor.execute("""
|
|
315
|
+
SELECT gs_team_schedule_time
|
|
316
|
+
FROM team_settings
|
|
317
|
+
WHERE team_id = ?
|
|
318
|
+
""", (team_id,))
|
|
319
|
+
|
|
320
|
+
row = cursor.fetchone()
|
|
321
|
+
if row and row[0]:
|
|
322
|
+
try:
|
|
323
|
+
schedule_settings = json.loads(row[0])
|
|
324
|
+
if schedule_settings:
|
|
325
|
+
self.logger.debug(f"Using team settings for scheduling: {team_id}")
|
|
326
|
+
conn.close()
|
|
327
|
+
# Convert team settings to scheduling rule format
|
|
328
|
+
return {
|
|
329
|
+
'business_hours_start': schedule_settings.get('business_hours_start', '08:00'),
|
|
330
|
+
'business_hours_end': schedule_settings.get('business_hours_end', '20:00'),
|
|
331
|
+
'default_delay_hours': schedule_settings.get('default_delay_hours', 2),
|
|
332
|
+
'timezone': schedule_settings.get('timezone', 'Asia/Bangkok'),
|
|
333
|
+
'follow_up_delay_hours': schedule_settings.get('follow_up_delay_hours', 120),
|
|
334
|
+
'avoid_weekends': schedule_settings.get('avoid_weekends', True)
|
|
335
|
+
}
|
|
336
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
337
|
+
self.logger.warning(f"Failed to parse team schedule settings: {e}")
|
|
338
|
+
|
|
339
|
+
# Fall back to scheduling_rules table for team-specific rule
|
|
340
|
+
cursor.execute("""
|
|
341
|
+
SELECT business_hours_start, business_hours_end, default_delay_hours,
|
|
342
|
+
timezone, follow_up_delay_hours
|
|
343
|
+
FROM scheduling_rules
|
|
344
|
+
WHERE org_id = ? AND team_id = ? AND is_active = 1
|
|
345
|
+
ORDER BY updated_at DESC LIMIT 1
|
|
346
|
+
""", (org_id, team_id))
|
|
347
|
+
|
|
348
|
+
row = cursor.fetchone()
|
|
349
|
+
if row:
|
|
350
|
+
conn.close()
|
|
351
|
+
return {
|
|
352
|
+
'business_hours_start': row[0],
|
|
353
|
+
'business_hours_end': row[1],
|
|
354
|
+
'default_delay_hours': row[2],
|
|
355
|
+
'timezone': row[3],
|
|
356
|
+
'follow_up_delay_hours': row[4]
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Fall back to org-specific rule
|
|
360
|
+
cursor.execute("""
|
|
361
|
+
SELECT business_hours_start, business_hours_end, default_delay_hours,
|
|
362
|
+
timezone, follow_up_delay_hours
|
|
363
|
+
FROM scheduling_rules
|
|
364
|
+
WHERE org_id = ? AND is_active = 1
|
|
365
|
+
ORDER BY updated_at DESC LIMIT 1
|
|
366
|
+
""", (org_id,))
|
|
367
|
+
|
|
368
|
+
row = cursor.fetchone()
|
|
369
|
+
if row:
|
|
370
|
+
conn.close()
|
|
371
|
+
return {
|
|
372
|
+
'business_hours_start': row[0],
|
|
373
|
+
'business_hours_end': row[1],
|
|
374
|
+
'default_delay_hours': row[2],
|
|
375
|
+
'timezone': row[3],
|
|
376
|
+
'follow_up_delay_hours': row[4]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Fall back to default rule
|
|
380
|
+
cursor.execute("""
|
|
381
|
+
SELECT business_hours_start, business_hours_end, default_delay_hours,
|
|
382
|
+
timezone, follow_up_delay_hours
|
|
383
|
+
FROM scheduling_rules
|
|
384
|
+
WHERE org_id = 'default' AND is_active = 1
|
|
385
|
+
LIMIT 1
|
|
386
|
+
""")
|
|
387
|
+
|
|
388
|
+
row = cursor.fetchone()
|
|
389
|
+
conn.close()
|
|
390
|
+
|
|
391
|
+
if row:
|
|
392
|
+
return {
|
|
393
|
+
'business_hours_start': row[0],
|
|
394
|
+
'business_hours_end': row[1],
|
|
395
|
+
'default_delay_hours': row[2],
|
|
396
|
+
'timezone': row[3],
|
|
397
|
+
'follow_up_delay_hours': row[4]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
# Ultimate fallback
|
|
401
|
+
return {
|
|
402
|
+
'business_hours_start': '08:00',
|
|
403
|
+
'business_hours_end': '20:00',
|
|
404
|
+
'default_delay_hours': 2,
|
|
405
|
+
'timezone': 'Asia/Bangkok',
|
|
406
|
+
'follow_up_delay_hours': 120
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
self.logger.error(f"Failed to get scheduling rule: {str(e)}")
|
|
411
|
+
# Return default rule
|
|
412
|
+
return {
|
|
413
|
+
'business_hours_start': '08:00',
|
|
414
|
+
'business_hours_end': '20:00',
|
|
415
|
+
'default_delay_hours': 2,
|
|
416
|
+
'timezone': 'Asia/Bangkok',
|
|
417
|
+
'follow_up_delay_hours': 120
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
def _calculate_send_time(self, rule: Dict[str, Any], customer_timezone: str) -> datetime:
|
|
421
|
+
"""
|
|
422
|
+
Calculate optimal send time based on scheduling rule and customer timezone.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
rule: Scheduling rule dictionary
|
|
426
|
+
customer_timezone: Customer's timezone
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Optimal send time in UTC
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
# Validate timezone
|
|
433
|
+
try:
|
|
434
|
+
customer_tz = pytz.timezone(customer_timezone)
|
|
435
|
+
except pytz.exceptions.UnknownTimeZoneError:
|
|
436
|
+
self.logger.warning(f"Unknown timezone '{customer_timezone}', using default")
|
|
437
|
+
customer_tz = pytz.timezone(rule.get('timezone', 'Asia/Bangkok'))
|
|
438
|
+
customer_timezone = rule.get('timezone', 'Asia/Bangkok')
|
|
439
|
+
|
|
440
|
+
# Get current time in customer timezone
|
|
441
|
+
now_customer = datetime.now(customer_tz)
|
|
442
|
+
|
|
443
|
+
# Parse business hours
|
|
444
|
+
start_hour, start_minute = map(int, rule['business_hours_start'].split(':'))
|
|
445
|
+
end_hour, end_minute = map(int, rule['business_hours_end'].split(':'))
|
|
446
|
+
|
|
447
|
+
# Calculate proposed send time (now + delay)
|
|
448
|
+
delay_hours = rule['default_delay_hours']
|
|
449
|
+
proposed_time = now_customer + timedelta(hours=delay_hours)
|
|
450
|
+
|
|
451
|
+
# Check if proposed time is within business hours
|
|
452
|
+
business_start = proposed_time.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
|
|
453
|
+
business_end = proposed_time.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
|
|
454
|
+
|
|
455
|
+
# Skip weekends (Saturday=5, Sunday=6)
|
|
456
|
+
while proposed_time.weekday() >= 5:
|
|
457
|
+
proposed_time += timedelta(days=1)
|
|
458
|
+
business_start = proposed_time.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
|
|
459
|
+
business_end = proposed_time.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
|
|
460
|
+
|
|
461
|
+
if business_start <= proposed_time <= business_end:
|
|
462
|
+
# Within business hours, use proposed time
|
|
463
|
+
send_time_customer = proposed_time
|
|
464
|
+
else:
|
|
465
|
+
# Outside business hours, schedule for next business day at start time
|
|
466
|
+
if proposed_time < business_start:
|
|
467
|
+
# Too early, schedule for today's business start
|
|
468
|
+
send_time_customer = business_start
|
|
469
|
+
else:
|
|
470
|
+
# Too late, schedule for tomorrow's business start
|
|
471
|
+
next_day = proposed_time + timedelta(days=1)
|
|
472
|
+
# Skip weekends
|
|
473
|
+
while next_day.weekday() >= 5:
|
|
474
|
+
next_day += timedelta(days=1)
|
|
475
|
+
send_time_customer = next_day.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
|
|
476
|
+
|
|
477
|
+
# Convert to UTC for storage
|
|
478
|
+
send_time_utc = send_time_customer.astimezone(pytz.UTC)
|
|
479
|
+
|
|
480
|
+
self.logger.info(f"Calculated send time: {send_time_customer} ({customer_timezone}) -> {send_time_utc} (UTC)")
|
|
481
|
+
|
|
482
|
+
return send_time_utc.replace(tzinfo=None) # Store as naive datetime in UTC
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
self.logger.error(f"Failed to calculate send time: {str(e)}")
|
|
486
|
+
# Fallback: 2 hours from now
|
|
487
|
+
return datetime.utcnow() + timedelta(hours=2)
|
|
488
|
+
|
|
489
|
+
def get_scheduled_events(self, org_id: str = None, status: str = None) -> List[Dict[str, Any]]:
|
|
490
|
+
"""
|
|
491
|
+
Get list of scheduled events.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
org_id: Filter by organization ID (optional)
|
|
495
|
+
status: Filter by status (optional)
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
List of scheduled events
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
502
|
+
cursor = conn.cursor()
|
|
503
|
+
|
|
504
|
+
query = "SELECT * FROM scheduled_events WHERE 1=1"
|
|
505
|
+
params = []
|
|
506
|
+
|
|
507
|
+
if org_id:
|
|
508
|
+
query += " AND org_id = ?"
|
|
509
|
+
params.append(org_id)
|
|
510
|
+
|
|
511
|
+
if status:
|
|
512
|
+
query += " AND status = ?"
|
|
513
|
+
params.append(status)
|
|
514
|
+
|
|
515
|
+
query += " ORDER BY scheduled_time ASC"
|
|
516
|
+
|
|
517
|
+
cursor.execute(query, params)
|
|
518
|
+
rows = cursor.fetchall()
|
|
519
|
+
|
|
520
|
+
# Get column names
|
|
521
|
+
columns = [description[0] for description in cursor.description]
|
|
522
|
+
|
|
523
|
+
conn.close()
|
|
524
|
+
|
|
525
|
+
# Convert to list of dictionaries
|
|
526
|
+
events = []
|
|
527
|
+
for row in rows:
|
|
528
|
+
event = dict(zip(columns, row))
|
|
529
|
+
# Parse event_data JSON
|
|
530
|
+
if event['event_data']:
|
|
531
|
+
try:
|
|
532
|
+
event['event_data'] = json.loads(event['event_data'])
|
|
533
|
+
except json.JSONDecodeError:
|
|
534
|
+
pass
|
|
535
|
+
events.append(event)
|
|
536
|
+
|
|
537
|
+
return events
|
|
538
|
+
|
|
539
|
+
except Exception as e:
|
|
540
|
+
self.logger.error(f"Failed to get scheduled events: {str(e)}")
|
|
541
|
+
return []
|
|
542
|
+
|
|
543
|
+
def cancel_scheduled_event(self, event_id: str) -> bool:
|
|
544
|
+
"""
|
|
545
|
+
Cancel a scheduled event by marking it as cancelled.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
event_id: ID of the event to cancel
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
True if cancelled successfully
|
|
552
|
+
"""
|
|
553
|
+
try:
|
|
554
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
555
|
+
cursor = conn.cursor()
|
|
556
|
+
|
|
557
|
+
cursor.execute("""
|
|
558
|
+
UPDATE scheduled_events
|
|
559
|
+
SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
|
|
560
|
+
WHERE event_id = ?
|
|
561
|
+
""", (event_id,))
|
|
562
|
+
|
|
563
|
+
conn.commit()
|
|
564
|
+
rows_affected = cursor.rowcount
|
|
565
|
+
conn.close()
|
|
566
|
+
|
|
567
|
+
if rows_affected > 0:
|
|
568
|
+
self.logger.info(f"Cancelled scheduled event: {event_id}")
|
|
569
|
+
return True
|
|
570
|
+
else:
|
|
571
|
+
self.logger.warning(f"Event not found for cancellation: {event_id}")
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
except Exception as e:
|
|
575
|
+
self.logger.error(f"Failed to cancel event {event_id}: {str(e)}")
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
def create_scheduling_rule(self, org_id: str, team_id: str = None, rule_name: str = 'default',
|
|
579
|
+
business_hours_start: str = '08:00', business_hours_end: str = '20:00',
|
|
580
|
+
default_delay_hours: int = 2, timezone: str = 'Asia/Bangkok',
|
|
581
|
+
follow_up_delay_hours: int = 120) -> bool:
|
|
582
|
+
"""
|
|
583
|
+
Create or update a scheduling rule.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
org_id: Organization ID
|
|
587
|
+
team_id: Team ID (optional)
|
|
588
|
+
rule_name: Name of the rule
|
|
589
|
+
business_hours_start: Business hours start time (HH:MM)
|
|
590
|
+
business_hours_end: Business hours end time (HH:MM)
|
|
591
|
+
default_delay_hours: Default delay in hours
|
|
592
|
+
timezone: Timezone for the rule
|
|
593
|
+
follow_up_delay_hours: Follow-up delay in hours
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
True if created/updated successfully
|
|
597
|
+
"""
|
|
598
|
+
try:
|
|
599
|
+
conn = sqlite3.connect(self.main_db_path)
|
|
600
|
+
cursor = conn.cursor()
|
|
601
|
+
|
|
602
|
+
cursor.execute("""
|
|
603
|
+
INSERT OR REPLACE INTO scheduling_rules
|
|
604
|
+
(id, org_id, team_id, rule_name, business_hours_start, business_hours_end,
|
|
605
|
+
default_delay_hours, timezone, follow_up_delay_hours, updated_at)
|
|
606
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
607
|
+
""", (f"uuid:{str(uuid.uuid4())}", org_id, team_id, rule_name, business_hours_start, business_hours_end,
|
|
608
|
+
default_delay_hours, timezone, follow_up_delay_hours))
|
|
609
|
+
|
|
610
|
+
conn.commit()
|
|
611
|
+
conn.close()
|
|
612
|
+
|
|
613
|
+
self.logger.info(f"Created/updated scheduling rule for {org_id}/{team_id}")
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
except Exception as e:
|
|
617
|
+
self.logger.error(f"Failed to create scheduling rule: {str(e)}")
|
|
618
|
+
return False
|