В этой статье будет показано, как разработать навык для Яндекс Алисы, позволяющий удалённо управлять компьютером. Для реализации мы будем использовать языки Kotlin и Java.
Мне такой навык понадобился для управления медиаплеером — например, чтобы ставить видео на паузу, регулировать громкость, переключать треки или видео, перематывать назад или вперёд, открывать определённые фильмы на Кинопоиске. Я часто использую компьютер как телевизор, и возможность голосового управления делает использование гораздо удобнее.
Архитектура решения
Определим архитектуру приложения. Она будет состоять из клиентских и серверных нод.
При запуске клиентская нода отправляет запрос на серверную ноду, передавая следующие данные: имя ноды, хост, порт.
При отключении клиентская нода также отправляет запрос с указанием своего имени для удаления из кластера.
Клиентских нод может быть несколько — они идентифицируются по имени.
Серверная нода выполняет следующие функции:
Хранит информацию о клиентских нодах (имя, адрес, порт).
Принимает команды от пользователя (например, через навык Алисы).
Определяет, какую команду нужно выполнить и на какой клиентской ноде, используя шаблоны, описанные в конфигурации.
Пересылает команду соответствующей клиентской ноде для выполнения.

Таким образом, сервер выступает как центральный координатор между внешними интерфейсами и реальными исполнительными агентами (клиентами).
Клиентская нода
Начнём с разработки клиентской ноды. Основные задачи:
Отправить запрос на серверную ноду для подключения к кластеру.
Принимать HTTP-запросы и выполнять команды.
При завершении работы отправить запрос на серверную ноду для отключения.
Информацию о командах, которые может выполнять клиентская нода, мы будем хранить в конфигурационном файле client_commands.toml.
[windows.post]
shutdown = "shutdown /s /t 1"
reboot = "shutdown /r /t 1"
sleep = "shutdown /h"
[macos.post]
shutdown = "sudo shutdown -h now"
reboot = "sudo shutdown -r now"
sleep = "pmset sleepnow"
[linux.post]
shutdown = "sudo shutdown -h now"
reboot = "sudo reboot"
sleep = "systemctl suspend"
Файл команд разделён на секции, каждая из которых указывает:
Первый префикс — операционная система, для которой предназначены команды (windows, macos, linux).
Второй префикс — HTTP-метод, по которому команда будет доступна (post, get и т.д.).
Далее — список команд, каждая из которых содержит:
название команды (например, shutdown, reboot, sleep);
строку с системной командой, которую нужно выполнить на ноде.
Каждая клиентская нода содержит файл конфигурации config.yml, в котором указаны ключевые параметры её работы:
name: "Компьютер"
host: "192.168.0.100"
port: 11301
server-base-url: "http://server-core/server-core/api/v1/"
name — уникальное имя клиентской ноды;
host — IP-адрес или доменное имя, по которому нода доступна в сети;
port — порт, на котором будет запущен HTTP-сервер;
server-base-url — URL серверной ноды, к которой подключается клиент.
При запуске приложения вызывается метод initializeApplication()
, который выполняет несколько ключевых задач:
private static void initializeApplication() throws IOException {
config = loadConfig(); // Загрузка конфигурации из config.yml
serverCore = createServerCore(config.serverBaseUrl()); // Создание Retrofit-клиента
registerShutdownHook(); // Регистрация shutdown hook для корректного отключения
}
Загрузка конфигурации: с помощью SnakeYAML читается
config.yml
, и значения помещаются в объектAppConfig
.Создание Retrofit-клиента: инициализируется
ServerCore
— интерфейс взаимодействия с серверной нодой.Shutdown hook: при завершении работы приложения будет отправлен запрос
unregisterNode
на серверную ноду, чтобы удалить клиент из кластера.
После инициализации вызывается метод startApplication()
, который регистрирует клиентскую ноду на сервере и, при успешной регистрации, запускает HTTP-сервер с командами:
private static void startApplication() {
connectToServer(() -> {
ParserCommand parser = new TomlParserCommand(); // Парсинг client_commands.toml
var osCommands = parser.parse("/client_commands.toml", OSUtils.getOperatingSystem());
app = createAndStartJavalinApp(config.port()); // Запуск HTTP-сервера Javalin
registerPostCommands(app, osCommands.commandsTypePost()); // Регистрация команд POST
});
}
connectToServer
— отправляет запрос на регистрацию клиентской ноды на сервере. В случае успеха выполняется лямбда-функция.client_commands.toml
— содержит список системных команд, сгруппированных по ОС и HTTP-методу (как описано ранее).Регистрация команд — каждая команда из TOML-файла становится доступной по HTTP-адресу:
http://{host}:{port}/{имя_команды}
Пример: команда
reboot
будет доступна поhttp://192.168.0.100:11301/reboot
.
Каждая команда обрабатывается следующим образом
app.post(path, ctx -> executeCommand(ctx, command));
Внутри executeCommand
создаётся процесс с помощью ProcessBuilder
, и результат исполнения возвращается пользователю.
Полный исходный код клиентской ноды доступен на GitHub.
Серверная нода
Серверная нода (server-core
) отвечает за хранение сведений о клиентских нодах и обработку поступающих команд. Команды поступают в виде текстового сообщения, содержащего имя ноды и требуемое действие (например, «перезагрузить node-1»).
Для распознавания и интерпретации таких команд используется конфигурационный файл, в котором задаются шаблоны сообщений и соответствующие команды, например:
remote-ops:
commands:
- messages:
- "перезагрузить ${name}"
- "перезагрузи ${name}"
command: "reboot"
- messages:
- "выключить ${name}"
- "выключи ${name}"
command: "shutdown"
Основная логика находится в RemoteOpsService
Метод processRemoteOperation(message)
отвечает за обработку поступившего сообщения. Он ищет подходящую команду, сравнивая шаблоны с текстом сообщения. В случае совпадения извлекается команда и выполняется на всех подходящих клиентских нодах:
public void processRemoteOperation(String message) {
findMatchingCommand(message)
.ifPresentOrElse(
this::executeCommandOnAllNodes,
() -> handleUnknownCommand(message)
);
}
Поиск команды осуществляется по шаблону с подстановкой имени каждой зарегистрированной ноды:
private boolean matchesMessage(String template, String message) {
return clientNodeService.getRegisteredNodeNames().stream()
.anyMatch(nodeName ->
message.equalsIgnoreCase(template.replace("${name}", nodeName))
);
}
Если подходящая команда найдена, она отправляется всем нодам, имя которых соответствует шаблону.
Работа с клиентскими нодами — ClientNodeService
Класс ClientNodeService
реализует весь функционал, связанный с клиентскими нодами:
Регистрация и удаление нод;
Хранение адресов;
Отправка команд;
Все зарегистрированные клиентские ноды хранятся в ConcurrentHashMap<String, String>
, где ключ — это имя ноды, а значение — её сетевой адрес в формате host:port
. Регистрация и удаление нод осуществляется через HTTP-интерфейс:
public void registerNode(String name, String host, int port) {
String address = formatAddress(host, port);
nodes.put(name, address);
log.info("Node registered: {} -> {}", name, address);
}
public void unregisterNode(String name) {
nodes.remove(name);
}
Вместо встроенного HashMap можно использовать внешнюю систему хранения, например Redis.
Выполнение команды на конкретной ноде:
public void executeCommandOnNode(String command, String nodeName) {
validateNodeExists(nodeName);
String url = buildCommandUrl(nodeName, command);
Request request = buildPostRequest(url);
try (Response response = httpClient.newCall(request).execute()) {
handleResponse(response, nodeName, command);
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to execute command on node: " + nodeName, e);
}
}
Каждая команда отправляется по шаблону http://<ip:port>/<command>
с пустым телом POST-запроса.
HTTP-интерфейс
POST /client-nodes
— регистрация новой ноды.DELETE /client-nodes/{name}
— удаление ноды.POST /remote-operations
— принимает JSON с полемmessage
, обрабатывает и выполняет команду, если она определена.
Полный исходный код серверной ноды доступен на GitHub.
Навык Яндекс Алиса
Для взаимодействия пользователя с системой через голосовой интерфейс я реализовал навык Яндекс Алисы. Навык построен с использованием Kotlin-библиотеки alice-ktx, которая упрощает создание навыков. Вы также можете использовать любую другую клиентскую реализацию — будь то Web, AlexStar, Android или iOS приложение, — для отправки текстовых команд на сервер.
Пример реализации навыка на Kotlin:
fun main() {
val remoteOpsService = RemoteOpsService(
createRetrofitClient(SkillConfig.SERVER_BASE_URL)
)
skill {
webhookServer = ktorWebhookServer {
port = SkillConfig.WEBHOOK_PORT
path = SkillConfig.WEBHOOK_PATH
}
dispatch {
newSession {
response {
text = "Привет! Я могу выполнять удаленные команды."
}
}
message {
val isSuccess = remoteOpsService.executeCommand(messageText)
val responseText = if (isSuccess) {
"Команда успешно выполнена"
} else {
"Не удалось выполнить команду"
}
response { text = responseText }
}
}
}.run()
}
Что делает этот код:
При запуске навыка открывается HTTP‑сервер, принимающий вебхуки от Яндекс Алисы.
Когда пользователь впервые запускает сессию, навык приветствует его фразой: «Привет! Я могу выполнять удаленные команды.»
Когда пользователь произносит команду (например: «выключи сервер1»), навык отправляет текстовое сообщение на серверную ноду через
RemoteOpsService
.В зависимости от результата выполнения команды навык возвращает один из двух вариантов ответа: «Команда успешно выполнена» или «Не удалось выполнить команду».