Last active
September 15, 2023 21:32
-
-
Save richdrummer33/1dd26e2f622b82787eaa7327d8e55995 to your computer and use it in GitHub Desktop.
Auto-Toggl Toggle Timer Script: Automatically starts/stops the Toggl timer based on user inactivity or based on keywords in a messenger app.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import time | |
import datetime | |
import requests | |
import threading | |
import os | |
from pytz import timezone | |
# Created by Richard Beare, as a balm for his forgetfulness | |
# Toggl Automation | |
#################################################################################### | |
# Features: | |
# Starts/stops timers at start and end of day based on keyword in typed messages | |
# Starts/stops timers when user goes on break using KEYWORDS in typed message | |
# Automatically stops timer after 20 minutes of inactivity (accounts for inactivity time in timer duration) | |
# Automatically starts timer when user returns from break | |
# | |
# Usage notes: | |
# API key can be found here: https://track.toggl.com/profile | |
# | |
# To set your API key for this script: | |
# 1. Open the start menu and search for "environment variables", and open "Edit the system environment variables" | |
# 3. Click "Environment Variables" in the bottom right of the window | |
# 4. Under "User Variables", click "New" | |
# 5. Enter "TOGGL_API_KEY" for the name and your API key for the value | |
#################################################################################### | |
## >>>> USER-VARIABLES >>>> ## | |
# apps that you use for work - interacting with these - IF their window names contain _project_title in them - means you are working | |
_work_apps = ["slack", "unity", "visual studio", "code"] | |
# work comms app - if this is the window. Keywords (e.g. break, end of day) are only filtered/checked in this one app. | |
_work_comms_app = "slack" | |
# the title of the project you are working on (e.g. your slack team name, or discord server name, visual studio project name, etc.) | |
_project_title = "unseen" | |
# phrases that mean "end of day" - if these are typed in slack (or chosen app), the timer will stop and the monitor will stop until next day | |
_eod_phrases = ["I'm out", "Im out", "done for the day", "wrapping up", "cya tomorrow", "see y'all", "catch y'all", "c u later", "c u monday", "see you monday", "catch you next week", "catch you then", "catch u then"] | |
# phrases that mean "break" - if these are typed in slack (or chosen app), the timer will stop | |
_break_phrases = ["break", "breaky"] | |
# phrases that mean "back from break" - if these are typed in slack (or chosen app), the timer will start | |
_back_phrases = ["bac"] | |
# constants | |
INACTIVITY_THRESHOLD = 20 * 60 # if away 20 mins, then timer stops (it subtracts the 20 mins overage from the timer) | |
INACTIVITY_DAY_PASSED = 60 * 60 * 6 # if you sent end of day message, then this is how long script will wait to start the timer again when interacting with work apps | |
EST = timezone('US/Eastern') | |
# debug flags | |
_debug_logic = False | |
_debug_logic_verbose = False | |
_debug_api = False | |
## <<<< END OF USER-VARIABLES <<<< ## | |
# buffer that holds 30 characters that are the last typed characters | |
_keystroke_buffer = [] | |
_buffer_size = 30 | |
# activity states / flags | |
_on_break = False | |
_last_work_activity_time = time.time() - 999 # start at some long time ago so the loop can start when launched | |
_last_any_activity_time = time.time() - 999 # start at some long time ago so the loop can start when launched | |
_monitor_loop_permitted = True # the monitor loop can run | |
_timer_running = False | |
_day_ended = False | |
#################################################################################### | |
############################# MISC METHODS ##################################### | |
#################################################################################### | |
import winsound | |
def play_notification_sound(sound_path): | |
winsound.PlaySound(sound_path, winsound.SND_FILENAME) | |
#################################################################################### | |
################################ TOGGL API ####################################### | |
#################################################################################### | |
from toggl.api_client import TogglClientApi | |
_key = os.environ.get("TOGGL_API_KEY") | |
# toggl api | |
_credentials = { | |
"token": _key, | |
"username": "rstuart 33", # just ur user ID intoggl | |
"workspace_id": 1234567, # int ID from the URL of your workspace | |
"user_agent": "Toggl Automater" # a name for your client app (can be anything) | |
} | |
_toggl_api = TogglClientApi(_credentials) | |
_response = _toggl_api.get_workspaces() | |
if _debug_api: print(_response) | |
def start_new_timer(): | |
""" | |
Starts a new timer in toggle that runs until we stop it. | |
""" | |
# set flag | |
global _timer_running | |
_timer_running = True | |
# Create an instance of the TogglClientApi | |
toggl_api = TogglClientApi(_credentials) | |
# get now as ISO 8601 | |
now = datetime.datetime.now(EST).isoformat() | |
# Define the time entry details | |
time_entry = { | |
"time_entry": { | |
"description": "", | |
"duration": -1, # example duration in seconds (1 hour); set to a negative number to start a timer | |
"start": now, # ISO 8601 format datetime | |
"pid": 123456789, # replace with your project id from Toggl (in the URL for that project) | |
"created_with": "Toggl Automater" | |
} | |
} | |
# Start the timer | |
response = toggl_api.create_time_entry(time_entry) | |
if _debug_api: print(response) | |
# notify user | |
print("Started new timer...") | |
play_notification_sound("C:\\Windows\\Media\\Speech On.wav") | |
os.system("msg * Timer started!") | |
def stop_current_timer(auto = False, playsound = True, self = _toggl_api): | |
""" | |
Stops the current running timer. | |
""" | |
# set flag | |
global _timer_running | |
_timer_running = False | |
# First, get the current running time entry | |
response = self.query("/time_entries/current") | |
if response.status_code != requests.codes.ok: | |
response.raise_for_status() | |
current_time_entry = response.json()["data"] | |
# If there's no running timer, return | |
if not current_time_entry: | |
return None | |
# Now, stop the timer by setting the stop time to the current time | |
time_entry_id = current_time_entry["id"] | |
# Get the time the timer started | |
start_time_str = current_time_entry["start"] | |
if start_time_str.endswith('+00:0'): | |
start_time_str = start_time_str[:-1] + '0' | |
# String has been stripped of the last character, so we can use fromisoformat | |
start_time = datetime.datetime.fromisoformat(start_time_str) | |
# If auto, set the stop time 20 mins before it was auto-stopped | |
if auto: | |
stop_time = (datetime.datetime.utcnow() - datetime.timedelta(seconds=INACTIVITY_THRESHOLD)) | |
else: | |
stop_time = datetime.datetime.utcnow() | |
# Attach timezone information to stop_time | |
stop_time = stop_time.replace(tzinfo=timezone('UTC')) | |
# Round times to nearest 5 minutes | |
stop_time -= datetime.timedelta(minutes=stop_time.minute % 5, seconds=stop_time.second, microseconds=stop_time.microsecond) | |
start_time -= datetime.timedelta(minutes=start_time.minute % 5, seconds=start_time.second, microseconds=start_time.microsecond) | |
updated_data = { | |
"time_entry": { | |
"stop": stop_time, | |
"duration": int((stop_time - start_time).total_seconds()) # Calculate total duration | |
} | |
} | |
response = self.query(f"/time_entries/{time_entry_id}/stop", method="PUT", json_data=updated_data) | |
if response.status_code != requests.codes.ok: | |
response.raise_for_status() | |
if playsound: | |
play_notification_sound("C:\\Windows\\Media\\Speech Off.wav") | |
# notify user | |
print("Stopped timer...") | |
os.system("msg * Timer stopped!") | |
return response.json() | |
def is_within_working_hours(): | |
now = datetime.datetime.now(EST) | |
return 10 <= now.hour < 22 | |
def is_activity_timeout(last_activity_time, check_day_passed = False): | |
threshold = INACTIVITY_THRESHOLD | |
if check_day_passed: | |
threshold = INACTIVITY_DAY_PASSED | |
is_active = time.time() - last_activity_time <= threshold | |
if _debug_logic: print("User is active." if is_active else "User is inactive.") | |
return not is_active | |
#################################################################################### | |
############################# ACTIVITY CHECKS #################################### | |
#################################################################################### | |
import pygetwindow as gw | |
#from fuzzywuzzy import fuzz | |
def is_string_in_phrases(string, phrases, fuzzy_threshold__unused): | |
for phrase in phrases: | |
# remove spaces from phrase | |
phrase = phrase.replace(" ", "") | |
if phrase.lower() in string.lower(): | |
print ("Found phrase: " + phrase + " in string: " + string) | |
# cleart the buffer | |
_keystroke_buffer.clear() | |
return True | |
return False | |
#for phrase in phrases: | |
# similarity_score = fuzz.token_set_ratio(phrase, phrases) | |
# if _debug_logic: print("Similarity score: " + str(similarity_score)) | |
# if similarity_score >= threshold: | |
# return True | |
#return False | |
def is_work_app_focused(app_names): | |
# get the active window | |
active_window = gw.getActiveWindow() | |
if active_window is None: | |
return False | |
if active_window and _debug_logic and _debug_logic_verbose: print(active_window.title) | |
# check if active window is one of the specified apps | |
for app_name in app_names: | |
if not _project_title in active_window.title.lower(): | |
continue | |
if _debug_logic and _debug_logic_verbose: print(app_name.lower(), active_window.title.lower()) | |
if app_name.lower() in active_window.title.lower(): | |
return True | |
def is_any_work_app_open(): | |
# Get all visible windows | |
windows = gw.getAllTitles() | |
# iterate thru all the window titles from pygetwindow | |
for window in windows: | |
if window is None: | |
continue | |
# iterate thru all the required apps | |
for app in _work_apps: | |
# if the required app is open, and it has the word _project_title in the title, return True | |
if app.lower() in window.lower() and _project_title in window.lower(): | |
if _debug_logic: print("Required app is open.") | |
return True | |
return False | |
# query for break message | |
def query_for_break_slack_message(): | |
return query_for_specified_messages(_break_phrases, 95) | |
# query for back message | |
def query_for_back_slack_message(): | |
return query_for_specified_messages(_back_phrases, 95) | |
# if slack is the window, check if the buffer contains any of the phrases that mean "eod" | |
def query_for_eod_slack_message(): | |
return query_for_specified_messages(_eod_phrases) | |
def query_for_specified_messages(messages, fuzzy_threshold = 80): | |
if not is_work_app_focused("slack"): | |
return False | |
# global variables | |
global _last_work_activity_time | |
global _monitor_loop_permitted | |
# check if the buffer contains any of the phrases | |
if _debug_logic: print("Buffer content: " + str(_keystroke_buffer)) | |
buffer_string_concat = ''.join(_keystroke_buffer) | |
if is_string_in_phrases(buffer_string_concat, messages, fuzzy_threshold): | |
return True | |
return False | |
# every N interactions, check if the user is interacting with a work window | |
interaction_check_count = 5 # N interactions (clicks, keystrokes) | |
interaction_counter = 0 | |
def on_interacted_with_any_window(): | |
global _last_work_activity_time | |
global _last_any_activity_time | |
global interaction_counter | |
global interaction_check_count | |
# any activity at all, reset this timer so we know when user got back on the PC | |
_last_any_activity_time = time.time() | |
#if _debug_logic: print("User interacted with PC " + str(interaction_counter) + " times.") | |
if interaction_counter >= interaction_check_count: | |
if is_work_app_focused(_work_apps): | |
if _debug_logic: print("User interacted: " + str(_keystroke_buffer)) | |
_last_work_activity_time = time.time() | |
interaction_counter = 0 | |
interaction_counter += 1 | |
return gw.getActiveWindow() | |
#################################################################################### | |
############################## EVENT HANDLERS #################################### | |
#################################################################################### | |
from pynput import mouse, keyboard | |
def handle_new_day_check(): | |
global _on_break | |
global _timer_running | |
if not (not _on_break and not _timer_running): | |
return | |
global _day_ended | |
global _last_work_activity_time | |
if _day_ended: | |
# check if the user is back from EOD (app may have ran overnight) | |
if is_activity_timeout(_last_work_activity_time, True): # check day has passed | |
print("User is back from EOD - starting timer.") | |
threading.Thread(target=start_new_timer).start() | |
_day_ended = False | |
else: | |
# likely app just launched, so check if we are within working hours | |
print("Work has commenced! Initialized.") | |
init_activity_timers() | |
threading.Thread(target=start_new_timer).start() | |
def buffer_keystroke(key): | |
# add the key to the buffer. If the buffer is full, remove the oldest key. | |
try: | |
char_key = key.char | |
if key == keyboard.Key.backspace: | |
_keystroke_buffer.pop() | |
elif key == keyboard.Key.space: | |
char_key = " " | |
elif key == keyboard.Key.enter: | |
_keystroke_buffer.clear() | |
else: | |
char_key = key.char | |
_keystroke_buffer.append(char_key) | |
if _debug_logic: print(char_key) | |
if len(_keystroke_buffer) > _buffer_size: | |
_keystroke_buffer.pop(0) | |
except AttributeError: | |
pass | |
def on_mouse_button_activity(x, y, button, pressed): | |
global _last_work_activity_time | |
window = on_interacted_with_any_window() | |
if window and "slack" in window.title.lower(): | |
handle_new_day_check() | |
def init_activity_timers(): | |
global _last_work_activity_time | |
global _last_any_activity_time | |
_last_work_activity_time = time.time() | |
_last_any_activity_time = time.time() | |
def on_keyboard_activity(key): | |
global _on_break | |
global _monitor_loop_permitted | |
global _timer_running | |
global _day_ended | |
window = on_interacted_with_any_window() | |
if _debug_logic and _debug_logic_verbose: print(window) | |
buffer_keystroke(key) | |
# if slack is the window, check for keyword phrases | |
if window and "slack" in window.title.lower(): | |
if query_for_break_slack_message(): | |
if _timer_running: | |
to_print = "Break message detected... " | |
print("Break message detected! Was the timer running? " + str(_timer_running)) | |
if _timer_running: | |
_on_break = True | |
print (to_print + " stopping timer.") | |
threading.Thread(target=stop_current_timer, args=(True, True)).start() | |
return | |
if query_for_back_slack_message(): | |
if not _timer_running: | |
to_print = "Back from break message detected... " | |
if not _timer_running: | |
_on_break = False | |
print (to_print + " restarting timer.") | |
threading.Thread(target=start_new_timer).start() | |
return | |
if query_for_eod_slack_message(): | |
print("EOD message detected! Was the timer running? " + str(_timer_running)) | |
if _timer_running: | |
_day_ended = True | |
threading.Thread(target=stop_current_timer, args=(True, True)).start() | |
else: | |
print("Timer not running at eod message, but it should be!") | |
play_notification_sound("C:\\Windows\\Media\\Windows Exclamation.wav") | |
_monitor_loop_permitted = False | |
return | |
# else we are in slack, but no keyword phrases were detected - this means work may have commenced (e.g. start of day)! | |
handle_new_day_check() | |
# Start monitoring mouse and keyboard | |
_mouse_listener = mouse.Listener(on_click=on_mouse_button_activity) | |
_keyboard_listener = keyboard.Listener(on_press=on_keyboard_activity) | |
_mouse_listener.start() | |
_keyboard_listener.start() | |
#################################################################################### | |
################################ MAIN LOOP ####################################### | |
#################################################################################### | |
try: | |
time.sleep(1) | |
print("Beginning status monitor...") | |
while True: | |
# All the work apps are closed, so we are DEFINITELY done for the day | |
if is_activity_timeout(_last_work_activity_time) and _timer_running: | |
if _debug_logic: print("User work activity timed out - stopping timer.") | |
threading.Thread(target=stop_current_timer, args=(True, False)).start() | |
play_notification_sound("C:\\Windows\\Media\\Speech Sleep.wav") | |
# Loop to check activity again after N seconds | |
if _monitor_loop_permitted: | |
time.sleep(15) | |
continue | |
while not _monitor_loop_permitted: | |
time.sleep(15) | |
# ctrl-c to force exit | |
except KeyboardInterrupt: | |
if _timer_running: | |
threading.Thread(target=stop_current_timer, args=(False, True)).start() | |
time.sleep(1) | |
print("Timer stopped. Exiting program.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment