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

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)