feat: Wave 5 Python parity — outlook COM + time tracker
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run

Outlook COM (`outlook_handlers.py`, agent):
- 4 handlers mirroring Rust pack: unread_count, latest, send_clipboard,
  summarize_inbox. Uses `win32com.client` with lazy import so the module
  loads even when pywin32 is missing — handlers degrade gracefully and
  speak a "Outlook недоступен" reply.
- `requirements.txt`: pywin32>=305 (optional).
- 7 unit tests mock `_outlook_session` via unittest.mock.

Time tracker (`time_tracker_handlers.py`, agent):
- 5 handlers: start, stop, today, week, reset. JSON store at
  `<here>/time_tracker.json`, atomic write-then-rename.
- Same shape as the Rust pack's `jarvis.state` blob: current_session_start
  + sessions[{start,end}]. Local-calendar today boundary.
- Full Russian pluralisation (час/часа/часов, минута/минуты/минут).
- 14 unit tests using the existing `isolated_state` fixture in conftest.py.

`extensions.py` + `main.py`: imports + 9 new proxy do_* functions + 9
new dispatch elifs. `commands.yaml`: 9 new entries.

Tests: 60 → 81 (+14 tracker + 7 outlook).
This commit is contained in:
Bossiara13 2026-05-16 14:34:44 +03:00
parent 45d9425ed7
commit 5265cb0fbe
8 changed files with 971 additions and 0 deletions

View file

@ -1549,3 +1549,78 @@ personality_tony_quote:
- процитируй железного человека - процитируй железного человека
- цитата старка - цитата старка
action: {type: personality_tony_quote} action: {type: personality_tony_quote}
tracker_start:
phrases:
- начни отсчёт
- начни отсчет
- запусти трекер
- я начинаю работу
- начало рабочего дня
action: {type: tracker_start}
tracker_stop:
phrases:
- закончи отсчёт
- закончи отсчет
- останови трекер
- я закончил работу
- конец рабочего дня
action: {type: tracker_stop}
tracker_today:
phrases:
- сколько я работаю сегодня
- сколько отработал сегодня
- сколько часов сегодня
- сколько времени за сегодня
action: {type: tracker_today}
tracker_week:
phrases:
- сколько на этой неделе
- сколько отработал на неделе
- часы за неделю
- статистика за неделю
action: {type: tracker_week}
tracker_reset:
phrases:
- сбрось трекер
- обнули трекер
- очисти трекер
- сбрось отсчёт
action: {type: tracker_reset}
outlook_unread_count:
phrases:
- сколько у меня непрочитанных писем
- сколько непрочитанных писем
- проверь почту
- проверь outlook
- сколько новых писем
action: {type: outlook_unread_count}
outlook_latest:
phrases:
- прочитай последнее письмо
- что в последнем письме
- последнее письмо
- покажи последнее письмо
action: {type: outlook_latest}
outlook_send_clipboard:
phrases:
- отправь письмо
- отправь почту
- напиши письмо
- отправь это
action: {type: outlook_send_clipboard}
outlook_summarize_inbox:
phrases:
- коротко расскажи про инбокс
- расскажи что в инбоксе
- сделай саммари инбокса
- что нового в почте
action: {type: outlook_summarize_inbox}

View file

@ -29,6 +29,8 @@ import dev_handlers
import llm_backend import llm_backend
import wave1_handlers import wave1_handlers
import personality_handlers import personality_handlers
import time_tracker_handlers
import outlook_handlers
_speak_fn = print _speak_fn = print
_set_clipboard_fn = None _set_clipboard_fn = None
@ -43,6 +45,8 @@ def set_speak(fn):
dev_handlers.set_speak(fn) dev_handlers.set_speak(fn)
wave1_handlers.set_speak(fn) wave1_handlers.set_speak(fn)
personality_handlers.set_speak(fn) personality_handlers.set_speak(fn)
time_tracker_handlers.set_speak(fn)
outlook_handlers.set_speak(fn)
def set_clipboard_fn(fn): def set_clipboard_fn(fn):
@ -1130,3 +1134,20 @@ def do_personality_thanks(a, v): personality_handlers.do_personality_thanks(a, v
def do_personality_compliment(a, v): personality_handlers.do_personality_compliment(a, v) def do_personality_compliment(a, v): personality_handlers.do_personality_compliment(a, v)
def do_personality_how_are_you(a, v): personality_handlers.do_personality_how_are_you(a, v) def do_personality_how_are_you(a, v): personality_handlers.do_personality_how_are_you(a, v)
def do_personality_tony_quote(a, v): personality_handlers.do_personality_tony_quote(a, v) def do_personality_tony_quote(a, v): personality_handlers.do_personality_tony_quote(a, v)
# ── Time tracker proxies (to time_tracker_handlers) ─────────────────────────
def do_tracker_start(a, v): time_tracker_handlers.do_tracker_start(a, v)
def do_tracker_stop(a, v): time_tracker_handlers.do_tracker_stop(a, v)
def do_tracker_today(a, v): time_tracker_handlers.do_tracker_today(a, v)
def do_tracker_week(a, v): time_tracker_handlers.do_tracker_week(a, v)
def do_tracker_reset(a, v): time_tracker_handlers.do_tracker_reset(a, v)
# ── Outlook proxies (to outlook_handlers) ───────────────────────────────────
def do_outlook_unread_count(a, v): outlook_handlers.do_outlook_unread_count(a, v)
def do_outlook_latest(a, v): outlook_handlers.do_outlook_latest(a, v)
def do_outlook_send_clipboard(a, v): outlook_handlers.do_outlook_send_clipboard(a, v)
def do_outlook_summarize_inbox(a, v): outlook_handlers.do_outlook_summarize_inbox(a, v)

18
main.py
View file

@ -1699,6 +1699,24 @@ def run_action(action):
extensions.do_personality_how_are_you(action, _current_voice or '') extensions.do_personality_how_are_you(action, _current_voice or '')
elif t == 'personality_tony_quote': elif t == 'personality_tony_quote':
extensions.do_personality_tony_quote(action, _current_voice or '') extensions.do_personality_tony_quote(action, _current_voice or '')
elif t == 'tracker_start':
extensions.do_tracker_start(action, _current_voice or '')
elif t == 'tracker_stop':
extensions.do_tracker_stop(action, _current_voice or '')
elif t == 'tracker_today':
extensions.do_tracker_today(action, _current_voice or '')
elif t == 'tracker_week':
extensions.do_tracker_week(action, _current_voice or '')
elif t == 'tracker_reset':
extensions.do_tracker_reset(action, _current_voice or '')
elif t == 'outlook_unread_count':
extensions.do_outlook_unread_count(action, _current_voice or '')
elif t == 'outlook_latest':
extensions.do_outlook_latest(action, _current_voice or '')
elif t == 'outlook_send_clipboard':
extensions.do_outlook_send_clipboard(action, _current_voice or '')
elif t == 'outlook_summarize_inbox':
extensions.do_outlook_summarize_inbox(action, _current_voice or '')
_current_voice: str = '' _current_voice: str = ''

308
outlook_handlers.py Normal file
View file

@ -0,0 +1,308 @@
"""Outlook voice-command handlers — Python parity for the Rust `outlook` pack.
Talks to Outlook via the `win32com.client` COM bridge (pywin32). When pywin32
isn't installed or Outlook isn't running we degrade gracefully: every handler
reports the failure through the speak callback instead of crashing.
Exposes four handlers, mirroring the Rust pack:
do_outlook_unread_count(action, voice)
do_outlook_latest(action, voice)
do_outlook_send_clipboard(action, voice)
do_outlook_summarize_inbox(action, voice)
"""
import re
import subprocess
from typing import Optional
# Lazy import — pywin32 is Windows-only and may not be installed on dev/CI.
try:
import win32com.client as _w32com # type: ignore
_W32_OK = True
except Exception: # ImportError, OSError, etc.
_w32com = None
_W32_OK = False
_speak_fn = print
def set_speak(fn):
"""Register the voice output callback (called by extensions.set_speak)."""
global _speak_fn
_speak_fn = fn
def _speak(text: str) -> None:
try:
_speak_fn(text)
except Exception as exc:
print(f"[outlook] speak: {exc}")
# ── Outlook session helpers ────────────────────────────────────────────────
def _outlook_session():
"""Return (app, namespace) or (None, None) on any failure."""
if not _W32_OK:
return None, None
try:
app = _w32com.Dispatch("Outlook.Application")
ns = app.GetNamespace("MAPI")
return app, ns
except Exception as exc:
print(f"[outlook] session: {exc}")
return None, None
def _unavailable_msg() -> str:
return "Outlook недоступен — запусти Outlook сначала."
def _strip_html(s: str) -> str:
if not s:
return ""
s = re.sub(r"<[^>]+>", " ", s)
s = (s.replace("&nbsp;", " ").replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", '"').replace("&#39;", "'"))
return re.sub(r"\s+", " ", s).strip()
def _get_clipboard() -> str:
"""Fetch clipboard via PowerShell. Returns "" on failure."""
try:
res = subprocess.run(
["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"],
capture_output=True, text=True, encoding="utf-8", timeout=10,
)
return (res.stdout or "").strip()
except Exception as exc:
print(f"[outlook] clipboard: {exc}")
return ""
# ── handlers ───────────────────────────────────────────────────────────────
def do_outlook_unread_count(action, voice):
_, ns = _outlook_session()
if ns is None:
_speak(_unavailable_msg())
return
try:
inbox = ns.GetDefaultFolder(6) # olFolderInbox
# Prefer the cheap property; fall back to scanning items.
try:
count = int(inbox.UnReadItemCount)
except Exception:
count = sum(1 for it in inbox.Items if getattr(it, "UnRead", False))
except Exception as exc:
print(f"[outlook] unread_count: {exc}")
_speak(_unavailable_msg())
return
if count == 0:
_speak("Непрочитанных писем нет.")
elif count == 1:
_speak("Одно непрочитанное письмо.")
else:
_speak(f"Непрочитанных писем: {count}.")
def do_outlook_latest(action, voice):
_, ns = _outlook_session()
if ns is None:
_speak(_unavailable_msg())
return
try:
inbox = ns.GetDefaultFolder(6)
items = inbox.Items
items.Sort("[ReceivedTime]", True)
latest = None
for it in items:
try:
if it.Class == 43: # olMail
latest = it
break
except Exception:
continue
if latest is None:
_speak("Входящие пусты.")
return
from_name = (
getattr(latest, "SenderName", "")
or getattr(latest, "SenderEmailAddress", "")
or "неизвестного отправителя"
)
subject = getattr(latest, "Subject", "") or "без темы"
body = getattr(latest, "Body", "") or ""
if not body:
body = _strip_html(getattr(latest, "HTMLBody", "") or "")
body = re.sub(r"\s+", " ", body).strip()
if len(body) > 200:
body = body[:200] + "..."
except Exception as exc:
print(f"[outlook] latest: {exc}")
_speak(_unavailable_msg())
return
_speak(f"Последнее письмо от {from_name}. Тема: {subject}. {body}")
_SEND_TRIGGERS = (
"отправь письмо ", "отправь почту ", "напиши письмо ", "отправь это ",
"email this to ", "send email to ", "send this email to ", "mail this to ",
)
def _extract_recipient(voice: str) -> str:
"""Pull the recipient name off the voice command by stripping the trigger."""
if not voice:
return ""
low = voice.lower()
for trig in _SEND_TRIGGERS:
if trig in low:
idx = low.find(trig)
return voice[idx + len(trig):].strip()
return voice.strip()
def _resolve_recipient(ns, query: str) -> Optional[dict]:
if not query or ns is None:
return None
try:
r = ns.CreateRecipient(query)
r.Resolve()
if not r.Resolved:
return None
addr = None
try:
addr = r.AddressEntry.GetExchangeUser().PrimarySmtpAddress
except Exception:
pass
if not addr:
try:
addr = r.AddressEntry.Address
except Exception:
pass
if not addr:
addr = r.Name
return {"name": r.Name, "address": addr}
except Exception as exc:
print(f"[outlook] resolve: {exc}")
return None
def do_outlook_send_clipboard(action, voice):
recipient = _extract_recipient(voice or "")
if not recipient:
_speak("Кому отправить? Назови имя.")
return
app, ns = _outlook_session()
if ns is None or app is None:
_speak(_unavailable_msg())
return
resolved = _resolve_recipient(ns, recipient)
if not resolved:
_speak(f"Не нашёл контакт «{recipient}».")
return
body = _get_clipboard()
first_line = (body.split("\n", 1)[0] if body else "").strip()
if len(first_line) > 60:
subject = first_line[:60].strip()
else:
subject = first_line
if not subject:
subject = "From J.A.R.V.I.S."
try:
mail = app.CreateItem(0) # olMailItem
mail.To = resolved["address"]
mail.Subject = subject
mail.Body = body
mail.Send()
except Exception as exc:
print(f"[outlook] send: {exc}")
_speak("Не получилось отправить — Outlook отказал.")
return
_speak(f"Отправил {resolved['name']}. Тема: {subject}.")
def _llm_summarize(emails) -> Optional[str]:
"""Call the configured LLM to summarise the email list. Returns None on
any failure, callers should provide a fallback."""
try:
import llm_backend
except Exception:
return None
if not emails:
return None
listing = "\n".join(
f"{i+1}. From: {m['from']} | Subject: {m['subject']}"
for i, m in enumerate(emails)
)
sys_prompt = (
"Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем "
"(2-4 предложения). Сгруппируй по темам/отправителям если возможно. "
"Без преамбулы, без перечисления списком."
)
user_prompt = "Непрочитанные письма:\n" + listing
try:
reply = llm_backend.complete(
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=250,
temperature=0.4,
)
if reply and isinstance(reply, str) and reply.strip():
return reply.strip()
except Exception as exc:
print(f"[outlook] llm: {exc}")
return None
def do_outlook_summarize_inbox(action, voice, limit: int = 5):
_, ns = _outlook_session()
if ns is None:
_speak(_unavailable_msg())
return
try:
inbox = ns.GetDefaultFolder(6)
items = inbox.Items
items.Sort("[ReceivedTime]", True)
emails = []
for it in items:
if len(emails) >= limit:
break
try:
if it.Class != 43:
continue
if not getattr(it, "UnRead", False):
continue
except Exception:
continue
emails.append({
"from": getattr(it, "SenderName", "") or "",
"subject": getattr(it, "Subject", "") or "",
})
except Exception as exc:
print(f"[outlook] summarize: {exc}")
_speak(_unavailable_msg())
return
if not emails:
_speak("Непрочитанных писем нет.")
return
summary = _llm_summarize(emails)
if not summary:
# Fallback to a flat enumeration.
parts = "; ".join(f"{m['from']}{m['subject']}" for m in emails)
summary = f"Непрочитанных: {len(emails)}. {parts}."
_speak(summary)

View file

@ -27,6 +27,10 @@ sounddevice
PyYAML PyYAML
python-dotenv python-dotenv
# Outlook COM automation (Win32-only). Optional: outlook_handlers degrades
# gracefully when this isn't installed.
pywin32>=305
# Semantic intent classifier (MiniLM-L6-v2 ONNX) # Semantic intent classifier (MiniLM-L6-v2 ONNX)
onnxruntime>=1.17 onnxruntime>=1.17
tokenizers>=0.15 tokenizers>=0.15

View file

@ -0,0 +1,103 @@
"""Tests for outlook_handlers — COM bridge mocked via unittest.mock.
We patch the module-level `_outlook_session` to return a fake (app, ns) pair
made of MagicMocks so we never need a live Outlook install.
"""
import importlib
import sys
from unittest.mock import MagicMock, patch
def _fresh_module():
if 'outlook_handlers' in sys.modules:
del sys.modules['outlook_handlers']
return importlib.import_module('outlook_handlers')
def _make_inbox_with_unread(count):
"""Build a fake Inbox object whose UnReadItemCount is `count`."""
inbox = MagicMock()
inbox.UnReadItemCount = count
return inbox
def test_unread_count_speaks_singular_for_one():
mod = _fresh_module()
spoken = []
mod.set_speak(lambda t: spoken.append(t))
app = MagicMock()
ns = MagicMock()
ns.GetDefaultFolder.return_value = _make_inbox_with_unread(1)
with patch.object(mod, '_outlook_session', return_value=(app, ns)):
mod.do_outlook_unread_count({}, '')
assert spoken, "expected the handler to speak"
assert "Одно" in spoken[0]
def test_unread_count_zero_says_no_unread():
mod = _fresh_module()
spoken = []
mod.set_speak(lambda t: spoken.append(t))
app = MagicMock()
ns = MagicMock()
ns.GetDefaultFolder.return_value = _make_inbox_with_unread(0)
with patch.object(mod, '_outlook_session', return_value=(app, ns)):
mod.do_outlook_unread_count({}, '')
assert spoken
assert "нет" in spoken[0].lower()
def test_unread_count_plural_count():
mod = _fresh_module()
spoken = []
mod.set_speak(lambda t: spoken.append(t))
ns = MagicMock()
ns.GetDefaultFolder.return_value = _make_inbox_with_unread(7)
with patch.object(mod, '_outlook_session', return_value=(MagicMock(), ns)):
mod.do_outlook_unread_count({}, '')
assert "7" in spoken[0]
def test_unavailable_outlook_says_so():
mod = _fresh_module()
spoken = []
mod.set_speak(lambda t: spoken.append(t))
with patch.object(mod, '_outlook_session', return_value=(None, None)):
mod.do_outlook_unread_count({}, '')
assert spoken
assert "Outlook" in spoken[0]
def test_extract_recipient_strips_trigger():
mod = _fresh_module()
assert mod._extract_recipient("отправь письмо Дмитрию") == "Дмитрию"
assert mod._extract_recipient("email this to Bob") == "Bob"
assert mod._extract_recipient("Bob") == "Bob"
assert mod._extract_recipient("") == ""
def test_send_clipboard_no_recipient_prompts_for_name():
mod = _fresh_module()
spoken = []
mod.set_speak(lambda t: spoken.append(t))
# No trigger and no recipient in the voice -> _extract_recipient returns ""
mod.do_outlook_send_clipboard({}, "")
assert spoken
assert "имя" in spoken[0].lower() or "name" in spoken[0].lower()
def test_strip_html_basic():
mod = _fresh_module()
assert mod._strip_html("<b>Hi</b>&nbsp;there") == "Hi there"
assert mod._strip_html("") == ""

179
tests/test_time_tracker.py Normal file
View file

@ -0,0 +1,179 @@
"""Tests for time_tracker_handlers — parity with rust time_tracker pack."""
import json
import os
import time as _time
import pytest
def test_load_returns_empty_when_file_missing(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
data = tt.load()
assert data == {'current_session_start': None, 'sessions': []}
def test_start_session_records_timestamp(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
tt.start_session(now=1_700_000_000)
data = tt.load()
assert data['current_session_start'] == 1_700_000_000
assert data['sessions'] == []
def test_start_session_is_idempotent_when_already_running(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
tt.start_session(now=1_700_000_000)
tt.start_session(now=1_700_010_000) # second start should not overwrite
data = tt.load()
assert data['current_session_start'] == 1_700_000_000
def test_stop_session_pushes_completed(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
tt.start_session(now=1_700_000_000)
_, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour
assert elapsed == 3600
data = tt.load()
assert data['current_session_start'] is None
assert len(data['sessions']) == 1
assert data['sessions'][0]['start'] == 1_700_000_000
assert data['sessions'][0]['end'] == 1_700_003_600
def test_stop_session_without_open_returns_none(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
_, elapsed = tt.stop_session(now=1_700_000_000)
assert elapsed is None
def test_today_total_sums_overlap_in_local_day(isolated_state):
"""Two sessions today + one yesterday — only today's pair is counted."""
tt = isolated_state('time_tracker_handlers', ['_PATH'])
# Pick a deterministic "now" at 15:00 local time today.
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
now = today_midnight + 15 * 3600 # 15:00 local today
data = {
'current_session_start': None,
'sessions': [
# yesterday: 10:00 → 12:00 (2h, ignored)
{'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600},
# today: 09:00 → 10:30 (90 min)
{'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800},
# today: 13:00 → 14:00 (60 min)
{'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600},
],
}
tt.save(data)
total = tt.today_total(now=now)
assert total == 90 * 60 + 60 * 60 # 2h30m
def test_today_total_includes_open_session(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
now = today_midnight + 10 * 3600 # 10:00
data = {
'current_session_start': today_midnight + 9 * 3600, # opened at 09:00
'sessions': [],
}
tt.save(data)
total = tt.today_total(now=now)
assert total == 3600 # 1h of open session counts
def test_week_total_covers_last_seven_days(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
now = today_midnight + 12 * 3600
data = {
'current_session_start': None,
'sessions': [
# 8 days ago: outside window
{'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600},
# 3 days ago: inside window (1h)
{'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600},
# today: 2h
{'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600},
],
}
tt.save(data)
total = tt.week_total(now=now)
assert total == 3 * 3600 # 3 hours
def test_reset_today_drops_only_todays_sessions(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
now = today_midnight + 14 * 3600
data = {
'current_session_start': today_midnight + 13 * 3600,
'sessions': [
{'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday
{'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today
{'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today
],
}
tt.save(data)
dropped, had_open = tt.reset_today(now=now)
assert dropped == 2
assert had_open is True
after = tt.load()
assert after['current_session_start'] is None
assert len(after['sessions']) == 1
assert after['sessions'][0]['start'] == today_midnight - 86400
def test_reset_today_when_empty_reports_zero(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
dropped, had_open = tt.reset_today(now=1_700_000_000)
assert dropped == 0
assert had_open is False
def test_format_duration_ru_pluralisation(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
assert tt.format_duration_ru(0) == "0 минут"
assert tt.format_duration_ru(60) == "1 минута"
assert tt.format_duration_ru(2 * 60) == "2 минуты"
assert tt.format_duration_ru(5 * 60) == "5 минут"
assert tt.format_duration_ru(11 * 60) == "11 минут"
assert tt.format_duration_ru(3600) == "1 час"
assert tt.format_duration_ru(2 * 3600) == "2 часа"
assert tt.format_duration_ru(5 * 3600) == "5 часов"
assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты"
def test_persists_atomic_write(isolated_state):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
tt.start_session(now=1_700_000_000)
assert os.path.isfile(tt._PATH)
with open(tt._PATH, encoding='utf-8') as f:
on_disk = json.load(f)
assert on_disk['current_session_start'] == 1_700_000_000
def test_voice_entry_speaks_start_message(isolated_state, monkeypatch):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
spoken = []
monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s))
tt.do_tracker_start({}, '')
assert spoken and 'Отсчёт' in spoken[0]
# second call says already running
tt.do_tracker_start({}, '')
assert any('Уже считаю' in s for s in spoken)
def test_voice_entry_today_reports_total(isolated_state, monkeypatch):
tt = isolated_state('time_tracker_handlers', ['_PATH'])
spoken = []
monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s))
# No data → "ничего не отработано"
tt.do_tracker_today({}, '')
assert spoken and 'ничего не отработано' in spoken[-1].lower()

263
time_tracker_handlers.py Normal file
View file

@ -0,0 +1,263 @@
"""Time tracker — voice-driven daily work-time log.
Parity with the rust `time_tracker` command pack. Stores active session state
and a list of completed sessions in `<script_dir>/time_tracker.json` with
atomic write-through (write-then-rename).
Public API:
do_tracker_start(action, voice) -> None
do_tracker_stop(action, voice) -> None
do_tracker_today(action, voice) -> None
do_tracker_week(action, voice) -> None
do_tracker_reset(action, voice) -> None
Lower-level helpers (also used by tests):
load() -> dict
save(data) -> None
start_session(now=None) -> dict # returns updated state
stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds)
today_total(now=None) -> int # total seconds today (incl. open session)
week_total(now=None) -> int # total seconds across last 7 days
reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open)
"""
import json
import os
import time as _time
_HERE = os.path.dirname(os.path.abspath(__file__))
_PATH = os.path.join(_HERE, 'time_tracker.json')
_speak_fn = print
def set_speak(fn):
global _speak_fn
_speak_fn = fn
def _speak(text):
try:
_speak_fn(text)
except Exception as exc:
print(f"[tracker] speak: {exc}")
# ── persistence ──────────────────────────────────────────────────────────
def load() -> dict:
if not os.path.isfile(_PATH):
return {'current_session_start': None, 'sessions': []}
try:
with open(_PATH, encoding='utf-8') as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
print(f"[tracker] corrupt store ({exc}) — starting empty")
return {'current_session_start': None, 'sessions': []}
if not isinstance(data, dict):
return {'current_session_start': None, 'sessions': []}
data.setdefault('current_session_start', None)
data.setdefault('sessions', [])
return data
def save(data: dict) -> None:
tmp = _PATH + '.tmp'
try:
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, _PATH)
except OSError as exc:
print(f"[tracker] save failed: {exc}")
# ── local-day math ───────────────────────────────────────────────────────
def _local_today_start(now: int) -> int:
"""Return the unix timestamp of local midnight on the day that contains
`now`."""
tm = _time.localtime(now)
return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec)
# ── operations ───────────────────────────────────────────────────────────
def start_session(now: int | None = None) -> dict:
"""Open a new active session if none is running. Returns the new state."""
if now is None:
now = int(_time.time())
data = load()
if data.get('current_session_start') is None:
data['current_session_start'] = now
save(data)
return data
def stop_session(now: int | None = None) -> tuple[dict, int | None]:
"""Close the open session (if any). Returns (state, elapsed_seconds)."""
if now is None:
now = int(_time.time())
data = load()
start_ts = data.get('current_session_start')
if start_ts is None:
return data, None
elapsed = max(1, now - int(start_ts))
sessions = data.get('sessions') or []
sessions.append({'start': int(start_ts), 'end': now})
data['sessions'] = sessions
data['current_session_start'] = None
save(data)
return data, elapsed
def _sum_overlap(sessions: list[dict], open_start: int | None,
lo: int, hi: int) -> int:
total = 0
for sess in sessions:
s = int(sess.get('start') or 0)
e = int(sess.get('end') or 0)
a = max(s, lo)
b = min(e, hi)
if b > a:
total += b - a
if open_start is not None:
a = max(int(open_start), lo)
if hi > a:
total += hi - a
return total
def today_total(now: int | None = None) -> int:
if now is None:
now = int(_time.time())
data = load()
today_start = _local_today_start(now)
return _sum_overlap(data.get('sessions') or [],
data.get('current_session_start'),
today_start, now)
def week_total(now: int | None = None) -> int:
if now is None:
now = int(_time.time())
data = load()
today_start = _local_today_start(now)
week_start = today_start - 6 * 86400
return _sum_overlap(data.get('sessions') or [],
data.get('current_session_start'),
week_start, now)
def reset_today(now: int | None = None) -> tuple[int, bool]:
"""Drop today's completed sessions and stop any open one.
Returns (dropped_count, had_open_session)."""
if now is None:
now = int(_time.time())
data = load()
today_start = _local_today_start(now)
sessions = data.get('sessions') or []
kept = [s for s in sessions if int(s.get('start') or 0) < today_start]
dropped = len(sessions) - len(kept)
had_open = data.get('current_session_start') is not None
data['sessions'] = kept
data['current_session_start'] = None
save(data)
return dropped, had_open
# ── formatting ───────────────────────────────────────────────────────────
def _ru_hour_word(n: int) -> str:
n100 = n % 100
if 11 <= n100 <= 14:
return 'часов'
n10 = n % 10
if n10 == 1:
return 'час'
if 2 <= n10 <= 4:
return 'часа'
return 'часов'
def _ru_minute_word(n: int) -> str:
n100 = n % 100
if 11 <= n100 <= 14:
return 'минут'
n10 = n % 10
if n10 == 1:
return 'минута'
if 2 <= n10 <= 4:
return 'минуты'
return 'минут'
def _ru_session_word(n: int) -> str:
n100 = n % 100
if 11 <= n100 <= 14:
return 'сессий'
n10 = n % 10
if n10 == 1:
return 'сессия'
if 2 <= n10 <= 4:
return 'сессии'
return 'сессий'
def format_duration_ru(seconds: int) -> str:
seconds = max(0, int(seconds))
h = seconds // 3600
m = (seconds % 3600) // 60
if h > 0 and m > 0:
return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}"
if h > 0:
return f"{h} {_ru_hour_word(h)}"
return f"{m} {_ru_minute_word(m)}"
# ── voice entry points ───────────────────────────────────────────────────
def do_tracker_start(action, voice):
data = load()
if data.get('current_session_start') is not None:
_speak("Уже считаю, сэр.")
return
start_session()
_speak("Отсчёт пошёл.")
def do_tracker_stop(action, voice):
_, elapsed = stop_session()
if elapsed is None:
_speak("Трекер не запущен, сэр.")
return
_speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.")
def do_tracker_today(action, voice):
total = today_total()
if total < 60:
_speak("Сегодня ещё ничего не отработано, сэр.")
return
_speak(f"Сегодня отработано {format_duration_ru(total)}.")
def do_tracker_week(action, voice):
total = week_total()
if total < 60:
_speak("За эту неделю ничего не отработано, сэр.")
return
_speak(f"За неделю отработано {format_duration_ru(total)}.")
def do_tracker_reset(action, voice):
dropped, had_open = reset_today()
if dropped == 0 and not had_open:
_speak("Сегодня сбрасывать нечего, сэр.")
return
parts = ["Подтверждаю: трекер за сегодня очищен"]
if dropped > 0:
parts.append(f", удалено {dropped} {_ru_session_word(dropped)}")
if had_open:
parts.append(", активный отсчёт остановлен")
parts.append(".")
_speak("".join(parts))