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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue