feat: Wave 5 Python parity — outlook COM + time tracker
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:
parent
45d9425ed7
commit
5265cb0fbe
8 changed files with 971 additions and 0 deletions
|
|
@ -1549,3 +1549,78 @@ 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}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import dev_handlers
|
|||
import llm_backend
|
||||
import wave1_handlers
|
||||
import personality_handlers
|
||||
import time_tracker_handlers
|
||||
import outlook_handlers
|
||||
|
||||
_speak_fn = print
|
||||
_set_clipboard_fn = None
|
||||
|
|
@ -43,6 +45,8 @@ def set_speak(fn):
|
|||
dev_handlers.set_speak(fn)
|
||||
wave1_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):
|
||||
|
|
@ -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_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)
|
||||
|
||||
|
||||
# ── 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
18
main.py
|
|
@ -1699,6 +1699,24 @@ def run_action(action):
|
|||
extensions.do_personality_how_are_you(action, _current_voice or '')
|
||||
elif t == 'personality_tony_quote':
|
||||
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 = ''
|
||||
|
|
|
|||
308
outlook_handlers.py
Normal file
308
outlook_handlers.py
Normal 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(" ", " ").replace("&", "&")
|
||||
.replace("<", "<").replace(">", ">")
|
||||
.replace(""", '"').replace("'", "'"))
|
||||
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)
|
||||
|
|
@ -27,6 +27,10 @@ sounddevice
|
|||
PyYAML
|
||||
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)
|
||||
onnxruntime>=1.17
|
||||
tokenizers>=0.15
|
||||
|
|
|
|||
103
tests/test_outlook_handlers.py
Normal file
103
tests/test_outlook_handlers.py
Normal 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> there") == "Hi there"
|
||||
assert mod._strip_html("") == ""
|
||||
179
tests/test_time_tracker.py
Normal file
179
tests/test_time_tracker.py
Normal 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
263
time_tracker_handlers.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue