3
3
and send notifications to the user on predefined conditions.
4
4
"""
5
5
6
+ import logging
7
+ import threading
6
8
from datetime import datetime , timedelta , timezone
7
9
from time import sleep
8
10
11
13
import click
12
14
from desktop_notifier import DesktopNotifier
13
15
16
+ logger = logging .getLogger (__name__ )
17
+
14
18
# TODO: Get categories from aw-webui export (in the future from server key-val store)
15
19
# TODO: Add thresholds for total time today (incl percentage of productive time)
16
20
39
43
40
44
time_offset = timedelta (hours = 4 )
41
45
46
+ aw = aw_client .ActivityWatchClient ("aw-notify" , testing = False )
42
47
43
- def get_time (category : str ) -> timedelta :
44
- aw = aw_client .ActivityWatchClient ("aw-notify" , testing = False )
45
48
49
+ def get_time (category : str ) -> timedelta :
46
50
now = datetime .now (timezone .utc )
47
51
timeperiods = [
48
52
(
@@ -51,10 +55,11 @@ def get_time(category: str) -> timedelta:
51
55
)
52
56
]
53
57
58
+ hostname = aw .get_info ().get ("hostname" , "unknown" )
54
59
canonicalQuery = aw_client .queries .canonicalEvents (
55
60
aw_client .queries .DesktopQueryParams (
56
- bid_window = "aw-watcher-window_erb-m2.localdomain " ,
57
- bid_afk = "aw-watcher-afk_erb-m2.localdomain " ,
61
+ bid_window = f "aw-watcher-window_ { hostname } " ,
62
+ bid_afk = f "aw-watcher-afk_ { hostname } " ,
58
63
classes = CATEGORIES ,
59
64
filter_classes = [[category ]] if category else [],
60
65
)
@@ -89,19 +94,19 @@ def to_hms(duration: timedelta) -> str:
89
94
notifier : DesktopNotifier = None
90
95
91
96
92
- def notify (msg : str ):
97
+ def notify (title : str , msg : str ):
93
98
# send a notification to the user
94
99
95
100
global notifier
96
101
if notifier is None :
97
102
notifier = DesktopNotifier (
98
- app_name = "ActivityWatch notify " ,
103
+ app_name = "ActivityWatch" ,
99
104
# icon="file:///path/to/icon.png",
100
105
notification_limit = 10 ,
101
106
)
102
107
103
108
print (msg )
104
- notifier .send_sync (title = "AW" , message = msg )
109
+ notifier .send_sync (title = title , message = msg )
105
110
106
111
107
112
td15min = timedelta (minutes = 15 )
@@ -153,21 +158,24 @@ def update(self):
153
158
time_to_threshold = self .time_to_next_threshold
154
159
# print("Update?")
155
160
if now > (self .last_check + time_to_threshold ):
156
- print (f"Updating { self .category } " )
161
+ logger . debug (f"Updating { self .category } " )
157
162
# print(f"Time to threshold: {time_to_threshold}")
158
163
self .last_check = now
159
164
self .time_spent = get_time (self .category )
160
165
else :
161
166
pass
162
- # print ("Not updating, too soon")
167
+ # logger.debug ("Not updating, too soon")
163
168
164
169
def check (self ):
165
170
"""Check if thresholds have been reached"""
166
171
for thres in sorted (self .thresholds_untriggered , reverse = True ):
167
172
if thres <= self .time_spent :
168
173
# threshold reached
169
174
self .max_triggered = thres
170
- notify (f"[Alert]: { self .category or 'All' } for { to_hms (thres )} " )
175
+ notify (
176
+ "Time spent" ,
177
+ f"{ self .category or 'All' } : { to_hms (self .time_spent )} " ,
178
+ )
171
179
break
172
180
173
181
def status (self ) -> str :
@@ -183,14 +191,17 @@ def test_category_alert():
183
191
184
192
185
193
@click .group ()
186
- def main ():
187
- pass
194
+ @click .option ("-v" , "--verbose" , is_flag = True , help = "Enables verbose mode." )
195
+ def main (verbose : bool ):
196
+ logging .basicConfig (level = logging .DEBUG if verbose else logging .INFO )
197
+ logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
188
198
189
199
190
200
@main .command ()
191
201
def start ():
192
202
"""Start the notification service."""
193
203
checkin ()
204
+ hourly ()
194
205
threshold_alerts ()
195
206
196
207
@@ -208,24 +219,87 @@ def threshold_alerts():
208
219
for alert in alerts :
209
220
alert .update ()
210
221
alert .check ()
211
- print (alert .status ())
222
+ status = alert .status ()
223
+ if status != getattr (alert , "last_status" , None ):
224
+ print (f"New status: { status } " )
225
+ alert .last_status = status
212
226
213
227
sleep (10 )
214
228
215
229
216
230
@main .command ()
231
+ def _checkin ():
232
+ """Send a summary notification."""
233
+ checkin ()
234
+
235
+
217
236
def checkin ():
218
237
"""
219
238
Sends a summary notification of the day.
220
239
Meant to be sent at a particular time, like at the end of a working day (e.g. 5pm).
221
240
"""
222
241
# TODO: load categories from data
223
- top_categories = ["" , "Work" , "Twitter" ]
242
+ top_categories = ["" ] + [ k [ 0 ] for k , _ in CATEGORIES ]
224
243
time_spent = [get_time (c ) for c in top_categories ]
225
244
msg = f"Time spent today: { sum (time_spent , timedelta ())} \n "
226
245
msg += "Categories:\n "
227
- msg += "\n " .join (f" - { c } : { t } " for c , t in zip (top_categories , time_spent ))
228
- notify (msg )
246
+ msg += "\n " .join (
247
+ f" - { c if c else 'All' } : { t } "
248
+ for c , t in sorted (
249
+ zip (top_categories , time_spent ), key = lambda x : x [1 ], reverse = True
250
+ )
251
+ )
252
+ notify ("Checkin" , msg )
253
+
254
+
255
+ def get_active_status () -> bool :
256
+ """
257
+ Get active status by polling latest event in aw-watcher-afk bucket.
258
+ Returns True if user is active/not-afk, False if not.
259
+ On error, like out-of-date event, returns None.
260
+ """
261
+
262
+ hostname = aw .get_info ().get ("hostname" , "unknown" )
263
+ events = aw .get_events (f"aw-watcher-afk_{ hostname } " , limit = 1 )
264
+ print (events )
265
+ if not events :
266
+ return None
267
+ event = events [0 ]
268
+ if event .timestamp < datetime .now (timezone .utc ) - timedelta (minutes = 5 ):
269
+ # event is too old
270
+ logger .warning (
271
+ "AFK event is too old, can't use to reliably determine AFK state"
272
+ )
273
+ return None
274
+ return events [0 ]["data" ]["status" ] == "not-afk"
275
+
276
+
277
+ def hourly ():
278
+ """Start a thread that does hourly checkins, on every whole hour that the user is active (not if afk)."""
279
+
280
+ def checkin_thread ():
281
+ while True :
282
+ # wait until next whole hour
283
+ now = datetime .now (timezone .utc )
284
+ next_hour = now .replace (minute = 0 , second = 0 , microsecond = 0 ) + timedelta (
285
+ hours = 1
286
+ )
287
+ sleep_time = (next_hour - now ).total_seconds ()
288
+ logger .debug (f"Sleeping for { sleep_time } seconds" )
289
+ sleep (sleep_time )
290
+
291
+ # check if user is afk
292
+ active = get_active_status ()
293
+ if active is None :
294
+ logger .warning ("Can't determine AFK status, skipping hourly checkin" )
295
+ continue
296
+ if not active :
297
+ logger .info ("User is AFK, skipping hourly checkin" )
298
+ continue
299
+
300
+ checkin ()
301
+
302
+ threading .Thread (target = checkin_thread , daemon = True ).start ()
229
303
230
304
231
305
if __name__ == "__main__" :
0 commit comments