J.A.R.V.I.S-py/extensions.py
Bossiara13 6a328077bc feat: vision + macros + scheduler in Python (129 → 142 commands)
Three big rust packs ported. Python parity now covers the FULL imba lineup
except codebase_qa and github_pr.

vision_handler.py (new, ~125 lines)
  - Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq
    vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
  - do_vision_describe   "что на экране" / "опиши экран"
  - do_vision_read_error "прочитай ошибку"
  - Requires GROQ_TOKEN; speaks "Не удалось" on missing.

macros_store.py (new, ~170 lines)
  - JSON store at <here>/macros.json (atomic write-through).
  - start(name) / save() / cancel() / replay(name, dispatch_fn)
  - is_recording / recording_name / list_names / get / delete
  - record_step(phrase) called by execute_cmd hook (see main.py)
  - is_macro_control filter blocks "запиши макрос ..." from recording itself
    (no infinite recursion).
  - replay spawns daemon thread, fires each step with 800ms delay.

scheduler_store.py (new, ~220 lines)
  - JSON store at <here>/schedule.json.
  - parse_schedule(spec) handles "daily HH:MM", "at HH:MM",
    "every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English.
  - Background daemon thread ticks every 30s, fires Speak actions through the
    same speak callback used by main.py.
  - Once-tasks auto-delete after firing; daily/interval cycle forever.
  - add/remove/clear/remove_by_text/list_all.

extensions.py (+13 handlers)
  do_vision_describe / do_vision_read_error → thin proxies to vision_handler
  do_macros_start / save / cancel / replay / list / delete
  do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text

main.py
  - extensions.init_background_services() at startup → scheduler thread boots.
  - Wraps execute_cmd so every successful dispatch feeds macros_store.record_step.
  - set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the
    normal command pipeline (recognize_cmd → execute_cmd).

commands.yaml (+13 entries, 129 → 142)
  vision_describe, vision_read_error, macros_start, macros_save, macros_cancel,
  macros_replay, macros_list, macros_delete, scheduler_reminder,
  scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text.

Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries.

Python parity status vs rust (72 packs):
  ✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch
    (via env), llm_context (basic), media_keys (existing), screenshot, net_info,
    unit_convert, generators, disk, date_math, sleep_timer (via shutdown),
    interesting_fact, mailto, self_check, ssl_check, intro/echo
  ✗ codebase_qa, github_pr (require gh CLI + file walk — TODO)
  ✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics
    (could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00

877 lines
31 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
_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)
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 configured Groq LLM for a fun fact. Requires GROQ_TOKEN."""
import config as cfg
if not getattr(cfg, 'GROQ_TOKEN', None):
_speak("LLM не настроен.")
return
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:
from openai import OpenAI
except ImportError:
_speak("OpenAI клиент не установлен.")
return
client = OpenAI(
api_key=cfg.GROQ_TOKEN,
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
)
try:
resp = client.chat.completions.create(
model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'),
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("Не получилось получить факт.")