commit b9930dbe591bb30a920a584b053a4ff384a6b408 Author: Jester Date: Tue Feb 17 09:17:46 2026 +0300 init diff --git a/Icon.ico b/Icon.ico new file mode 100644 index 0000000..563e94c Binary files /dev/null and b/Icon.ico differ diff --git a/PLStatus.exe b/PLStatus.exe new file mode 100644 index 0000000..d687a68 Binary files /dev/null and b/PLStatus.exe differ diff --git a/PLStatus.spec b/PLStatus.spec new file mode 100644 index 0000000..5d6877b --- /dev/null +++ b/PLStatus.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['status_automation.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='PLStatus', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['icon.ico'], +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c88a9f9 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# Автоматическая смена статуса в мессенджере + +Скрипт запускается вместе с ОС, проверяет IP по маске из конфига, ждёт запуска процесса мессенджера, через заданное время выполняет смену статуса через имитацию кликов и ввода с клавиатуры. + +Поддерживаются **Windows** и **Linux**. + +## Установка + +1. Клонируйте или скопируйте папку проекта. +2. Создайте виртуальное окружение (рекомендуется): + ```bash + python -m venv .venv + # Windows: + .venv\Scripts\activate + # Linux: + source .venv/bin/activate + ``` +3. Установите зависимости: + ```bash + pip install -r requirements.txt + ``` +4. **Linux:** для управления окнами установите `wmctrl`: + ```bash + sudo apt install wmctrl # Debian/Ubuntu + sudo dnf install wmctrl # Fedora + ``` + +## Настройка + +1. Скопируйте пример конфига (или отредактируйте существующий): + ```bash + copy config.json.example config.json # Windows + cp config.json.example config.json # Linux + ``` +2. Конфиг **config.json** — объект JSON: + - Ключи — **маски IP** (первые три октета, например `"192.168.1"`). Скрипт выбирает блок по текущему IP. + - Значение для каждой маски — объект: **message** (текст статуса), **pause** (задержка в секундах после запуска процесса). Имя процесса и заголовок окна задаются в **"_app"** по ОС. + - Ключ **"_app"** — общие настройки для всех масок. Обязательно укажите в зависимости от ОС: + - **proc_name_windows** и **proc_name_linux** — имя процесса мессенджера (например `postlink-client.exe` / `postlink-client`). + - **window_title_windows** и **window_title_linux** — заголовок окна для фокуса и поиска по PID. Скрипт сам выбирает значение по текущей ОС (Windows/Linux). + - **Способ клика** — для каждой цели (иконка, поле статуса, кнопка «Сохранить») задаётся **один** из вариантов: + - **Поиск по картинке** (рекомендуется для разных разрешений): **icon_image**, **status_image**, **save_button_image** — пути к файлам изображений (относительно папки скрипта, например `images/icon.png`). Скрипт ищет картинку на экране и кликает в её центр. Требуется **opencv-python**. + - **Координаты в пикселях**: **icon_position**, **status_position**, **save_button_position** — от левого верхнего угла окна. + - **Координаты в долях**: **icon_position_rel**, **status_position_rel**, **save_button_position_rel** — доли 0.0–1.0 от размера окна. + - **image_confidence** (опционально) — порог совпадения при поиске по картинке (0.0–1.0, по умолчанию 0.8). Чем выше, тем строже совпадение. + +### Поиск по картинкам (разные разрешения) + +Сделайте скриншоты элементов интерфейса (иконка/аватар для открытия меню, поле статуса, кнопка «Сохранить»), сохраните в папку `images/` и укажите в `_app`: + +- **icon_image**: `"images/icon.png"` +- **status_image**: `"images/status.png"` +- **save_button_image**: `"images/save.png"` + +Картинки должны быть небольшими фрагментами (например 40×40–80×80 пикселей), без лишнего фона. Скрипт ищет их на экране и кликает в центр — один конфиг работает на любом разрешении и масштабе. При необходимости понизьте **image_confidence** (например 0.7), если совпадение не находится. + +### Координаты в долях (альтернатива картинкам) + +Чтобы один конфиг работал на разных разрешениях без картинок, задайте координаты **относительно окна** в долях 0–1 в полях `*_position_rel`: + +- `icon_position_rel`: [доля по горизонтали, доля по вертикали] — клик по иконке/аватару. +- `status_position_rel`: [доля X, доля Y] — поле статуса. +- `save_button_position_rel`: [доля X, доля Y] — кнопка «Сохранить». + +Пример: при размере окна 400×300 значение `[0.5, 0.5]` — центр окна. На другом разрешении окно может быть 600×450 — центр останется по центру. + +### Подбор координат + +Запустите вспомогательный скрипт, активируйте окно мессенджера и наведите мышь на нужные элементы — в консоли выводятся координаты относительно окна: + +```bash +python get_coords.py +``` + +Остановка: **Ctrl+C**. Подставьте полученные значения в **config.json** в секцию `_app`: `icon_position`, `status_position`, `save_button_position`. + +## Запуск + +Обычный запуск (для проверки): + +```bash +python status_automation.py +``` + +Логи выводятся в консоль. Скрипт выходит сразу, если текущий IP не совпадает ни с одной маской из конфига; иначе ждёт процесс, затем через `pause` выполняет клики и устанавливает статус. + +## Автозапуск при старте ОС + +### Windows + +1. Откройте **Планировщик заданий** (Task Scheduler). +2. Создайте задачу: + - Триггер: **При входе в систему** (или **При запуске компьютера**). + - Действие: **Запуск программы**. + - Программа: полный путь к `python.exe` (из вашего venv или системы), например: + ``` + D:\work\PLStatus\venv\Scripts\python.exe + ``` + - Аргументы: + ``` + D:\work\PLStatus\status_automation.py + ``` + - Рабочая папка: `D:\work\PLStatus`. +3. В свойствах задачи можно включить «Выполнять с наивысшими правами» только если реально нужно. + +Альтернатива — ярлык в папке автозагрузки: + +- `Win+R` → `shell:startup` → Enter. +- Создайте ярлык с целью, например: + `D:\work\PLStatus\venv\Scripts\pythonw.exe D:\work\PLStatus\status_automation.py` + (использование `pythonw.exe` скрывает консоль.) + +### Linux + +Через **systemd** (пользовательский сервис): + +1. Создайте файл `~/.config/systemd/user/plstatus.service`: + +```ini +[Unit] +Description=PLStatus messenger status automation +After=network-online.target +WantedBy=default.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /home/USER/PLStatus/status_automation.py +WorkingDirectory=/home/USER/PLStatus +Restart=on-failure +Environment=DISPLAY=:0 + +[Install] +WantedBy=default.target +``` + +Замените `USER` и путь `/home/USER/PLStatus` на свои. + +2. Включите и запустите сервис: + +```bash +systemctl --user daemon-reload +systemctl --user enable plstatus.service +systemctl --user start plstatus.service +``` + +Если скрипт должен выполняться после входа в графическую сессию, убедитесь, что `DISPLAY=:0` соответствует вашей сессии (или задайте корректный `DISPLAY` в `Environment=`). + +## Поведение + +1. При запуске читается `config.yaml`. +2. Вычисляются локальные IPv4 и проверяется совпадение с **ip_mask** (первые три октета). Если совпадений нет — скрипт завершается без действий. +3. Ожидается появление процесса с именем **process_name** (опрос раз в несколько секунд). +4. После появления процесса выполняется пауза **startup_delay** секунд (по умолчанию 60). +5. Окно мессенджера активируется по **window_title**. +6. Выполняются клики: + - по иконке/аватару (**icon_position**), + - по полю статуса (**status_position**), + - затем Ctrl+A и Delete (очистка текущего статуса), + - вставка **status_message** (через буфер обмена, поддерживается кириллица), + - клик по кнопке сохранения (**save_button_position**). + +Координаты в секции `_app` задаются **относительно окна** приложения, поэтому не зависят от положения окна на экране. + +## Упаковка в один исполняемый файл + +Чтобы не устанавливать Python и библиотеки на других машинах, можно собрать один исполняемый файл со всеми зависимостями. + +### Варианты + +| Инструмент | Плюсы | Минусы | +|-------------|---------------------------------|---------------------------| +| **PyInstaller** | Просто, один .exe, одна команда | Размер 50–150 МБ | +| **cx_Freeze** | Гибкая настройка, кроссплатформа | Часто папка с файлами, не один exe | +| **Nuitka** | Нативная сборка, быстрый старт | Долгая сборка, нужен C-компилятор | + +Рекомендуется **PyInstaller**: один файл, работает на Windows и Linux. + +### Сборка (PyInstaller) + +Скрипты **build.bat** (Windows) и **build.sh** (Linux) собирают exe **без окна консоли** (`--noconsole`) и подставляют иконку, если в корне проекта есть **icon.ico**. + +1. В папке проекта с активированным venv: + ```bash + pip install pyinstaller + ``` +2. **Иконка (необязательно):** положите в корень проекта файл **icon.ico** (формат ICO, лучше несколько размеров: 16×16, 32×32, 48×48). Если файла нет, при первой сборке создаётся простая заглушка (или запустите `python make_icon.py`). + **Как взять иконку из другого приложения (Windows):** + - **Resource Hacker** (бесплатно): скачайте с [angusj.com/resourcehacker](http://www.angusj.com/resourcehacker/). Откройте в нём нужный .exe → в дереве слева откройте **Icon Group** → выберите нужную иконку (часто первая с большим размером) → правый клик → **Save icon as .ico** → сохраните как **icon.ico** в папку проекта. + - **IconsExtract (NirSoft):** [nirsoft.net/utils/iconsext.html](https://www.nirsoft.net/utils/iconsext.html) — показывает все иконки из папки с exe, можно сохранить выбранную в .ico. + - Рядом с программой иногда уже лежит **.ico** — скопируйте его в проект и при необходимости переименуйте в **icon.ico**. +3. **Windows**: запустите `build.bat` или вручную: + ```cmd + pyinstaller --onefile --noconsole --name PLStatus --clean --icon icon.ico status_automation.py + ``` +4. **Linux**: `./build.sh` или: + ```bash + pyinstaller --onefile --noconsole --name PLStatus --clean --icon icon.ico status_automation.py + ``` + +Результат: в папке **dist/** появится **PLStatus.exe** (Windows) или **PLStatus** (Linux) — при запуске окно консоли не появляется. Размер порядка 80–150 МБ. + +### Запуск собранного exe + +- Рядом с исполняемым файлом должны лежать: + - **config.json** — конфигурация (скопируйте из проекта или создайте по образцу). + - Папка **images/** с файлами icon.png, status.png, save.png (если используется поиск по картинкам). +- Лог пишется в **status_automation.log** в той же папке. +- На целевой машине **Python и библиотеки устанавливать не нужно**; на Linux для управления окнами по‑прежнему нужен **wmctrl** (`sudo apt install wmctrl`). diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..6a8dad9 --- /dev/null +++ b/build.bat @@ -0,0 +1,23 @@ +@echo off +REM Сборка одного exe для Windows (PyInstaller). Запуск из папки проекта. +REM Требуется: pip install pyinstaller + +if not exist ".venv\Scripts\activate.bat" ( + echo Создайте venv и установите зависимости: python -m venv .venv && .venv\Scripts\activate && pip install -r requirements.txt + exit /b 1 +) +call .venv\Scripts\activate +pip install pyinstaller -q + +REM Иконка: если нет icon.ico — создаём заглушку (make_icon.py) +if not exist "icon.ico" python make_icon.py + +set ICON_OPT= +if exist "icon.ico" set ICON_OPT=--icon icon.ico + +pyinstaller --onefile --noconsole --name PLStatus --clean %ICON_OPT% status_automation.py + +echo. +echo Готово: dist\PLStatus.exe (без консоли, с иконкой если был icon.ico) +echo Рядом с exe положите config.json и папку images\ (см. README) +exit /b 0 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..bab40df --- /dev/null +++ b/build.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Сборка одного исполняемого файла для Linux (PyInstaller) +# Запуск: chmod +x build.sh && ./build.sh + +set -e +cd "$(dirname "$0")" + +if [ ! -f ".venv/bin/activate" ]; then + echo "Создайте venv и установите зависимости:" + echo " python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt" + exit 1 +fi +. .venv/bin/activate +pip install pyinstaller -q + +# Иконка: если нет icon.ico — создаём заглушку +[ ! -f "icon.ico" ] && python make_icon.py || true + +ICON_OPT="" +[ -f "icon.ico" ] && ICON_OPT="--icon icon.ico" + +pyinstaller --onefile --noconsole --name PLStatus --clean $ICON_OPT status_automation.py + +echo "" +echo "Готово: dist/PLStatus (без консоли, с иконкой если был icon.ico)" +echo "Рядом с бинарником положите config.json и папку images/ (см. README)" diff --git a/config.json b/config.json new file mode 100644 index 0000000..720d903 --- /dev/null +++ b/config.json @@ -0,0 +1,24 @@ +{ + "_app": { + "proc_name_windows": "postlink-client.exe", + "proc_name_linux": "postlink-client", + "window_title_windows": "postlink-client.exe", + "window_title_linux": "PostLink", + "icon_image": "images/icon.png", + "status_image": "images/status.png", + "save_button_image": "images/save.png", + "image_confidence": 0.8 + }, + "172.20.36": { + "message": "Добро.Душный|Слабоумие и отвага:> На Ручьях: 546(р.т. None)", + "pause": 60 + }, + "172.26.120": { + "message": "Добро.Душный|Слабоумие и отвага:> На К37: 436(р.т. 5183)", + "pause": 60 + }, + "default": { + "message": "Добро.Душный|Слабоумие и отвага", + "pause": 5 + } +} diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..627c609 --- /dev/null +++ b/config.json.example @@ -0,0 +1,24 @@ +{ + "_app": { + "proc_name_windows": "postlink-client.exe", + "proc_name_linux": "postlink-client", + "window_title_windows": "postlink-client.exe", + "window_title_linux": "PostLink", + "icon_image": "images/icon.png", + "status_image": "images/status.png", + "save_button_image": "images/save.png", + "image_confidence": 0.8 + }, + "192.168.1": { + "message": "Home", + "pause": 60 + }, + "192.20.10": { + "message": "Work", + "pause": 40 + }, + "default": { + "message": "Default status", + "pause": 60 + } +} diff --git a/get_coords.py b/get_coords.py new file mode 100644 index 0000000..d06832b --- /dev/null +++ b/get_coords.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Вспомогательный скрипт для подбора координат для config.json. +Запустите его, затем наведите мышь на нужный элемент в окне мессенджера +и нажмите клавишу — в консоль выведется позиция мыши. +Для координат ОТНОСИТЕЛЬНО окна: сначала активируйте окно мессенджера, +запустите скрипт и кликайте по элементам — скрипт покажет и экранные координаты, +и координаты относительно активного окна (если доступно). +""" + +import sys +import time +import platform +from pathlib import Path + +def get_cursor_pos(): + import pyautogui + return pyautogui.position() + +def get_active_window_rect(): + """Позиция активного окна (Windows: через pygetwindow, Linux: wmctrl).""" + if platform.system() == "Windows": + try: + import pygetwindow as gw + w = gw.getActiveWindow() + if w: + return (w.left, w.top, w.width, w.height) + except Exception: + pass + return None + try: + import subprocess + out = subprocess.run(["xdotool", "getactivewindow", "getwindowgeometry"], capture_output=True, text=True, timeout=2) + if out.returncode != 0: + return None + # Parse "Position: 100,200 (screen: 0)\nDimensions: 400x300" + pos = dim = None + for line in out.stdout.strip().splitlines(): + if line.startswith("Position:"): + part = line.split("(", 1)[0].replace("Position:", "").strip() + a, b = part.split(",") + pos = (int(a.strip()), int(b.strip())) + elif line.startswith("Dimensions:"): + part = line.split(":", 1)[1].strip() + w, h = part.split("x") + dim = (int(w), int(h)) + if pos and dim: + return (pos[0], pos[1], dim[0], dim[1]) + except Exception: + pass + return None + + +if __name__ == "__main__": + print("Координаты обновляются в реальном времени. Остановка: Ctrl+C") + try: + while True: + time.sleep(0.2) + x, y = get_cursor_pos() + rect = get_active_window_rect() + if rect: + rel_x = x - rect[0] + rel_y = y - rect[1] + print(f"\rЭкран: ({x}, {y}) | Относительно окна: ({rel_x}, {rel_y}) ", end="", flush=True) + else: + print(f"\rЭкран: ({x}, {y}) ", end="", flush=True) + except KeyboardInterrupt: + print("\nГотово.") diff --git a/images/README.txt b/images/README.txt new file mode 100644 index 0000000..367dc65 --- /dev/null +++ b/images/README.txt @@ -0,0 +1,15 @@ +Папка для изображений при поиске по картинкам. + +Положите сюда скриншоты элементов интерфейса PostLink (или укажите эти имена в config.json): + + icon.png — иконка/аватар, по которой кликаем чтобы открыть меню статуса + status.png — поле ввода статуса (небольшой фрагмент, например 50×30 px) + save.png — кнопка «Сохранить» + +Как сделать: + 1. Откройте окно приложения в нужном состоянии. + 2. Сделайте скриншот экрана (Win+Shift+S или Print Screen). + 3. В графическом редакторе вырежьте только нужный элемент (без лишнего фона), сохраните как PNG. + 4. Имена файлов должны совпадать с путями в config.json (например images/icon.png). + +Чем меньше и уникальнее фрагмент — тем надёжнее поиск на любом разрешении. diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..5b2ede0 Binary files /dev/null and b/images/icon.png differ diff --git a/images/save.png b/images/save.png new file mode 100644 index 0000000..f477ea9 Binary files /dev/null and b/images/save.png differ diff --git a/images/status.png b/images/status.png new file mode 100644 index 0000000..271a016 Binary files /dev/null and b/images/status.png differ diff --git a/make_icon.py b/make_icon.py new file mode 100644 index 0000000..8cfbeb9 --- /dev/null +++ b/make_icon.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Создаёт icon.ico в папке проекта (если нет своего). Подставьте свой .ico и пересоберите.""" +from pathlib import Path + +try: + from PIL import Image +except ImportError: + print("Установите Pillow: pip install Pillow") + raise + +path = Path(__file__).resolve().parent / "icon.ico" +if path.exists(): + print("icon.ico уже есть, не перезаписываю.") + exit(0) +# Простая иконка 48x48 (синий квадрат) +img = Image.new("RGBA", (48, 48), (70, 130, 180, 255)) +img.save(path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)]) +print("Создан icon.ico. Замените своим файлом при необходимости.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6033856 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +psutil>=5.9.0 +pyautogui>=0.9.54 +pyperclip>=1.8.2 +pygetwindow>=0.0.9; sys_platform == "win32" +opencv-python>=4.5.0 +Pillow>=10.0.0 +mss>=9.0.0 diff --git a/status_automation.py b/status_automation.py new file mode 100644 index 0000000..c2af324 --- /dev/null +++ b/status_automation.py @@ -0,0 +1,636 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Автоматическая смена статуса в мессенджере по маске IP. +Запускается при старте ОС, проверяет IP, ждёт процесс мессенджера, +через заданное время выполняет клики: иконка -> статус -> очистка -> ввод текста -> сохранить. +Работает на Windows и Linux. +pyinstaller --onefile --name PLStatus --clean --noconsole --icon icon.ico status_automation.py +""" + +import sys +import socket +import time +import platform +import subprocess +import logging +from pathlib import Path + +import json +import psutil +import pyautogui +import pyperclip + +# Отключаем защиту pyautogui от выхода за край экрана (мессенджер может быть где угодно) +pyautogui.FAILSAFE = True +pyautogui.PAUSE = 0.15 + +CONFIG_NAME = "config.json" +LOG_FILE = "status_automation.log" +# При запуске из собранного exe (PyInstaller и т.п.) — папка с исполняемым файлом +SCRIPT_DIR = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent + + +def _setup_logging(): + """Настраивает логирование в файл (файл перезаписывается при каждом запуске, UTF-8).""" + log_path = SCRIPT_DIR / LOG_FILE + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler = logging.FileHandler(log_path, mode="w", encoding="utf-8") + handler.setFormatter(formatter) + root = logging.getLogger() + root.setLevel(logging.INFO) + if root.handlers: + root.handlers.clear() + root.addHandler(handler) + return logging.getLogger(__name__) + + +log = _setup_logging() + + +def _app_get_os(defaults, base_key): + """Возвращает значение из _app для текущей ОС: base_key + '_windows' или '_linux'.""" + os_key = "windows" if platform.system() == "Windows" else "linux" + key_os = f"{base_key}_{os_key}" + return defaults.get(key_os) or defaults.get(base_key) + + +def load_config(): + """ + Загружает конфиг из config.json. + Формат: ключи — маски IP (первые три октета), значение — настройки { proc_name, message, pause }; + опциональные ключи: "_app" — общие настройки окна; "default" — профиль, если текущий IP не совпал ни с одной маской. + Возвращает (profile, ip_mask): профиль и ключ (маска IP или "default"), или (None, None) если конфиг невалиден. + """ + config_path = SCRIPT_DIR / CONFIG_NAME + if not config_path.exists(): + log.error("Файл конфигурации не найден: %s", config_path) + sys.exit(1) + with open(config_path, "r", encoding="utf-8") as f: + raw = json.load(f) + if not isinstance(raw, dict): + log.error("Конфиг должен быть объектом (ключи — маски IP).") + sys.exit(1) + prefixes = get_local_ip_prefixes() + defaults = raw.pop("_app", None) or {} + fallback_block = raw.pop("default", None) + for ip_mask, block in raw.items(): + if not isinstance(block, dict): + continue + if ip_mask.strip() not in prefixes: + continue + profile = _build_profile(block, defaults, ip_mask) + if profile is not None: + return profile, ip_mask + if isinstance(fallback_block, dict): + profile = _build_profile(fallback_block, defaults, "default") + if profile is not None: + log.info("Текущий IP не в конфиге — используем профиль по ключу default.") + return profile, "default" + return None, None + + +def _build_profile(block, defaults, label): + """Собирает профиль из блока и defaults. При ошибке возвращает None и логирует.""" + # Имя процесса и заголовок окна — только из _app, по текущей ОС + process_name = _app_get_os(defaults, "proc_name") + window_title = _app_get_os(defaults, "window_title") + profile = { + "window_title": window_title, + "icon_position": block.get("icon_position") or defaults.get("icon_position"), + "status_position": block.get("status_position") or defaults.get("status_position"), + "save_button_position": block.get("save_button_position") or defaults.get("save_button_position"), + "icon_position_rel": block.get("icon_position_rel") or defaults.get("icon_position_rel"), + "status_position_rel": block.get("status_position_rel") or defaults.get("status_position_rel"), + "save_button_position_rel": block.get("save_button_position_rel") or defaults.get("save_button_position_rel"), + "icon_image": block.get("icon_image") or defaults.get("icon_image"), + "status_image": block.get("status_image") or defaults.get("status_image"), + "save_button_image": block.get("save_button_image") or defaults.get("save_button_image"), + "image_confidence": block.get("image_confidence") or defaults.get("image_confidence") or 0.8, + "status_message": block.get("message", ""), + "process_name": process_name, + "startup_delay": int(block.get("pause", 60)), + } + if not profile.get("process_name"): + log.error("В _app укажите proc_name_windows и/или proc_name_linux для текущей ОС") + return None + if not profile.get("window_title"): + log.error("В _app укажите window_title_windows и/или window_title_linux для текущей ОС") + return None + # Для каждой точки клика: либо картинка (*_image), либо координаты (*_position или *_position_rel) + for key_img, key_px, key_rel in ( + ("icon_image", "icon_position", "icon_position_rel"), + ("status_image", "status_position", "status_position_rel"), + ("save_button_image", "save_button_position", "save_button_position_rel"), + ): + has_img = bool(profile.get(key_img)) + has_px = profile.get(key_px) is not None + has_rel = profile.get(key_rel) is not None + if not (has_img or has_px or has_rel): + log.error("В конфиге для %s (или в _app) укажите %s или %s или %s", label, key_img, key_px, key_rel) + return None + return profile + + +def get_local_ip_prefixes(): + """Возвращает множество префиксов IP (первые три октета) для всех IPv4-интерфейсов.""" + prefixes = set() + try: + af_inet = getattr(psutil, "AF_INET", None) or socket.AF_INET + af_link = getattr(psutil, "AF_LINK", -1) + for name, addrs in psutil.net_if_addrs().items(): + for addr in addrs: + if addr.family == af_link: + continue + if addr.family != af_inet: + continue + ip = getattr(addr, "address", None) or "" + if not ip or ip.startswith("127."): + continue + parts = ip.split(".") + if len(parts) == 4 and all(p.isdigit() for p in parts): + prefixes.add(".".join(parts[:3])) + except Exception as e: + log.warning("Ошибка при получении списка IP: %s", e) + print(prefixes) + return prefixes + + +def ip_matches_mask(ip_mask): + """Проверяет, совпадает ли маска (первые три октета) с одним из локальных IP.""" + ip_mask = (ip_mask or "").strip() + if not ip_mask: + return False + # Нормализуем маску до трёх октетов + parts = ip_mask.split(".") + if len(parts) >= 3: + prefix = ".".join(parts[:3]) + else: + prefix = ip_mask + return prefix in get_local_ip_prefixes() + + +def wait_for_process(process_name, poll_interval=2): + """Ждёт появления процесса с заданным именем. Возвращает PID или None при ошибке.""" + name_lower = process_name.lower() + # На Windows "App.exe", но процесс может быть и без .exe в имени — проверяем оба варианта + log.info("Ожидание процесса: %s", process_name) + while True: + for proc in psutil.process_iter(["name", "pid"]): + try: + pname = (proc.info.get("name") or "").lower() + if pname == name_lower or pname == name_lower.replace(".exe", ""): + return proc.info["pid"] + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + time.sleep(poll_interval) + + +def _focus_window_windows_by_pid(pid): + """Активирует главное окно процесса по PID (Windows, ctypes).""" + if platform.system() != "Windows": + return False + try: + import ctypes + from ctypes import wintypes + user32 = ctypes.windll.user32 + found_hwnd = wintypes.HWND() + + def enum_callback(hwnd, _pid): + if user32.IsWindowVisible(hwnd): + wpid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid)) + if wpid.value == _pid: + found_hwnd.value = hwnd + return False # прекратить перебор + return True + + WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + user32.EnumWindows(WNDENUMPROC(enum_callback), pid) + if found_hwnd.value: + user32.SetForegroundWindow(found_hwnd.value) + time.sleep(0.3) + return True + except Exception as e: + log.warning("Активация окна по PID не удалась: %s", e) + return False + + +def _get_window_rect_windows_by_pid(pid): + """Возвращает (left, top, width, height) главного окна процесса (Windows).""" + if platform.system() != "Windows": + return None + try: + import ctypes + from ctypes import wintypes + user32 = ctypes.windll.user32 + rect = wintypes.RECT() + found_hwnd = wintypes.HWND() + + def enum_callback(hwnd, _pid): + if user32.IsWindowVisible(hwnd): + wpid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid)) + if wpid.value == _pid: + found_hwnd.value = hwnd + return False + return True + + WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + user32.EnumWindows(WNDENUMPROC(enum_callback), pid) + if found_hwnd.value and user32.GetWindowRect(found_hwnd.value, ctypes.byref(rect)): + return (rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top) + except Exception as e: + log.warning("Получение rect по PID не удалось: %s", e) + return None + + +def focus_window_windows(title): + """Фокус окна по заголовку (Windows).""" + try: + import pygetwindow as gw + wins = gw.getWindowsWithTitle(title) + if not wins: + # Частичное совпадение + for w in gw.getAllWindows(): + if title.lower() in (w.title or "").lower(): + w.activate() + time.sleep(0.3) + return True + return False + wins[0].activate() + time.sleep(0.3) + return True + except Exception as e: + log.warning("Не удалось активировать окно (Windows): %s", e) + return False + + +def _focus_window_linux_by_pid(pid): + """Активирует окно процесса по PID (Linux, wmctrl -lp).""" + try: + out = subprocess.run( + ["wmctrl", "-lp"], + capture_output=True, + text=True, + timeout=5, + ) + if out.returncode != 0: + return False + pid_str = str(pid) + for line in out.stdout.strip().splitlines(): + parts = line.split(None, 3) + if len(parts) >= 3 and parts[2] == pid_str: + subprocess.run(["wmctrl", "-ia", parts[0]], capture_output=True, timeout=5) + time.sleep(0.3) + return True + except Exception as e: + log.warning("Активация окна по PID (Linux): %s", e) + return False + + +def _get_window_rect_linux_by_pid(pid): + """Возвращает (left, top, width, height) окна процесса (Linux, wmctrl).""" + try: + out = subprocess.run(["wmctrl", "-lp"], capture_output=True, text=True, timeout=5) + if out.returncode != 0: + return None + pid_str = str(pid) + wid = None + for line in out.stdout.strip().splitlines(): + parts = line.split(None, 3) + if len(parts) >= 3 and parts[2] == pid_str: + wid = parts[0] + break + if not wid: + return None + out2 = subprocess.run(["wmctrl", "-l", "-G"], capture_output=True, text=True, timeout=5) + if out2.returncode != 0: + return None + for line in out2.stdout.strip().splitlines(): + parts = line.split(None, 7) + if len(parts) >= 6 and parts[0] == wid: + x, y, w, h = int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5]) + return (x, y, w, h) + except Exception as e: + log.warning("Получение rect по PID (Linux): %s", e) + return None + + +def focus_window_linux(title): + """Фокус окна по заголовку (Linux, wmctrl).""" + try: + subprocess.run( + ["wmctrl", "-a", title], + capture_output=True, + timeout=5, + check=False, + ) + time.sleep(0.3) + return True + except FileNotFoundError: + log.warning("wmctrl не установлен. Установите: sudo apt install wmctrl (или аналог)") + return False + except subprocess.TimeoutExpired: + return False + except Exception as e: + log.warning("Ошибка активации окна (Linux): %s", e) + return False + + +def get_window_rect_windows(title): + """Позиция и размер окна (Windows). Возвращает (left, top, width, height) или None.""" + try: + import pygetwindow as gw + wins = gw.getWindowsWithTitle(title) + if not wins: + for w in gw.getAllWindows(): + if title.lower() in (w.title or "").lower(): + return (w.left, w.top, w.width, w.height) + return None + w = wins[0] + return (w.left, w.top, w.width, w.height) + except Exception as e: + log.warning("Ошибка получения позиции окна (Windows): %s", e) + return None + + +def get_window_rect_linux(title): + """Позиция и размер окна (Linux, wmctrl -l -G). Возвращает (left, top, width, height) или None.""" + try: + out = subprocess.run( + ["wmctrl", "-l", "-G"], + capture_output=True, + text=True, + timeout=5, + ) + if out.returncode != 0: + return None + for line in out.stdout.strip().splitlines(): + # wmctrl -l -G: id desktop pid x y w h host title + parts = line.split(None, 7) + if len(parts) < 8: + continue + win_title = parts[7] + if title.lower() in win_title.lower(): + x, y, w, h = int(parts[2]), int(parts[3]), int(parts[4]), int(parts[5]) + return (x, y, w, h) + return None + except Exception as e: + log.warning("Ошибка получения позиции окна (Linux): %s", e) + return None + + +def focus_window(title, pid=None): + """Активирует окно по заголовку; если не найдено и задан pid — по PID (Windows) или wmctrl (Linux).""" + if platform.system() == "Windows": + if focus_window_windows(title): + return True + if pid is not None and _focus_window_windows_by_pid(pid): + log.info("Окно активировано по PID процесса (заголовок не совпал).") + return True + return False + if focus_window_linux(title): + return True + if pid is not None and _focus_window_linux_by_pid(pid): + log.info("Окно активировано по PID процесса (заголовок не совпал).") + return True + return False + + +def get_window_rect(title, pid=None): + """Позиция и размер окна по заголовку; если не найдено и задан pid — по PID (Windows/Linux).""" + if platform.system() == "Windows": + rect = get_window_rect_windows(title) + if rect is None and pid is not None: + rect = _get_window_rect_windows_by_pid(pid) + return rect + rect = get_window_rect_linux(title) + if rect is None and pid is not None: + rect = _get_window_rect_linux_by_pid(pid) + return rect + + +def click_relative(rect, relative_x, relative_y): + """Клик в координатах относительно окна (rect = left, top, w, h).""" + abs_x = rect[0] + relative_x + abs_y = rect[1] + relative_y + pyautogui.click(abs_x, abs_y) + + +def _resolve_click_position(rect, pixel_pos, rel_pos): + """ + Возвращает (abs_x, abs_y) для клика. + rect = (left, top, width, height). Если заданы rel_pos (доли 0–1 от размера окна) — используем их; + иначе pixel_pos (смещение в пикселях от левого верхнего угла окна). + """ + if rect is None: + if pixel_pos is None: + return None + return (pixel_pos[0], pixel_pos[1]) + left, top, width, height = rect[0], rect[1], rect[2], rect[3] + if rel_pos is not None and len(rel_pos) >= 2: + try: + rx, ry = float(rel_pos[0]), float(rel_pos[1]) + return (left + rx * width, top + ry * height) + except (TypeError, ValueError): + pass + if pixel_pos is not None and len(pixel_pos) >= 2: + return (left + pixel_pos[0], top + pixel_pos[1]) + return None + + +def _resolve_image_path(path): + """Путь к файлу: если не абсолютный — относительно папки скрипта.""" + if not path: + return None + p = Path(path) + if not p.is_absolute(): + p = SCRIPT_DIR / p + return p if p.exists() else None + + +def _find_and_click_image_opencv(template_path, confidence=0.8): + """ + Поиск по картинке через OpenCV + mss (без pyscreeze/Pillow). + Делает скриншот через mss, ищет шаблон через cv2.matchTemplate, кликает в центр. + """ + try: + import numpy as np + import cv2 + import mss + except ImportError as e: + log.warning("Поиск по картинке недоступен (нужны opencv-python, mss): %s", e) + return False + template = cv2.imread(str(template_path)) + if template is None: + log.warning("OpenCV не смог прочитать изображение: %s", template_path) + return False + with mss.mss() as sct: + monitor = sct.monitors[0] + screenshot = sct.grab(monitor) + img = np.array(screenshot) + img_bgr = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) + result = cv2.matchTemplate(img_bgr, template, cv2.TM_CCOEFF_NORMED) + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) + if max_val < confidence: + return False + h, w = template.shape[:2] + center_x = max_loc[0] + w // 2 + center_y = max_loc[1] + h // 2 + pyautogui.click(center_x, center_y) + return True + + +def find_and_click_image(image_path, confidence=0.8, timeout=10, retries=3): + """ + Ищет изображение на экране, кликает в центр. + Сначала пробует pyautogui.locateOnScreen (pyscreeze + Pillow); + при ошибке импорта — резервно OpenCV + mss. + """ + resolved = _resolve_image_path(image_path) + if not resolved: + log.warning("Файл изображения не найден: %s", image_path) + return False + path_str = str(resolved) + use_opencv_fallback = False + for attempt in range(retries): + if not use_opencv_fallback: + try: + loc = pyautogui.locateOnScreen(path_str, confidence=confidence) + if loc is not None: + x = loc.left + loc.width // 2 + y = loc.top + loc.height // 2 + pyautogui.click(x, y) + return True + except TypeError: + try: + loc = pyautogui.locateOnScreen(path_str) + if loc is not None: + x = loc.left + loc.width // 2 + y = loc.top + loc.height // 2 + pyautogui.click(x, y) + return True + except Exception: + pass + use_opencv_fallback = True + except Exception as e: + err_text = str(e).lower() + if "pyscreeze" in err_text or "pillow" in err_text or "unable to import" in err_text: + use_opencv_fallback = True + else: + log.warning("Поиск по картинке %s (попытка %s): %s", image_path, attempt + 1, e) + if use_opencv_fallback: + if _find_and_click_image_opencv(resolved, confidence=confidence): + return True + if attempt < retries - 1: + time.sleep(timeout / retries) + continue + if attempt < retries - 1: + time.sleep(timeout / retries) + if use_opencv_fallback: + log.warning("Изображение на экране не найдено (OpenCV): %s", image_path) + else: + log.warning("Изображение на экране не найдено: %s", image_path) + return False + + +def set_status(config, pid=None): + """Выполняет последовательность: фокус окна, клики, очистка, ввод текста, сохранить. pid — для резервного поиска окна по процессу.""" + title = config["window_title"] + message = config["status_message"] + confidence = float(config.get("image_confidence", 0.8)) + + if not focus_window(title, pid=pid): + log.error("Не удалось активировать окно: %s", title) + return False + + time.sleep(0.7) + rect = get_window_rect(title, pid=pid) + use_icon_img = config.get("icon_image") + use_status_img = config.get("status_image") + use_save_img = config.get("save_button_image") + + # Иконка + if use_icon_img: + if not find_and_click_image(use_icon_img, confidence=confidence): + log.error("Не найдена картинка иконки: %s", use_icon_img) + return False + else: + icon_xy = _resolve_click_position( + rect, config.get("icon_position"), config.get("icon_position_rel") + ) + if icon_xy is None: + log.error("Не заданы координаты иконки (icon_image или icon_position / icon_position_rel).") + return False + if not rect: + log.warning("Позиция окна не получена; координаты считаются абсолютными.") + pyautogui.click(icon_xy[0], icon_xy[1]) + time.sleep(0.4) + + # Поле статуса + if use_status_img: + if not find_and_click_image(use_status_img, confidence=confidence): + log.error("Не найдена картинка поля статуса: %s", use_status_img) + return False + else: + status_xy = _resolve_click_position( + rect, config.get("status_position"), config.get("status_position_rel") + ) + if status_xy is None: + log.error("Не заданы координаты поля статуса (status_image или status_position / status_position_rel).") + return False + pyautogui.click(status_xy[0], status_xy[1]) + + time.sleep(0.5) + # Выделить всё и удалить (очистка текущего статуса) + pyautogui.hotkey("ctrl", "a") + time.sleep(0.3) + pyautogui.press("delete") + time.sleep(0.3) + # Ввод нового статуса (через буфер обмена) + pyperclip.copy(message) + pyautogui.hotkey("ctrl", "v") + time.sleep(0.3) + + # Кнопка «Сохранить» + if use_save_img: + if not find_and_click_image(use_save_img, confidence=confidence): + log.error("Не найдена картинка кнопки «Сохранить»: %s", use_save_img) + return False + else: + save_xy = _resolve_click_position( + rect, config.get("save_button_position"), config.get("save_button_position_rel") + ) + if save_xy is None: + log.error("Не заданы координаты кнопки сохранения (save_button_image или save_button_position / save_button_position_rel).") + return False + pyautogui.click(save_xy[0], save_xy[1]) + + log.info("Статус установлен: %s", message) + return True + + +def main(): + config, ip_mask = load_config() + if config is None: + log.info("Текущий IP не совпадает ни с одной маской из конфига — выход без действий.") + return 0 + + process_name = config["process_name"] + startup_delay = config["startup_delay"] + + log.info("IP совпадает с маской %s, ожидание процесса %s...", ip_mask, process_name) + pid = wait_for_process(process_name) + log.info("Процесс запущен (PID %s), ожидание %s сек...", pid, startup_delay) + time.sleep(startup_delay) + + set_status(config, pid=pid) + return 0 + + +if __name__ == "__main__": + sys.exit(main())