J.A.R.V.I.S-py/extensions.py
Bossiara13 f102b8dc95
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Has been cancelled
Python CI / pytest (windows-latest) (push) Has been cancelled
Python CI / ruff (push) Has been cancelled
feat: expense tracker Python parity (Wave 10)
New `expenses_handlers.py`:
- parse_expense(phrase) -> (amount, category) | None  — pure fn, fully
  unit-tested. Handles "потратил 500 на еду", comma decimals "12,50",
  EN "spent 12.5 on coffee", default category "разное".
- do_expenses_log: append entry to expenses.json (atomic write-then-rename).
- do_expenses_today: sum today's entries (local-day boundary).
- do_expenses_week: rolling 7-day total + count.
- do_expenses_breakdown: per-category, top-5 + tail collapse.

extensions.py / main.py / commands.yaml: 4 new dispatch entries.
.gitignore: + expenses.json.

tests/test_expenses_handlers.py: 8 unit tests covering parse, log,
today/week math, breakdown ordering + tail collapse.

Tests: 96 → 104 (+8). Total Python coverage now mirrors Rust pack
behaviour for personal finance.
2026-05-16 15:02:07 +03:00

1178 lines
43 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.

"""Extension handlers — new commands ported from the Rust fork.
Each function takes (action, voice) and returns nothing; it speaks via the
`speak` callback passed in from main.py. We keep the speech callback as
a module global to avoid passing it through every call site.
Usage from main.py:
import extensions
extensions.set_speak(_speak)
# then dispatch:
if t == 'lock_workstation': extensions.do_lock(action, voice)
"""
import os
import math
import random
import subprocess
import time
import uuid as uuidlib
import re
import urllib.request
import memory_store
import profiles_store
import macros_store
import scheduler_store
import vision_handler
import dev_handlers
import llm_backend
import wave1_handlers
import personality_handlers
import time_tracker_handlers
import outlook_handlers
import wave68_handlers
import expenses_handlers
_speak_fn = print
_set_clipboard_fn = None
def set_speak(fn):
"""Register the main.py _speak function as our voice output."""
global _speak_fn
_speak_fn = fn
scheduler_store.set_speak(fn)
vision_handler.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)
wave68_handlers.set_speak(fn)
expenses_handlers.set_speak(fn)
def set_clipboard_fn(fn):
"""Register the main.py _set_clipboard helper."""
global _set_clipboard_fn
_set_clipboard_fn = fn
def init_background_services():
"""Boot scheduler. Idempotent. Called once from main.py at startup."""
scheduler_store.init()
def _speak(text):
try:
_speak_fn(text)
except Exception as exc:
print(f"[ext] speak failed: {exc}")
def _clip(text):
if _set_clipboard_fn:
try:
_set_clipboard_fn(text)
except Exception as exc:
print(f"[ext] clipboard set failed: {exc}")
def _ps(cmd, timeout=15):
"""Run a PowerShell command, return stripped stdout."""
res = subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd],
capture_output=True, text=True, encoding='utf-8', timeout=timeout,
)
return (res.stdout or '').strip()
# ── lock_workstation ───────────────────────────────────────────────────────
def do_lock_workstation(action, voice):
"""Lock the workstation via LockWorkStation() in user32.dll."""
subprocess.Popen(['rundll32.exe', 'user32.dll,LockWorkStation'])
_speak("Компьютер заблокирован.")
# ── screenshot ─────────────────────────────────────────────────────────────
_SHOT_TO_CLIP_PS = (
"Add-Type -AssemblyName System.Windows.Forms; "
"Add-Type -AssemblyName System.Drawing; "
"$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; "
"$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; "
"$g = [System.Drawing.Graphics]::FromImage($bmp); "
"$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); "
"[System.Windows.Forms.Clipboard]::SetImage($bmp); "
"$g.Dispose(); $bmp.Dispose()"
)
def do_screenshot(action, voice):
mode = action.get('mode', 'clipboard')
if mode == 'clipboard':
try:
subprocess.run(
['powershell', '-NoProfile', '-Command', _SHOT_TO_CLIP_PS],
capture_output=True, timeout=10, check=True,
)
_speak("Скриншот в буфере.")
except Exception as exc:
print(f"[ext] screenshot clipboard failed: {exc}")
_speak("Не получилось сделать скриншот.")
elif mode == 'file':
userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public')
screens_dir = os.path.join(userprofile, 'Pictures', 'Screenshots')
os.makedirs(screens_dir, exist_ok=True)
ts = time.strftime('%Y%m%d-%H%M%S')
path = os.path.join(screens_dir, f'jarvis-{ts}.png')
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"Add-Type -AssemblyName System.Drawing; "
"$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; "
"$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; "
"$g = [System.Drawing.Graphics]::FromImage($bmp); "
"$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); "
f"$bmp.Save('{path}', [System.Drawing.Imaging.ImageFormat]::Png); "
"$g.Dispose(); $bmp.Dispose()"
)
try:
subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, timeout=10, check=True)
_speak("Скриншот сохранён в папку Скриншоты.")
except Exception as exc:
print(f"[ext] screenshot file failed: {exc}")
_speak("Не получилось сохранить скриншот.")
# ── disk space ─────────────────────────────────────────────────────────────
def do_disk_free(action, voice):
"""Reports free GB on the requested drive (defaults to C)."""
letter = (action.get('letter') or 'C').upper()
# Pull letter from voice if user named one
m = re.search(r'диск[еа]?\s*([A-ZА-Я])', (voice or '').upper())
if m:
letter = m.group(1)
# cyrillic Е/А fallback to C
if letter not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
letter = 'C'
out = _ps(
f"$d = Get-PSDrive {letter} -ErrorAction SilentlyContinue; "
"if ($d) {{ '{{0}}|{{1}}' -f "
"[math]::Round($d.Free/1GB,1), "
"[math]::Round(($d.Free + $d.Used)/1GB,0) }} else {{ 'NONE' }}".format(
letter=letter
)
)
if out == 'NONE' or '|' not in out:
_speak(f"Диск {letter} не найден.")
return
free, total = out.split('|', 1)
try:
pct = int(round(float(free) / float(total) * 100))
_speak(f"На диске {letter} свободно {free} гигабайт из {total}, это {pct} процентов.")
except (ValueError, ZeroDivisionError):
_speak(f"На диске {letter} свободно {free} гигабайт.")
def do_disk_list(action, voice):
out = _ps(
"Get-PSDrive -PSProvider FileSystem | "
"Where-Object { $_.Used -ne $null -or $_.Free -ne $null } | "
"ForEach-Object { '{0}:{1}' -f $_.Name, [math]::Round($_.Free/1GB,0) }"
)
if not out:
_speak("Дисков не нашёл.")
return
parts = []
for tok in out.split():
m = re.match(r'([A-Z]):(\d+)', tok)
if m:
parts.append(f"{m.group(1)} {m.group(2)} гигабайт")
if not parts:
_speak("Не понял ответ системы.")
return
_speak("Свободно: " + ", ".join(parts) + ".")
# ── random generators ──────────────────────────────────────────────────────
def do_coin(action, voice):
if random.randint(1, 2) == 1:
_speak("Орёл!")
else:
_speak("Решка!")
_PASS_POOL = (
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_=+'
)
def do_password_gen(action, voice):
m = re.search(r'(\d+)', voice or '')
n = int(m.group(1)) if m else 16
n = max(6, min(64, n))
password = ''.join(random.choice(_PASS_POOL) for _ in range(n))
_clip(password)
_speak(f"Пароль на {n} символов в буфере.")
def do_uuid(action, voice):
u = str(uuidlib.uuid4())
_clip(u)
_speak(f"UUID в буфере, заканчивается на {u[-4:]}.")
# ── SSL cert check ─────────────────────────────────────────────────────────
def do_ssl_check(action, voice):
domain = action.get('domain') or _extract_domain(voice)
if not domain:
_speak("Какой домен проверить?")
return
domain = domain.replace('https://', '').replace('http://', '').split('/')[0]
ps = f"""
$ErrorActionPreference = 'Stop'
try {{
$tcp = New-Object Net.Sockets.TcpClient('{domain}', 443)
$ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, {{ $true }})
$ssl.AuthenticateAsClient('{domain}')
$c = New-Object Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate)
$days = ($c.NotAfter - (Get-Date)).Days
Write-Output ('{{0}}|{{1}}' -f $days, $c.NotAfter.ToString('yyyy-MM-dd'))
$ssl.Close(); $tcp.Close()
}} catch {{ Write-Output ('ERR|' + $_.Exception.Message) }}
"""
out = _ps(ps)
if out.startswith('ERR|'):
_speak(f"Не получилось: {out[4:80]}")
return
if '|' not in out:
_speak("Не распарсил ответ.")
return
days, date = out.split('|', 1)
try:
days_n = int(days)
except ValueError:
_speak("Не понял ответ.")
return
prefix = ''
if days_n < 0:
prefix = "Сертификат уже просрочен! "
elif days_n < 7:
prefix = "СКОРО! "
elif days_n < 30:
prefix = "Скоро. "
_speak(f"{prefix}Сертификат {domain} истекает через {days} дней ({date}).")
def _extract_domain(voice):
if not voice:
return ''
# crude: pick last token that looks like a domain (has a dot)
for tok in voice.split():
if '.' in tok and len(tok) > 3:
return tok
return ''
# ── WiFi SSID ──────────────────────────────────────────────────────────────
def do_wifi_ssid(action, voice):
res = subprocess.run(['netsh', 'wlan', 'show', 'interfaces'],
capture_output=True, text=True, encoding='cp866', timeout=5)
out = res.stdout or ''
# Try ru + en field names
m = re.search(r'^\s*SSID\s+:\s+(.+?)$', out, re.MULTILINE)
if not m:
m = re.search(r'Имя сети\s+:\s+(.+?)$', out, re.MULTILINE)
if m:
ssid = m.group(1).strip()
if ssid in ('(none)', '(нет)'):
_speak("Wi-Fi не подключен.")
return
_speak(f"Сеть: {ssid}.")
else:
_speak("Wi-Fi не подключен или адаптер выключен.")
def do_public_ip(action, voice):
try:
with urllib.request.urlopen('https://api.ipify.org', timeout=5) as r:
ip = r.read().decode().strip()
if ip:
_speak(f"Внешний адрес: {ip}.")
else:
_speak("Сервис вернул пустой ответ.")
except Exception as exc:
print(f"[ext] public_ip failed: {exc}")
_speak("Не получилось узнать внешний IP.")
# ── unit conversion ────────────────────────────────────────────────────────
def _grab_number(voice):
m = re.search(r'(-?[\d.,]+)', voice or '')
if not m:
return None
try:
return float(m.group(1).replace(',', '.'))
except ValueError:
return None
def _ru_plural(n, one, few, many):
"""Russian-grammar pluralisation: 1 фут, 2-4 фута, 5+ футов."""
nn = abs(int(n))
last_two = nn % 100
last = nn % 10
if 11 <= last_two <= 19:
return many
if last == 1:
return one
if 2 <= last <= 4:
return few
return many
def do_unit_convert(action, voice):
mode = action.get('mode', 'length')
n = _grab_number(voice)
if n is None:
_speak("Не понял число.")
return
low = (voice or '').lower()
if mode == 'length':
if 'фут' in low and 'в метр' not in low:
result = n * 3.28084
unit = _ru_plural(result, 'фут', 'фута', 'футов')
elif 'метр' in low and 'в фут' not in low:
result = n / 3.28084
unit = 'метров'
elif 'мил' in low and 'в км' not in low:
result = n * 0.621371
unit = _ru_plural(result, 'миля', 'мили', 'миль')
elif 'км' in low or 'километр' in low:
result = n * 1.60934
unit = 'километров'
else:
_speak("Не понял в чём перевести.")
return
_speak(f"{round(result, 2):g} {unit}.")
elif mode == 'weight':
if 'фунт' in low and 'в кг' not in low:
result = n * 2.20462
unit = _ru_plural(result, 'фунт', 'фунта', 'фунтов')
else:
result = n / 2.20462
unit = 'килограмм'
_speak(f"{round(result, 2):g} {unit}.")
elif mode == 'temperature':
if 'фаренгейт' in low or 'fahrenheit' in low:
result = n * 9 / 5 + 32
unit = 'по Фаренгейту'
else:
result = (n - 32) * 5 / 9
unit = 'по Цельсию'
_speak(f"{round(result, 1):g} градусов {unit}.")
elif mode == 'speed':
if 'мил' in low and 'в км' not in low:
result = n * 0.621371
unit = 'миль в час'
else:
result = n * 1.60934
unit = 'километров в час'
_speak(f"{int(round(result))} {unit}.")
else:
_speak(f"Неизвестный режим конверсии: {mode}.")
# ── date math ──────────────────────────────────────────────────────────────
_MONTH_STEMS = {
'январ': 1, 'феврал': 2, 'март': 3, 'апрел': 4,
'мая': 5, 'май': 5, 'июн': 6, 'июл': 7,
'август': 8, 'сентябр': 9, 'октябр': 10, 'ноябр': 11, 'декабр': 12,
}
_DAYS_RU = [
'понедельник', 'вторник', 'среда', 'четверг',
'пятница', 'суббота', 'воскресенье',
]
def do_date_math(action, voice):
import datetime as dt
mode = action.get('mode', 'days_until')
low = (voice or '').lower()
today = dt.date.today()
if mode == 'day_of_week':
# weekday() returns 0=Monday..6=Sunday
_speak(f"Сегодня {_DAYS_RU[today.weekday()]}.")
return
# days_until
target = None
if 'нов' in low and 'год' in low:
ny = today.year if (today.month == 1 and today.day == 1) else today.year + 1
target = dt.date(ny, 1, 1)
elif 'рожд' in low:
target = dt.date(today.year, 1, 7)
elif '8 март' in low or 'восьмого март' in low:
target = dt.date(today.year, 3, 8)
elif '9 ма' in low or 'девятое ма' in low:
target = dt.date(today.year, 5, 9)
else:
m = re.search(r'до\s+(\d+)', low)
if m:
day = int(m.group(1))
for stem, month in _MONTH_STEMS.items():
if stem in low:
try:
target = dt.date(today.year, month, day)
except ValueError:
target = None
break
if target is None:
_speak("Не понял дату.")
return
if target < today:
target = target.replace(year=target.year + 1)
diff = (target - today).days
if diff == 0:
_speak("Сегодня.")
return
unit = _ru_plural(diff, 'день', 'дня', 'дней')
_speak(f"{diff} {unit}.")
# ── echo / self-check ──────────────────────────────────────────────────────
def do_echo_repeat(action, voice):
body = (voice or '').strip()
for trig in ('повтори за мной', 'скажи как я', 'эхо', 'repeat after me', 'echo'):
idx = body.lower().find(trig)
if idx == 0:
body = body[len(trig):].strip(' ,.:')
break
if not body:
_speak("Что повторить?")
return
_speak(body)
def do_self_ping(action, voice):
options = ['Да, сэр.', 'Здесь, сэр.', 'Слушаю.', 'К вашим услугам.', 'На месте.']
_speak(random.choice(options))
# ── memory ──────────────────────────────────────────────────────────────────
_MEMORY_REMEMBER_TRIGGERS = (
'запомни обо мне что', 'запомни обо мне', 'запомни что',
'помни что', 'запомни это', 'запомни про меня', 'запомни',
)
def do_memory_remember(action, voice):
body = (voice or '').strip()
low = body.lower()
for trig in _MEMORY_REMEMBER_TRIGGERS:
if low.startswith(trig):
body = body[len(trig):].strip(' ,.:')
break
if not body:
_speak("Что именно запомнить?")
return
if '=' in body:
key, value = body.split('=', 1)
key, value = key.strip(), value.strip()
else:
words = body.split()
key = ' '.join(words[:6])
value = body
if not key:
_speak("Не понял ключ.")
return
memory_store.remember(key, value)
_speak("Запомнил.")
def do_memory_recall(action, voice):
body = (voice or '').lower()
query = ''
for trig in ('что ты помнишь о', 'что ты помнишь про', 'что ты знаешь обо мне',
'что помнишь про', 'вспомни'):
idx = body.find(trig)
if idx >= 0:
query = body[idx + len(trig):].strip(' ,.:')
break
if not query:
recs = memory_store.all_facts()
if not recs:
_speak("Ничего пока не помню.")
return
line = "Я помню: "
for r in recs[:3]:
line += f"{r['key']}{r['value']}. "
_speak(line)
return
hits = memory_store.search(query, 3)
if not hits:
_speak(f"Ничего не нашёл про {query}.")
return
if len(hits) == 1:
_speak(hits[0]['value'])
else:
_speak('. '.join(f"{h['key']}: {h['value']}" for h in hits))
def do_memory_forget(action, voice):
body = (voice or '').lower()
for trig in ('забудь про', 'забудь что', 'забудь обо мне'):
idx = body.find(trig)
if idx >= 0:
key = body[idx + len(trig):].strip(' ,.:')
if memory_store.forget(key):
_speak(f"Забыл про {key}.")
return
hits = memory_store.search(key, 1)
if hits:
memory_store.forget(hits[0]['key'])
_speak(f"Забыл про {hits[0]['key']}.")
return
_speak(f"Я и не помнил про {key}.")
return
_speak("Что забыть?")
def do_memory_list(action, voice):
recs = memory_store.all_facts()
if not recs:
_speak("Я пока ничего о вас не запомнил.")
return
sample = min(len(recs), 5)
line = f"Помню {len(recs)} фактов. Первые: "
line += ", ".join(r['key'] for r in recs[:sample]) + "."
_speak(line)
# ── profile switch ──────────────────────────────────────────────────────────
def do_profile_set(action, voice):
target = action.get('target') or _profile_target_from_voice(voice)
if not target:
_speak("Не понял какой режим.")
return
try:
p = profiles_store.set_active(target)
except ValueError:
_speak(f"Профиль {target} не найден.")
return
greeting = p.get('greeting', '') or f"Режим {target}."
_speak(greeting)
def _profile_target_from_voice(voice):
low = (voice or '').lower()
if 'работ' in low: return 'work'
if 'игр' in low: return 'game'
if 'сон' in low or 'спать' in low or 'тиш' in low: return 'sleep'
if 'руль' in low or 'вожд' in low or 'поехали' in low: return 'driving'
if 'обычн' in low or 'умолчан' in low or 'сброс' in low or 'верн' in low: return 'default'
return ''
def do_profile_what(action, voice):
p = profiles_store.active()
name = p.get('name', 'default')
desc = p.get('description', '')
msg = f"Сейчас {name}."
if desc:
msg += " " + desc
_speak(msg)
# ── vision ──────────────────────────────────────────────────────────────────
def do_vision_describe(action, voice):
vision_handler.do_vision_describe(action, voice)
def do_vision_read_error(action, voice):
vision_handler.do_vision_read_error(action, voice)
# ── macros ──────────────────────────────────────────────────────────────────
def _strip_trig_lower(voice: str, triggers):
low = (voice or '').lower()
for t in triggers:
idx = low.find(t)
if idx >= 0:
return (voice or '')[idx + len(t):].strip(' ,.:')
return voice or ''
def do_macros_start(action, voice):
name = _strip_trig_lower(voice, (
'начни запись макроса', 'записывай макрос',
'запиши макрос', 'новый макрос',
)).strip()
if not name:
_speak("Как назвать макрос?")
return
try:
macros_store.start(name)
except ValueError as exc:
_speak(f"Не получилось: {exc}.")
return
_speak(f"Записываю макрос {name}. Говорите команды. Когда закончите — скажите сохрани макрос.")
def do_macros_save(action, voice):
try:
count = macros_store.save()
except (RuntimeError,) as exc:
_speak(str(exc))
return
_speak(f"Сохранил {count} шагов.")
def do_macros_cancel(action, voice):
if macros_store.cancel():
_speak("Запись макроса отменена.")
else:
_speak("Записи не было.")
_DISPATCH_FN = None
def set_dispatch_fn(fn):
"""Register the main.py `execute_cmd(matched_phrase, voice)` for replay."""
global _DISPATCH_FN
_DISPATCH_FN = fn
def do_macros_replay(action, voice):
name = _strip_trig_lower(voice, (
'выполни макрос', 'воспроизведи макрос',
'запусти макрос', 'сыграй макрос', 'проиграй макрос',
)).strip()
if not name:
_speak("Какой макрос запустить?")
return
if _DISPATCH_FN is None:
_speak("Replay не подключен.")
return
try:
count = macros_store.replay(name, _DISPATCH_FN)
except KeyError as exc:
_speak(str(exc))
return
_speak(f"Запускаю {name}, шагов: {count}.")
def do_macros_list(action, voice):
names = macros_store.list_names()
if not names:
_speak("Макросов нет.")
return
sample = names[:5]
line = f"Макросов: {len(names)}. Первые: " + ", ".join(sample) + "."
_speak(line)
def do_macros_delete(action, voice):
name = _strip_trig_lower(voice, (
'удали макрос', 'забудь о макросе',
)).strip()
if not name:
_speak("Какой макрос удалить?")
return
if macros_store.delete(name):
_speak(f"Удалил макрос {name}.")
else:
_speak(f"Макрос {name} не найден.")
# ── scheduler ───────────────────────────────────────────────────────────────
def _parse_duration_phrase(voice: str) -> tuple[int, str] | None:
"""From 'через 5 минут' / 'через 2 часа', return (count, unit_word)."""
low = (voice or '').lower()
m = re.search(r'(\d+)\s+(\S+)', low)
if not m:
return None
return (int(m.group(1)), m.group(2))
def do_scheduler_add_reminder(action, voice):
body = _strip_trig_lower(voice, (
'напомни мне через', 'напомни через',
'поставь напоминалку через', 'поставь напоминание через',
'разбуди через',
))
body = body.strip(' ,.:')
parsed = _parse_duration_phrase(body)
if not parsed:
_speak("Не понял через сколько.")
return
n, unit = parsed
if unit.startswith('минут'):
spec = f"in {n} minutes"
label = f"{n} минут"
elif unit.startswith('час'):
spec = f"in {n} hours"
label = f"{n} час" if n == 1 else (f"{n} часа" if n < 5 else f"{n} часов")
elif unit.startswith('секунд'):
spec = f"in {n} seconds"
label = f"{n} секунд"
else:
_speak("Не понял единицу. Минуты или часы.")
return
# Body after the number+unit is the reminder text.
text = re.sub(r'^\d+\s+\S+\s*', '', body).strip() or "Напоминание."
try:
scheduler_store.add({
'name': 'Reminder',
'schedule': spec,
'action': {'type': 'speak', 'text': text},
})
except ValueError as exc:
_speak(f"Не получилось: {exc}.")
return
_speak(f"Напомню через {label}.")
def do_scheduler_add_recurring(action, voice):
body = _strip_trig_lower(voice, (
'напоминай каждые', 'напоминай каждый',
'каждые', 'каждый час',
))
body = body.strip(' ,.:')
parsed = _parse_duration_phrase(body)
if not parsed:
# try implicit 1
m = re.match(r'^(\S+)', body)
if m and m.group(1) in ('час', 'минуту', 'минута'):
n = 1
unit = m.group(1)
else:
_speak("Не понял через сколько повторять.")
return
else:
n, unit = parsed
if unit.startswith('минут'):
spec = f"every {n} minutes"
sample = f"{n} минут"
elif unit.startswith('час'):
spec = f"every {n} hours"
sample = f"{n} час" if n == 1 else (f"{n} часа" if n < 5 else f"{n} часов")
else:
_speak("Не понял единицу.")
return
text = re.sub(r'^\d+\s+\S+\s*', '', body).strip() or "Напоминание."
text = re.sub(r'^напоминай\s+', '', text).strip() or "Напоминание."
try:
scheduler_store.add({
'name': 'Recurring',
'schedule': spec,
'action': {'type': 'speak', 'text': text},
})
except ValueError as exc:
_speak(f"Не получилось: {exc}.")
return
_speak(f"Буду напоминать каждые {sample}.")
def do_scheduler_list(action, voice):
tasks = scheduler_store.list_all()
if not tasks:
_speak("Расписание пустое.")
return
line = f"Запланировано {len(tasks)}."
_speak(line)
def do_scheduler_clear(action, voice):
n = scheduler_store.clear()
if n == 0:
_speak("В расписании и так пусто.")
else:
_speak(f"Удалил {n} задач.")
def do_scheduler_cancel_by_text(action, voice):
query = _strip_trig_lower(voice, (
'отмени напоминание про', 'отмени напоминание о',
'удали задачу про', 'удали задачу о',
'отмени про', 'забудь напоминание про',
)).strip()
if not query:
_speak("Что именно отменить?")
return
n = scheduler_store.remove_by_text(query)
if n == 0:
_speak(f"Не нашёл напоминаний про {query}.")
elif n == 1:
_speak("Отменил напоминание.")
else:
_speak(f"Отменил {n} напоминаний про {query}.")
# ── interesting fact (LLM-dependent) ───────────────────────────────────────
def do_interesting_fact(action, voice):
"""Asks the active LLM for a fun fact. Backend chosen by llm_backend."""
client = llm_backend.current_client()
if client is None:
_speak("LLM не настроен.")
return
model = llm_backend.current_model()
topic = ''
low = (voice or '').lower()
for trig in ('расскажи интересный факт про', 'расскажи интересный факт о',
'интересный факт про', 'интересный факт о',
'расскажи интересный факт', 'интересный факт',
'расскажи факт', 'удиви меня'):
idx = low.find(trig)
if idx >= 0:
topic = (voice or '')[idx + len(trig):].strip(' ,.:').strip()
break
if topic:
prompt = f"Расскажи один интересный факт про {topic}. На русском, 1-2 предложения. Без вступлений."
else:
prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'."
try:
resp = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'},
{'role': 'user', 'content': prompt},
],
max_tokens=200, temperature=0.8, timeout=30,
)
reply = resp.choices[0].message.content.strip()
if reply:
_speak(reply)
else:
_speak("Сегодня нет интересных фактов.")
except Exception as exc:
print(f"[ext] fact LLM failed: {exc}")
_speak("Не получилось получить факт.")
# ── LLM hot-swap ────────────────────────────────────────────────────────────
def do_llm_switch(action, voice):
"""Hot-swap LLM backend. Action.target preferred, else parse from voice."""
target = action.get('target') or ''
if not target:
# Try to extract from voice
low = (voice or '').lower()
if 'локал' in low or 'оллам' in low or 'ollama' in low:
target = 'ollama'
elif 'облак' in low or 'грок' in low or 'клауд' in low or 'groq' in low:
target = 'groq'
parsed = llm_backend.parse_backend(target)
if not parsed:
_speak("Не понял какой движок.")
return
try:
actual = llm_backend.swap_to(parsed)
except Exception as exc:
print(f"[llm] swap failed: {exc}")
human = "локальный" if parsed == 'ollama' else "облачный"
_speak(f"Не получилось переключиться на {human}.")
return
if actual == 'groq':
_speak("Переключился на облачный мозг.")
else:
_speak("Переключился на локальный мозг.")
def do_llm_status(action, voice):
backend = llm_backend.current_backend()
if backend == 'groq':
_speak("Сейчас работаю на облаке.")
elif backend == 'ollama':
_speak("Сейчас работаю локально.")
else:
_speak("LLM не настроен.")
# ── Now Playing (Windows SMTC) ──────────────────────────────────────────────
_NOW_PLAYING_PS = """
[Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime] | Out-Null;
$mgr = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync();
while ($mgr.Status -eq 0) { Start-Sleep -Milliseconds 50 };
$session = $mgr.GetResults().GetCurrentSession();
if (-not $session) { Write-Output 'NONE'; exit };
$propsTask = $session.TryGetMediaPropertiesAsync();
while ($propsTask.Status -eq 0) { Start-Sleep -Milliseconds 50 };
$props = $propsTask.GetResults();
Write-Output ("{0}|{1}" -f $props.Artist, $props.Title)
""".strip().replace('\n', '; ')
def do_now_playing(action, voice):
try:
res = subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', _NOW_PLAYING_PS],
capture_output=True, text=True, encoding='utf-8', timeout=8,
)
except Exception as exc:
print(f"[ext] now_playing: {exc}")
_speak("Не получилось получить данные.")
return
out = (res.stdout or '').strip()
if out == 'NONE' or not out:
_speak("Сейчас ничего не играет.")
return
if '|' not in out:
_speak("Не понял ответ системы.")
return
artist, _, title = out.partition('|')
artist, title = artist.strip(), title.strip()
if not title:
_speak("Трек без названия.")
return
if artist:
_speak(f"Сейчас играет: {artist}{title}.")
else:
_speak(f"Сейчас играет: {title}.")
# ── World Clock ─────────────────────────────────────────────────────────────
_TZ_MAP = {
'москва': 'Europe/Moscow',
'петербург': 'Europe/Moscow', 'спб': 'Europe/Moscow',
'новосибирск': 'Asia/Novosibirsk',
'екатеринбург': 'Asia/Yekaterinburg',
'владивосток': 'Asia/Vladivostok',
'омск': 'Asia/Omsk',
'красноярск': 'Asia/Krasnoyarsk',
'лондон': 'Europe/London',
'нью-йорк': 'America/New_York', 'нью йорк': 'America/New_York',
'токио': 'Asia/Tokyo',
'пекин': 'Asia/Shanghai',
'сидней': 'Australia/Sydney',
'дубай': 'Asia/Dubai',
'париж': 'Europe/Paris',
'берлин': 'Europe/Berlin',
'киев': 'Europe/Kyiv',
'минск': 'Europe/Minsk',
'алматы': 'Asia/Almaty',
'ташкент': 'Asia/Tashkent',
'сеул': 'Asia/Seoul',
}
def do_world_clock(action, voice):
import urllib.request
low = (voice or '').lower()
city = low
for trig in ('сколько времени в', 'который час в', 'сколько часов в', 'время в'):
idx = city.find(trig)
if idx >= 0:
city = city[idx + len(trig):].strip(' ,.:')
break
if not city:
_speak("В каком городе?")
return
tz = None
for stem, zone in _TZ_MAP.items():
if stem in city:
tz = zone
break
if not tz:
_speak("Не знаю такого города.")
return
try:
with urllib.request.urlopen(f"https://worldtimeapi.org/api/timezone/{tz}",
timeout=8) as r:
import json as _json
data = _json.loads(r.read().decode())
except Exception as exc:
print(f"[ext] world_clock: {exc}")
_speak("Не получилось узнать время.")
return
dt = data.get('datetime', '')
m = re.search(r'T(\d{2}):(\d{2})', dt)
if not m:
_speak("Не понял ответ сервиса.")
return
_speak(f"В {city} сейчас {m.group(1)}:{m.group(2)}.")
# ── Daily quote ─────────────────────────────────────────────────────────────
def do_daily_quote(action, voice):
import urllib.request
import json as _json
try:
with urllib.request.urlopen("https://zenquotes.io/api/random", timeout=8) as r:
data = _json.loads(r.read().decode())
except Exception as exc:
print(f"[ext] daily_quote: {exc}")
_speak("Не получилось получить цитату.")
return
if not data or not isinstance(data, list):
_speak("Пустой ответ.")
return
q = data[0]
quote_en = q.get('q', '')
author = q.get('a', 'Аноним')
if not quote_en:
_speak("Пустая цитата.")
return
client = llm_backend.current_client()
text = quote_en
if client:
try:
resp = client.chat.completions.create(
model=llm_backend.current_model(),
messages=[
{'role': 'system', 'content': 'Переведи английскую цитату на русский. Только перевод.'},
{'role': 'user', 'content': quote_en},
],
max_tokens=200, temperature=0.2, timeout=20,
)
translated = resp.choices[0].message.content.strip()
if translated:
text = translated
except Exception as exc:
print(f"[ext] daily_quote translate: {exc}")
_speak(f"{text}{author}.")
# ── codebase Q&A (proxies to dev_handlers) ──────────────────────────────────
def do_codebase_set(action, voice):
dev_handlers.do_codebase_set(action, voice)
def do_codebase_where(action, voice):
dev_handlers.do_codebase_where(action, voice)
def do_codebase_ask(action, voice):
dev_handlers.do_codebase_ask(action, voice)
# ── GitHub PR (proxies to dev_handlers) ─────────────────────────────────────
def do_github_set_repo(action, voice):
dev_handlers.do_github_set_repo(action, voice)
def do_github_list_prs(action, voice):
dev_handlers.do_github_list_prs(action, voice)
def do_github_summarize_pr(action, voice):
dev_handlers.do_github_summarize_pr(action, voice)
# ── Wave 1 proxies (to wave1_handlers) ──────────────────────────────────────
def do_magic_8ball(a, v): wave1_handlers.do_magic_8ball(a, v)
def do_gh_list_issues(a, v): wave1_handlers.do_github_list_issues(a, v)
def do_gh_my_issues(a, v): wave1_handlers.do_github_my_issues(a, v)
def do_weather_tomorrow(a, v): wave1_handlers.do_weather_tomorrow(a, v)
def do_weather_week(a, v): wave1_handlers.do_weather_week(a, v)
def do_rss_add(a, v): wave1_handlers.do_rss_add(a, v)
def do_rss_read(a, v): wave1_handlers.do_rss_read(a, v)
def do_rss_list(a, v): wave1_handlers.do_rss_list(a, v)
def do_ics_create(a, v): wave1_handlers.do_ics_create(a, v)
def do_backup_export(a, v): wave1_handlers.do_backup_export(a, v)
def do_clip_save(a, v): wave1_handlers.do_clip_save(a, v)
def do_clip_list(a, v): wave1_handlers.do_clip_list(a, v)
def do_clip_restore(a, v): wave1_handlers.do_clip_restore(a, v)
def do_notif_missed(a, v): wave1_handlers.do_notif_missed(a, v)
def do_notif_clear(a, v): wave1_handlers.do_notif_clear(a, v)
def do_vault_save(a, v): wave1_handlers.do_vault_save(a, v)
def do_vault_get(a, v): wave1_handlers.do_vault_get(a, v)
def do_vault_list(a, v): wave1_handlers.do_vault_list(a, v)
def do_habit_checkin(a, v): wave1_handlers.do_habit_checkin(a, v)
def do_habit_streak(a, v): wave1_handlers.do_habit_streak(a, v)
# ── Personality proxies (to personality_handlers) ───────────────────────────
def do_personality_greet(a, v): personality_handlers.do_personality_greet(a, v)
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)
# ── Wave 6-8 proxies (to wave68_handlers) ──────────────────────────────────
def do_memory_count(a, v): wave68_handlers.do_memory_count(a, v)
def do_memory_list(a, v): wave68_handlers.do_memory_list(a, v)
def do_memory_wipe(a, v): wave68_handlers.do_memory_wipe(a, v)
def do_cooking(a, v): wave68_handlers.do_cooking(a, v)
def do_leftoff(a, v): wave68_handlers.do_leftoff(a, v)
def do_trivia(a, v): wave68_handlers.do_trivia(a, v)
def do_good_night(a, v): wave68_handlers.do_good_night(a, v)
def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v)
def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v)
# ── Expenses proxies (to expenses_handlers) ────────────────────────────────
def do_expenses_log(a, v): expenses_handlers.do_expenses_log(a, v)
def do_expenses_today(a, v): expenses_handlers.do_expenses_today(a, v)
def do_expenses_week(a, v): expenses_handlers.do_expenses_week(a, v)
def do_expenses_breakdown(a, v): expenses_handlers.do_expenses_breakdown(a, v)