feat(#5): авто-пауза напоминаний во время ВКС (Zoom/Teams/Discord/Webex)

This commit is contained in:
AnRil
2026-05-22 13:48:42 +07:00
parent a6ae931461
commit 81481f2131
6 changed files with 131 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
/**
* Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов.
*
* Идея: если запущен Zoom/Teams/Discord/Meet/Webex — пользователь скорее
* всего на встрече или собирается зайти. Останавливаем напоминания, чтобы
* не прерывать. После «снятия» процессов возобновляем.
*
* Проверка раз в 30 сек через `tasklist` (CSV-режим, без окна). Не используем
* audio capture API / mic state — это потребовало бы Windows-specific
* native-модуля; задача fit-for-purpose, не security-critical.
*
* False positive vs false negative tradeoff: лучше один лишний скип
* напоминания, чем один pop-up посреди митинга. Поэтому даже если
* процесс запущен, но митинга нет (Teams в фоне) — пауза. Пользователь
* это переживёт, обратное — не очень.
*/
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { log } from './logger'
const execAsync = promisify(exec)
/**
* Имена процессов (Windows .exe). Регистр игнорируется при сравнении.
* Покрываем основные VOIP-приложения для индустрии WFH.
*/
const MEETING_PROCESSES = new Set([
'zoom.exe',
'teams.exe',
'ms-teams.exe', // новые Teams 2.0
'discord.exe',
'webex.exe',
'webexmta.exe',
'meet.exe', // Google Meet desktop (редкость)
'slack.exe', // huddle
'skype.exe',
'gotomeeting.exe',
'whereby.exe'
])
let cachedActive = false
let lastCheckAt = 0
const CACHE_MS = 30_000
/**
* Проверяет, запущен ли хотя бы один из meeting-процессов. Кеширует
* результат на CACHE_MS, чтобы tasklist не дёргался каждую секунду из
* scheduler-tick'а.
*/
export async function isMeetingActive(): Promise<boolean> {
if (process.platform !== 'win32') return false
const now = Date.now()
if (now - lastCheckAt < CACHE_MS) return cachedActive
lastCheckAt = now
try {
// CSV без заголовков (/NH), скрытое окно.
const { stdout } = await execAsync('tasklist /FO CSV /NH', {
windowsHide: true,
maxBuffer: 4 * 1024 * 1024 // tasklist бывает большой
})
const lower = stdout.toLowerCase()
for (const proc of MEETING_PROCESSES) {
// Простой substring-match достаточен: формат CSV-row
// "zoom.exe","12345","Console","1","85,432 K".
if (lower.includes(`"${proc}",`)) {
if (!cachedActive) {
log.info(`[meeting] detected ${proc} — pausing reminders`)
}
cachedActive = true
return true
}
}
if (cachedActive) {
log.info('[meeting] no meeting processes — resuming reminders')
}
cachedActive = false
return false
} catch (e) {
// tasklist может фейлиться на нестандартных образах Windows. Лучше
// продолжить работу как обычно (не паузить), чем глушить напоминания
// навсегда.
log.warn('[meeting] tasklist failed, assuming no meeting', e)
cachedActive = false
return false
}
}
/** Синхронный «последнее известное значение» без запроса. Используется
* в scheduler-tick'е — он не async. Background refresh идёт отдельно. */
export function isMeetingActiveSync(): boolean {
return cachedActive
}
/** Background refresh, дёргаем из scheduler'а. Не await'им. */
export function refreshMeetingState(): void {
void isMeetingActive()
}

View File

@@ -5,6 +5,7 @@ import { isQuietAt } from '@shared/types'
import { getExercises, getHistory, getSettings, updateExercise } from './store'
import { fireReminder } from './notifications'
import { broadcastState } from './state-actions'
import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect'
/**
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
@@ -45,6 +46,15 @@ function checkDueExercises(): void {
// CHECK_MS pass after the window ends will pick them up.
if (isQuietAt(settings.quietHours, new Date())) return
// Авто-пауза на встречах. Sync-чтение кеша (последнее значение); refresh
// запускаем в фоне чтобы кеш «зрел» к следующему tick'у. На холодном
// старте кеш false — первое срабатывание может прийти в момент митинга,
// но 30 сек спустя система догонит и больше не будет fire'ить.
if (settings.meetingAutoPause) {
refreshMeetingState()
if (isMeetingActiveSync()) return
}
const now = Date.now()
const exercises = getExercises()
// history запрашивается только если хотя бы у одного упражнения есть

View File

@@ -291,6 +291,11 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
if (v === undefined) return null
out.voicePromptsEnabled = v
}
if ('meetingAutoPause' in raw) {
const v = bool(raw.meetingAutoPause)
if (v === undefined) return null
out.meetingAutoPause = v
}
if ('notificationMode' in raw) {
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
if (v === undefined) return null