J.A.R.V.I.S-py/outlook_handlers.py
Bossiara13 5265cb0fbe
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
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).
2026-05-16 14:34:44 +03:00

308 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)