7
7
import threading
8
8
from datetime import datetime , timedelta , timezone
9
9
from time import sleep
10
+ from typing import Optional
10
11
11
12
import aw_client
12
13
import aw_client .queries
39
40
"ignore_case" : True ,
40
41
},
41
42
),
43
+ (
44
+ ["Youtube" ],
45
+ {
46
+ "type" : "regex" ,
47
+ "regex" : r"Youtube|youtube.com" ,
48
+ "ignore_case" : True ,
49
+ },
50
+ ),
42
51
]
43
52
44
53
time_offset = timedelta (hours = 4 )
45
54
46
55
aw = aw_client .ActivityWatchClient ("aw-notify" , testing = False )
47
56
57
+ from functools import wraps
58
+
59
+
60
+ def cache_ttl (ttl : timedelta ):
61
+ """Decorator that caches the result of a function, with a given time-to-live."""
62
+
63
+ def wrapper (func ):
64
+ @wraps (func )
65
+ def _cache_ttl (* args , ** kwargs ):
66
+ now = datetime .now (timezone .utc )
67
+ if now - _cache_ttl .last_update > ttl :
68
+ logger .debug (f"Cache expired for { func .__name__ } , updating" )
69
+ _cache_ttl .last_update = now
70
+ _cache_ttl .cache = func (* args , ** kwargs )
71
+ return _cache_ttl .cache
72
+
73
+ _cache_ttl .last_update = datetime (1970 , 1 , 1 , tzinfo = timezone .utc )
74
+ _cache_ttl .cache = None
75
+ return _cache_ttl
76
+
77
+ return wrapper
78
+
79
+
80
+ @cache_ttl (timedelta (minutes = 1 ))
81
+ def get_time () -> dict [str , timedelta ]:
82
+ """
83
+ Returns a dict with the time spent today for each category.
84
+ """
48
85
49
- def get_time (category : str ) -> timedelta :
50
86
now = datetime .now (timezone .utc )
51
87
timeperiods = [
52
88
(
@@ -61,18 +97,21 @@ def get_time(category: str) -> timedelta:
61
97
bid_window = f"aw-watcher-window_{ hostname } " ,
62
98
bid_afk = f"aw-watcher-afk_{ hostname } " ,
63
99
classes = CATEGORIES ,
64
- filter_classes = [[category ]] if category else [],
65
100
)
66
101
)
67
102
query = f"""
68
103
{ canonicalQuery }
69
104
duration = sum_durations(events);
70
- RETURN = {{"events": events, "duration": duration}};
105
+ cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"]));
106
+ RETURN = {{"events": events, "duration": duration, "cat_events": cat_events}};
71
107
"""
72
108
73
109
res = aw .query (query , timeperiods )[0 ]
74
- time = timedelta (seconds = res ["duration" ])
75
- return time
110
+ res ["cat_events" ] += [{"data" : {"$category" : ["All" ]}, "duration" : res ["duration" ]}]
111
+ return {
112
+ ">" .join (c ["data" ]["$category" ]): timedelta (seconds = c ["duration" ])
113
+ for c in res ["cat_events" ]
114
+ }
76
115
77
116
78
117
def to_hms (duration : timedelta ) -> str :
@@ -130,8 +169,11 @@ class CategoryAlert:
130
169
Keeps track of the time spent so far, which alerts to trigger, and which have been triggered.
131
170
"""
132
171
133
- def __init__ (self , category : str , thresholds : list [timedelta ]):
172
+ def __init__ (
173
+ self , category : str , thresholds : list [timedelta ], label : Optional [str ] = None
174
+ ):
134
175
self .category = category
176
+ self .label = label or category or "All"
135
177
self .thresholds = thresholds
136
178
self .max_triggered : timedelta = timedelta ()
137
179
self .time_spent = timedelta ()
@@ -166,8 +208,12 @@ def update(self):
166
208
if now > (self .last_check + time_to_threshold ):
167
209
logger .debug (f"Updating { self .category } " )
168
210
# print(f"Time to threshold: {time_to_threshold}")
169
- self .last_check = now
170
- self .time_spent = get_time (self .category )
211
+ try :
212
+ # TODO: move get_time call so that it is cached better
213
+ self .time_spent = get_time ()[self .category ]
214
+ self .last_check = now
215
+ except Exception as e :
216
+ logger .error (f"Error getting time for { self .category } : { e } " )
171
217
else :
172
218
pass
173
219
# logger.debug("Not updating, too soon")
@@ -180,12 +226,12 @@ def check(self):
180
226
self .max_triggered = thres
181
227
notify (
182
228
"Time spent" ,
183
- f"{ self .category or 'All' } : { to_hms (thres )} reached! ({ to_hms (self .time_spent )} )" ,
229
+ f"{ self .label } : { to_hms (thres )} reached! ({ to_hms (self .time_spent )} )" ,
184
230
)
185
231
break
186
232
187
233
def status (self ) -> str :
188
- return f"""{ self .category or 'All' } : { to_hms (self .time_spent )} """
234
+ return f"""{ self .label } : { to_hms (self .time_spent )} """
189
235
# (time to thres: {to_hms(self.time_to_next_threshold)})
190
236
# triggered: {self.max_triggered}"""
191
237
@@ -199,7 +245,12 @@ def test_category_alert():
199
245
@click .group ()
200
246
@click .option ("-v" , "--verbose" , is_flag = True , help = "Enables verbose mode." )
201
247
def main (verbose : bool ):
202
- logging .basicConfig (level = logging .DEBUG if verbose else logging .INFO )
248
+ logging .basicConfig (
249
+ level = logging .DEBUG if verbose else logging .INFO ,
250
+ format = "%(asctime)s [%(levelname)5s] %(message)s"
251
+ + (" (%(name)s.%(funcName)s:%(lineno)d)" if verbose else "" ),
252
+ datefmt = "%Y-%m-%d %H:%M:%S" ,
253
+ )
203
254
logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
204
255
205
256
@@ -216,9 +267,10 @@ def threshold_alerts():
216
267
Checks elapsed time for each category and triggers alerts when thresholds are reached.
217
268
"""
218
269
alerts = [
219
- CategoryAlert ("" , [td1h , td2h , td4h , td6h , td8h ]),
220
- CategoryAlert ("Twitter" , [td15min , td30min , td1h ]),
221
- CategoryAlert ("Work" , [td15min , td30min , td1h , td2h , td4h ]),
270
+ CategoryAlert ("All" , [td1h , td2h , td4h , td6h , td8h ], label = "All" ),
271
+ CategoryAlert ("Twitter" , [td15min , td30min , td1h ], label = "🐦 Twitter" ),
272
+ CategoryAlert ("Youtube" , [td15min , td30min , td1h ], label = "📺 Youtube" ),
273
+ CategoryAlert ("Work" , [td15min , td30min , td1h , td2h , td4h ], label = "💼 Work" ),
222
274
]
223
275
224
276
while True :
@@ -227,7 +279,7 @@ def threshold_alerts():
227
279
alert .check ()
228
280
status = alert .status ()
229
281
if status != getattr (alert , "last_status" , None ):
230
- logger .info (f"New status: { status } " )
282
+ logger .debug (f"New status: { status } " )
231
283
alert .last_status = status
232
284
233
285
sleep (10 )
@@ -245,12 +297,13 @@ def checkin():
245
297
Meant to be sent at a particular time, like at the end of a working day (e.g. 5pm).
246
298
"""
247
299
# TODO: load categories from data
248
- top_categories = ["" ] + [k [0 ] for k , _ in CATEGORIES ]
249
- time_spent = [get_time (c ) for c in top_categories ]
250
- msg = f"Time spent today: { sum (time_spent , timedelta ())} \n "
300
+ top_categories = ["All" ] + [k [0 ] for k , _ in CATEGORIES ]
301
+ cat_time = get_time ()
302
+ time_spent = [cat_time [c ] for c in top_categories ]
303
+ msg = f"Time spent today: { to_hms (time_spent [0 ])} \n "
251
304
msg += "Categories:\n "
252
305
msg += "\n " .join (
253
- f" - { c if c else 'All' } : { t } "
306
+ f" - { c if c else 'All' } : { to_hms ( t ) } "
254
307
for c , t in sorted (
255
308
zip (top_categories , time_spent ), key = lambda x : x [1 ], reverse = True
256
309
)
@@ -267,11 +320,12 @@ def get_active_status() -> bool:
267
320
268
321
hostname = aw .get_info ().get ("hostname" , "unknown" )
269
322
events = aw .get_events (f"aw-watcher-afk_{ hostname } " , limit = 1 )
270
- print (events )
323
+ logger . debug (events )
271
324
if not events :
272
325
return None
273
326
event = events [0 ]
274
- if event .timestamp < datetime .now (timezone .utc ) - timedelta (minutes = 5 ):
327
+ event_end = event .timestamp + event .duration
328
+ if event_end < datetime .now (timezone .utc ) - timedelta (minutes = 5 ):
275
329
# event is too old
276
330
logger .warning (
277
331
"AFK event is too old, can't use to reliably determine AFK state"
@@ -295,7 +349,11 @@ def checkin_thread():
295
349
sleep (sleep_time )
296
350
297
351
# check if user is afk
298
- active = get_active_status ()
352
+ try :
353
+ active = get_active_status ()
354
+ except Exception as e :
355
+ logger .warning (f"Error getting AFK status: { e } " )
356
+ continue
299
357
if active is None :
300
358
logger .warning ("Can't determine AFK status, skipping hourly checkin" )
301
359
continue
0 commit comments