@iola_adm/iola-cli 0.2.47 → 0.2.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -210,8 +210,10 @@ Yandex tools уже доступны: профиль Yandex ID, расширен
210
210
  Мой домофон:
211
211
 
212
212
  ```bash
213
+ iola ufanet
213
214
  iola ufanet setup
214
215
  iola ufanet intercoms
216
+ iola ufanet open
215
217
  iola ufanet open ID
216
218
  iola ufanet history
217
219
  iola ufanet cameras
@@ -222,7 +224,7 @@ iola dom_ru
222
224
  iola rostelecom
223
225
  ```
224
226
 
225
- Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры/RTSP и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
227
+ `/ufanet` в интерактивном CLI открывает отдельное меню домофона с выбором действия цифрой. Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры/RTSP и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Команда `открой домофон` открывает единственный домофон сразу; если домофонов несколько, CLI просит выбрать адрес цифрой. При уведомлении о вызове можно ответить `да`, `да открой` или `нет`. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
226
228
 
227
229
  Инструкция: [Мой домофон](https://github.com/adm-iola/iola-cli/wiki/Мой-домофон).
228
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -11,7 +11,7 @@ description: Мой домофон Уфанет: список домофонов
11
11
 
12
12
  - `ufanet_status` - проверить, подключен ли Уфанет.
13
13
  - `ufanet_intercoms` - показать доступные домофоны пользователя.
14
- - `ufanet_open_intercom` - открыть домофон по ID.
14
+ - `ufanet_open_intercom` - открыть домофон по ID; если ID не указан, CLI сам получает список домофонов.
15
15
  - `ufanet_call_history` - показать историю звонков домофона.
16
16
  - `ufanet_call_links` - получить ссылку на запись/превью звонка по UUID.
17
17
  - `ufanet_cameras` - показать доступные камеры и RTSP-ссылки.
@@ -22,6 +22,7 @@ description: Мой домофон Уфанет: список домофонов
22
22
  - `iola ufanet status` - проверить подключение.
23
23
  - `iola ufanet intercoms` - список домофонов.
24
24
  - `iola ufanet open ID` - открыть домофон после подтверждения.
25
+ - `iola ufanet open` - если домофон один, открыть его; если несколько, попросить выбрать цифрой.
25
26
  - `iola ufanet history` - история звонков.
26
27
  - `iola ufanet watch` - показывать новые вызовы, пока CLI открыт.
27
28
  - `iola ufanet notifications on|off|status` - включить, выключить или проверить настройку уведомлений.
@@ -32,7 +33,8 @@ description: Мой домофон Уфанет: список домофонов
32
33
  Безопасность:
33
34
 
34
35
  - Для `ufanet_open_intercom` всегда нужен явный запрос пользователя и `confirm=true`.
35
- - Если пользователь просит "открой домофон", но ID домофона неизвестен, сначала покажи список домофонов или попроси выбрать ID.
36
+ - Если пользователь просит "открой домофон", а ID не указан, CLI должен сам получить список домофонов. Если домофон один - открыть его. Если домофонов несколько - показать адреса с цифрами и ждать выбор цифрой; выбранная цифра считается подтверждением.
37
+ - Если включены уведомления и пришел вызов, в сообщении должен быть вопрос "Открыть?". Ответы `да`, `да открой`, `открой`, `ок` открывают сопоставленный домофон. Ответы `нет`, `отмена` отменяют.
36
38
  - Не открывай домофон по косвенному намерению вроде "кто там", "звонят", "посмотри".
37
39
  - Не выводи договор, пароль, JWT-токен и другие секреты.
38
40
  - Если Уфанет не подключен, скажи запустить `/master` и выбрать `Мой домофон Уфанет`, либо команду `iola ufanet setup`.
package/src/cli.js CHANGED
@@ -1187,7 +1187,7 @@ async function startAgentReadline() {
1187
1187
  }
1188
1188
 
1189
1189
  async function startAgentRawInput() {
1190
- const state = { history: [], buffer: "", selected: 0, slashOffset: 0, slashOpen: false, running: false, renderedInputLines: 0, renderedLines: 0, rawMode: true, pendingOutput: "", aiStatus: null, statusBar: false, statusRows: 0 };
1190
+ const state = { history: [], buffer: "", selected: 0, slashOffset: 0, slashOpen: false, running: false, renderedInputLines: 0, renderedLines: 0, rawMode: true, pendingOutput: "", pendingAction: null, aiStatus: null, statusBar: false, statusRows: 0 };
1191
1191
  const wasRaw = input.isRaw;
1192
1192
  activateRawInput(input);
1193
1193
  setupAgentStatusBar(state);
@@ -1195,6 +1195,7 @@ async function startAgentRawInput() {
1195
1195
  await refreshAgentAiStatus(state);
1196
1196
  const render = () => renderAgentInput(state);
1197
1197
  render();
1198
+ const stopUfanetNotifications = setupAgentUfanetNotifications(state, render);
1198
1199
 
1199
1200
  try {
1200
1201
  while (true) {
@@ -1271,6 +1272,7 @@ async function startAgentRawInput() {
1271
1272
  }
1272
1273
  }
1273
1274
  } finally {
1275
+ stopUfanetNotifications();
1274
1276
  clearAgentInputArea(state);
1275
1277
  finishAgentTerminalLine(state);
1276
1278
  if (!wasRaw) input.setRawMode(false);
@@ -1280,10 +1282,15 @@ async function startAgentRawInput() {
1280
1282
 
1281
1283
  async function handleAgentLine(line, state) {
1282
1284
  if (!line.startsWith("/")) {
1283
- const answer = await aiAsk(state.rawMode ? [line, "--quiet"] : [line], { history: state.history });
1285
+ if (/^\d{1,2}$/u.test(line.trim()) && (state.pendingAction?.type === "ufanet_menu" || state.lastCommand?.command === "ufanet")) {
1286
+ state.pendingAction = state.pendingAction?.type === "ufanet_menu" ? state.pendingAction : buildUfanetMenuPendingAction();
1287
+ }
1288
+ const pendingAnswer = await handlePendingAgentAction(line, state);
1289
+ const answer = pendingAnswer || await aiAsk(state.rawMode ? [line, "--quiet"] : [line], { history: state.history, state });
1284
1290
  state.history.push({ role: "user", content: line });
1285
1291
  state.history.push({ role: "assistant", content: answer });
1286
1292
  if (state.rawMode) state.pendingOutput = answer;
1293
+ else if (pendingAnswer) printAiAnswer(pendingAnswer);
1287
1294
  return false;
1288
1295
  }
1289
1296
 
@@ -1305,6 +1312,10 @@ async function handleAgentLine(line, state) {
1305
1312
  }
1306
1313
 
1307
1314
  if (command === "help") {
1315
+ if (state.pendingAction?.type === "ufanet_menu") {
1316
+ await printUfanetMenu({ agentState: state, pending: true });
1317
+ return false;
1318
+ }
1308
1319
  printAgentHelp();
1309
1320
  return false;
1310
1321
  }
@@ -1600,6 +1611,21 @@ async function handleAgentLine(line, state) {
1600
1611
  return false;
1601
1612
  }
1602
1613
 
1614
+ if (command === "ufanet") {
1615
+ await handleUfanet(args.length ? args : ["menu"], state);
1616
+ return false;
1617
+ }
1618
+
1619
+ if (command === "dom_ru" || command === "domru") {
1620
+ await handleDomRu(args);
1621
+ return false;
1622
+ }
1623
+
1624
+ if (command === "rostelecom") {
1625
+ await handleRostelecom(args);
1626
+ return false;
1627
+ }
1628
+
1603
1629
  const mapped = {
1604
1630
  health: ["health", args],
1605
1631
  doctor: ["doctor", args],
@@ -1615,10 +1641,6 @@ async function handleAgentLine(line, state) {
1615
1641
  files: ["files", args],
1616
1642
  archive: ["archive", args],
1617
1643
  yandex: ["yandex", args.length ? args : ["menu"]],
1618
- ufanet: ["ufanet", args.length ? args : ["menu"]],
1619
- dom_ru: ["dom_ru", args],
1620
- domru: ["dom_ru", args],
1621
- rostelecom: ["rostelecom", args],
1622
1644
  changes: ["changes", args],
1623
1645
  index: ["index", args],
1624
1646
  reports: ["reports", args],
@@ -1901,6 +1923,146 @@ function flushPendingAgentOutput(state) {
1901
1923
  printAiAnswer(text);
1902
1924
  }
1903
1925
 
1926
+ async function handlePendingAgentAction(line, state) {
1927
+ const action = state?.pendingAction;
1928
+ if (!action) return "";
1929
+ const text = String(line || "").trim();
1930
+ const normalized = text.toLocaleLowerCase("ru-RU");
1931
+ if (/^(нет|не|отмена|cancel|no|n)$/iu.test(normalized)) {
1932
+ state.pendingAction = null;
1933
+ return "Действие отменено.";
1934
+ }
1935
+ if (action.type === "ufanet_select_open") {
1936
+ const number = Number(text.match(/\d+/u)?.[0] || 0);
1937
+ if (!number || number < 1 || number > action.choices.length) {
1938
+ return formatUfanetChoicePrompt(action.choices);
1939
+ }
1940
+ const choice = action.choices[number - 1];
1941
+ state.pendingAction = null;
1942
+ const result = await ufanetOpenIntercom(choice.id, { confirm: true });
1943
+ return formatUfanetOpenResult(result, choice);
1944
+ }
1945
+ if (action.type === "ufanet_confirm_open") {
1946
+ if (!/^(да|д|yes|y|ok|ок|открой|да\s+открой|открывай|пусти)/iu.test(normalized)) {
1947
+ return "Ответьте `да`, чтобы открыть домофон, или `нет`, чтобы отменить.";
1948
+ }
1949
+ state.pendingAction = null;
1950
+ const result = await ufanetOpenIntercom(action.id, { confirm: true });
1951
+ return formatUfanetOpenResult(result, action);
1952
+ }
1953
+ if (action.type === "ufanet_menu") {
1954
+ const number = Number(text.match(/^\s*(\d{1,2})\s*$/u)?.[1] || 0);
1955
+ if (number === 0) {
1956
+ state.pendingAction = null;
1957
+ return "Выход из меню домофона.";
1958
+ }
1959
+ if (!number || number < 0 || number > action.items.length) {
1960
+ state.pendingAction = buildUfanetMenuPendingAction();
1961
+ return await formatUfanetMenu({ compact: true });
1962
+ }
1963
+ const item = action.items[number - 1];
1964
+ state.pendingAction = null;
1965
+ const answer = await executeUfanetMenuItem(item, state);
1966
+ return answer || "";
1967
+ }
1968
+ return "";
1969
+ }
1970
+
1971
+ function setupAgentUfanetNotifications(state, render) {
1972
+ if (!input.isTTY) return () => {};
1973
+ let stopped = false;
1974
+ let timer = null;
1975
+ let busy = false;
1976
+ const schedule = (seconds = 10) => {
1977
+ if (stopped) return;
1978
+ timer = setTimeout(loop, normalizeUfanetPollInterval(seconds) * 1000);
1979
+ };
1980
+ const loop = async () => {
1981
+ if (stopped || busy) {
1982
+ schedule(10);
1983
+ return;
1984
+ }
1985
+ busy = true;
1986
+ let nextInterval = 10;
1987
+ try {
1988
+ const config = await loadConfig();
1989
+ const notificationConfig = config.domophones?.providers?.ufanet?.notifications || {};
1990
+ nextInterval = normalizeUfanetPollInterval(notificationConfig.intervalSeconds || 10);
1991
+ if (notificationConfig.enabled) {
1992
+ const history = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
1993
+ const rows = history.results || [];
1994
+ let lastSeen = notificationConfig.lastSeen || "";
1995
+ if (!lastSeen && rows[0]) {
1996
+ await updateUfanetLastSeen(ufanetCallKey(rows[0]));
1997
+ } else {
1998
+ const fresh = [];
1999
+ for (const row of rows) {
2000
+ const key = ufanetCallKey(row);
2001
+ if (!key || key === lastSeen) break;
2002
+ fresh.push(row);
2003
+ }
2004
+ if (fresh.length > 0) {
2005
+ for (const row of fresh.reverse()) {
2006
+ await announceUfanetCallInAgent(row, state, render);
2007
+ }
2008
+ await updateUfanetLastSeen(ufanetCallKey(rows[0]) || lastSeen);
2009
+ }
2010
+ }
2011
+ }
2012
+ } catch (error) {
2013
+ if (process.env.IOLA_DEBUG === "1") {
2014
+ printAgentAsyncMessage(state, render, `Уфанет: ошибка проверки вызовов: ${error instanceof Error ? error.message : String(error)}`);
2015
+ }
2016
+ } finally {
2017
+ busy = false;
2018
+ schedule(nextInterval);
2019
+ }
2020
+ };
2021
+ schedule(2);
2022
+ return () => {
2023
+ stopped = true;
2024
+ if (timer) clearTimeout(timer);
2025
+ };
2026
+ }
2027
+
2028
+ async function announceUfanetCallInAgent(row, state, render) {
2029
+ const match = await resolveUfanetIntercomForCall(row).catch(() => null);
2030
+ if (match?.id) {
2031
+ state.pendingAction = {
2032
+ type: "ufanet_confirm_open",
2033
+ id: match.id,
2034
+ address: match.address || row.address || "",
2035
+ name: match.name || "",
2036
+ createdAt: new Date().toISOString(),
2037
+ };
2038
+ printAgentAsyncMessage(state, render, [
2039
+ "Новый вызов домофона Уфанет:",
2040
+ `Адрес: ${row.address || match.address || "-"}`,
2041
+ `Время: ${row.calledAt || "-"}`,
2042
+ "Открыть? Ответьте `да` или `нет`.",
2043
+ ].join("\n"));
2044
+ return;
2045
+ }
2046
+ const intercoms = await ufanetGetIntercoms().catch(() => []);
2047
+ const choices = intercoms.map((item) => ({ id: item.id, name: item.name, address: item.address }));
2048
+ state.pendingAction = choices.length
2049
+ ? { type: "ufanet_select_open", choices, createdAt: new Date().toISOString() }
2050
+ : null;
2051
+ printAgentAsyncMessage(state, render, [
2052
+ "Новый вызов домофона Уфанет:",
2053
+ `Адрес: ${row.address || "-"}`,
2054
+ `Время: ${row.calledAt || "-"}`,
2055
+ choices.length ? "Открыть? Выберите домофон номером:" : "Не смог сопоставить вызов с домофоном.",
2056
+ choices.length ? formatUfanetChoicePrompt(choices) : "",
2057
+ ].filter(Boolean).join("\n"));
2058
+ }
2059
+
2060
+ function printAgentAsyncMessage(state, render, text) {
2061
+ clearAgentInputArea(state);
2062
+ output.write(`${text}\n`);
2063
+ render();
2064
+ }
2065
+
1904
2066
  function colorSlashSelection(row) {
1905
2067
  if (!output.isTTY || process.env.NO_COLOR === "1") return row;
1906
2068
  return `\x1b[38;5;213m${row}\x1b[0m`;
@@ -3571,12 +3733,29 @@ async function handleRostelecom() {
3571
3733
  console.log("Пока доступна заготовка пункта в городских сервисах. API/авторизация будут добавлены после исследования провайдера.");
3572
3734
  }
3573
3735
 
3574
- async function handleUfanet(args = []) {
3736
+ async function handleUfanet(args = [], agentState = null) {
3575
3737
  const [action = process.stdin.isTTY ? "menu" : "status", target, ...rest] = args;
3576
3738
  const options = parseOptions(rest);
3577
3739
 
3578
- if (action === "menu" || action === "choose") {
3579
- await printUfanetMenu();
3740
+ if (action === "menu" || action === "choose" || action === "help") {
3741
+ await printUfanetMenu({ agentState, pending: action !== "help" });
3742
+ return;
3743
+ }
3744
+
3745
+ if (/^\d{1,2}$/u.test(String(action))) {
3746
+ const number = Number(action);
3747
+ const items = getUfanetMenuItems();
3748
+ if (number === 0) {
3749
+ console.log("Выход из меню домофона.");
3750
+ return;
3751
+ }
3752
+ const item = items[number - 1];
3753
+ if (!item) {
3754
+ console.log(formatUfanetMenu({ compact: true }));
3755
+ return;
3756
+ }
3757
+ const answer = await executeUfanetMenuItem(item, agentState);
3758
+ if (answer) console.log(answer);
3580
3759
  return;
3581
3760
  }
3582
3761
 
@@ -3598,7 +3777,11 @@ async function handleUfanet(args = []) {
3598
3777
 
3599
3778
  if (action === "open") {
3600
3779
  const intercomId = target || options.id || options.intercom;
3601
- if (!intercomId) throw new Error("Укажите ID домофона. Пример: iola ufanet open 123");
3780
+ if (!intercomId) {
3781
+ const result = await ufanetOpenSmart({ state: agentState });
3782
+ console.log(formatUfanetSmartOpenResult(result));
3783
+ return;
3784
+ }
3602
3785
  const ok = options.yes || options.confirm || await confirm(`Открыть домофон Уфанет #${intercomId}? [y/N] `);
3603
3786
  if (!ok) {
3604
3787
  console.log("Открытие отменено.");
@@ -3661,28 +3844,88 @@ async function handleUfanet(args = []) {
3661
3844
  iola ufanet delete`);
3662
3845
  }
3663
3846
 
3664
- async function printUfanetMenu() {
3847
+ async function printUfanetMenu(options = {}) {
3848
+ if (options.agentState && options.pending) {
3849
+ options.agentState.pendingAction = buildUfanetMenuPendingAction();
3850
+ }
3851
+ console.log(await formatUfanetMenu());
3852
+ }
3853
+
3854
+ async function formatUfanetMenu(options = {}) {
3665
3855
  const status = await getUfanetStatus();
3666
- console.log("Мой домофон");
3667
- printTable([
3668
- { id: "ufanet", provider: "Уфанет", status: status.configured ? "готово" : "не настроено", command: "iola ufanet setup" },
3669
- { id: "domru", provider: "Дом.ру", status: "в разработке", command: "iola dom_ru" },
3670
- { id: "rostelecom", provider: "Ростелеком", status: "в разработке", command: "iola rostelecom" },
3671
- ], [["id", "ID"], ["provider", "Провайдер"], ["status", "Статус"], ["command", "Команда"]]);
3672
- console.log("");
3673
- console.log("Команды Уфанет:");
3674
- printTable([
3675
- { command: "/ufanet status", action: "статус подключения" },
3676
- { command: "/ufanet intercoms", action: "список доступных домофонов и их ID" },
3677
- { command: "/ufanet open ID", action: "открыть домофон по ID, только после подтверждения" },
3678
- { command: "/ufanet history", action: "история последних звонков" },
3679
- { command: "/ufanet links UUID", action: "ссылка/превью записи звонка по UUID" },
3680
- { command: "/ufanet cameras", action: "список камер и RTSP-ссылок, если доступны" },
3681
- { command: "/ufanet watch", action: "показывать новые вызовы, пока CLI открыт" },
3682
- { command: "/ufanet notifications on", action: "включить уведомления о вызовах в настройках" },
3683
- { command: "/ufanet notifications off", action: "выключить уведомления о вызовах" },
3684
- { command: "/ufanet delete", action: "удалить локальное подключение Уфанет" },
3685
- ], [["command", "Команда"], ["action", "Что делает"]]);
3856
+ const lines = [
3857
+ "Мой домофон",
3858
+ `Уфанет: ${status.configured ? "готово" : "не настроено"}; уведомления: ${status.notifications}.`,
3859
+ "Дом.ру: в разработке. Ростелеком: в разработке.",
3860
+ "",
3861
+ "Выберите действие:",
3862
+ ...getUfanetMenuItems().map((item) => `${item.number}. ${item.title}`),
3863
+ "0. Назад",
3864
+ "",
3865
+ "Введите цифру или команду вида `/ufanet 3`. Пока открыт этот раздел, `/help` показывает справку по домофону.",
3866
+ ];
3867
+ return options.compact ? lines.slice(4).join("\n") : lines.join("\n");
3868
+ }
3869
+
3870
+ function getUfanetMenuItems() {
3871
+ return [
3872
+ { number: 1, key: "open", title: "Открыть домофон" },
3873
+ { number: 2, key: "intercoms", title: "Показать мои домофоны" },
3874
+ { number: 3, key: "history", title: "История звонков" },
3875
+ { number: 4, key: "notifications-on", title: "Включить уведомления о вызовах" },
3876
+ { number: 5, key: "notifications-off", title: "Выключить уведомления" },
3877
+ { number: 6, key: "notifications-status", title: "Статус уведомлений" },
3878
+ { number: 7, key: "cameras", title: "Камеры" },
3879
+ { number: 8, key: "status", title: "Статус подключения" },
3880
+ { number: 9, key: "delete", title: "Удалить подключение Уфанет" },
3881
+ ];
3882
+ }
3883
+
3884
+ function buildUfanetMenuPendingAction() {
3885
+ return { type: "ufanet_menu", items: getUfanetMenuItems(), createdAt: new Date().toISOString() };
3886
+ }
3887
+
3888
+ async function executeUfanetMenuItem(item, agentState = null) {
3889
+ if (!item) return "";
3890
+ if (item.key === "open") {
3891
+ const result = await ufanetOpenSmart({ state: agentState });
3892
+ return formatUfanetSmartOpenResult(result);
3893
+ }
3894
+ if (item.key === "intercoms") {
3895
+ const rows = await ufanetGetIntercoms();
3896
+ if (!rows.length) return "Доступные домофоны Уфанет не найдены.";
3897
+ return ["Домофоны Уфанет:", ...rows.map((row, index) => `${index + 1}. ${row.address || row.name || `Домофон #${row.id}`} — ID ${row.id}`)].join("\n");
3898
+ }
3899
+ if (item.key === "history") {
3900
+ const rows = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
3901
+ if (!rows.results?.length) return "В истории Уфанет звонков не найдено.";
3902
+ return ["История звонков Уфанет:", ...rows.results.map((row, index) => `${index + 1}. ${row.calledAt || "-"} — ${row.address || "-"}${row.uuid ? `, UUID ${row.uuid}` : ""}`)].join("\n");
3903
+ }
3904
+ if (item.key === "notifications-on") {
3905
+ await setUfanetNotifications(true);
3906
+ return "Уведомления Уфанет включены. При новом вызове в CLI можно ответить `да` или `нет`.";
3907
+ }
3908
+ if (item.key === "notifications-off") {
3909
+ await setUfanetNotifications(false);
3910
+ return "Уведомления Уфанет выключены.";
3911
+ }
3912
+ if (item.key === "notifications-status") {
3913
+ const status = await getUfanetStatus();
3914
+ return `Уведомления Уфанет: ${status.notifications}, интервал ${status.intervalSeconds} сек.`;
3915
+ }
3916
+ if (item.key === "cameras") {
3917
+ const rows = await ufanetGetCameras();
3918
+ if (!rows.length) return "Камеры Уфанет не найдены.";
3919
+ return ["Камеры Уфанет:", ...rows.slice(0, 10).map((row, index) => `${index + 1}. ${row.title || row.number || "камера"} — ${row.address || "-"}${row.rtspUrl ? `, ${row.rtspUrl}` : ""}`)].join("\n");
3920
+ }
3921
+ if (item.key === "status") {
3922
+ const status = await getUfanetStatus();
3923
+ return `Уфанет: ${status.configured ? "настроен" : "не настроен"}, ${status.enabled ? "включен" : "выключен"}, уведомления ${status.notifications}.`;
3924
+ }
3925
+ if (item.key === "delete") {
3926
+ return "Удаление подключения выполняется явной командой: `/ufanet delete`.";
3927
+ }
3928
+ return "";
3686
3929
  }
3687
3930
 
3688
3931
  async function setupUfanetConnector() {
@@ -3917,7 +4160,9 @@ function ufanetCallKey(row = {}) {
3917
4160
  async function executeUfanetTool(tool, args = {}) {
3918
4161
  if (tool === "ufanet_status") return getUfanetStatus();
3919
4162
  if (tool === "ufanet_intercoms") return ufanetGetIntercoms();
3920
- if (tool === "ufanet_open_intercom") return ufanetOpenIntercom(args.id || args.intercomId || args.intercom_id, args);
4163
+ if (tool === "ufanet_open_intercom") return args.id || args.intercomId || args.intercom_id
4164
+ ? ufanetOpenIntercom(args.id || args.intercomId || args.intercom_id, args)
4165
+ : ufanetOpenSmart({ ...args, state: args.state });
3921
4166
  if (tool === "ufanet_call_history") return ufanetGetCallHistory({ page: args.page || 1, pageSize: args.pageSize || args.page_size || args.limit || 10 });
3922
4167
  if (tool === "ufanet_call_links") return ufanetGetCallLinks(args.uuid || args.id);
3923
4168
  if (tool === "ufanet_cameras") return ufanetGetCameras();
@@ -4011,6 +4256,59 @@ async function ufanetOpenIntercom(intercomId, options = {}) {
4011
4256
  return { provider: "ufanet", status: payload?.result ? "opened" : "not-opened", id: Number(intercomId), result: Boolean(payload?.result) };
4012
4257
  }
4013
4258
 
4259
+ async function ufanetOpenSmart(options = {}) {
4260
+ const intercomId = options.id || options.intercomId || options.intercom_id;
4261
+ if (intercomId) {
4262
+ const result = await ufanetOpenIntercom(intercomId, { confirm: true });
4263
+ return { type: "opened", result, choice: { id: intercomId } };
4264
+ }
4265
+ const intercoms = await ufanetGetIntercoms();
4266
+ if (intercoms.length === 0) return { type: "empty" };
4267
+ const choices = intercoms.map((item) => ({ id: item.id, name: item.name, address: item.address }));
4268
+ if (intercoms.length === 1) {
4269
+ const result = await ufanetOpenIntercom(intercoms[0].id, { confirm: true });
4270
+ return { type: "opened", result, choice: choices[0] };
4271
+ }
4272
+ if (options.state) {
4273
+ options.state.pendingAction = { type: "ufanet_select_open", choices, createdAt: new Date().toISOString() };
4274
+ }
4275
+ return { type: "select", choices };
4276
+ }
4277
+
4278
+ function formatUfanetSmartOpenResult(value) {
4279
+ if (value.type === "empty") return "Уфанет подключен, но доступных домофонов не найдено.";
4280
+ if (value.type === "select") return formatUfanetChoicePrompt(value.choices);
4281
+ if (value.type === "opened") return formatUfanetOpenResult(value.result, value.choice);
4282
+ return "Не удалось выполнить действие с домофоном.";
4283
+ }
4284
+
4285
+ function formatUfanetChoicePrompt(choices = []) {
4286
+ return [
4287
+ "Найдено несколько домофонов. Какой открыть? Ответьте цифрой:",
4288
+ ...choices.map((item, index) => `${index + 1}. ${item.address || item.name || `Домофон #${item.id}`} — ID ${item.id}`),
4289
+ ].join("\n");
4290
+ }
4291
+
4292
+ function formatUfanetOpenResult(result, choice = {}) {
4293
+ const label = choice.address || choice.name || `ID ${result.id}`;
4294
+ return result.result
4295
+ ? `Домофон открыт: ${label}.`
4296
+ : `Уфанет не подтвердил открытие домофона: ${label}.`;
4297
+ }
4298
+
4299
+ async function resolveUfanetIntercomForCall(row = {}) {
4300
+ const address = normalizeGeoText(row.address || "");
4301
+ if (!address) return null;
4302
+ const intercoms = await ufanetGetIntercoms();
4303
+ const matches = intercoms.filter((item) => {
4304
+ const itemAddress = normalizeGeoText(item.address || item.name || "");
4305
+ return itemAddress && (itemAddress.includes(address) || address.includes(itemAddress));
4306
+ });
4307
+ if (matches.length === 1) return matches[0];
4308
+ if (matches.length > 1) return null;
4309
+ return intercoms.length === 1 ? intercoms[0] : null;
4310
+ }
4311
+
4014
4312
  async function ufanetGetCallHistory(options = {}) {
4015
4313
  const page = Math.max(1, Number(options.page || 1));
4016
4314
  const pageSize = Math.max(1, Math.min(100, Number(options.pageSize || 10)));
@@ -12915,6 +13213,7 @@ async function setupIolaLocal(args) {
12915
13213
 
12916
13214
  async function aiAsk(args, context = {}) {
12917
13215
  const options = parseOptions(args);
13216
+ if (context.state) options.state = context.state;
12918
13217
  const question = options._.join(" ").trim();
12919
13218
 
12920
13219
  if (!question) {
@@ -12964,6 +13263,20 @@ async function aiAsk(args, context = {}) {
12964
13263
  if (!options.quiet) console.log(userSkillAnswer);
12965
13264
  return userSkillAnswer;
12966
13265
  }
13266
+ const ufanetAnswer = await buildUfanetDirectAnswer(question, context);
13267
+ if (ufanetAnswer) {
13268
+ if (historyEnabled) {
13269
+ recordAskHistory({ question, answer: ufanetAnswer, providerConfig, dataContext, error: "", sessionId });
13270
+ appendSessionExchange(sessionId, question, ufanetAnswer, dataContext, "");
13271
+ }
13272
+ emitEvent(options, "answer", { length: ufanetAnswer.length, sessionId, direct: true, ufanet: true });
13273
+ if (options.output) {
13274
+ await assertPermission("writeFiles");
13275
+ await writeFile(options.output, ufanetAnswer, "utf8");
13276
+ }
13277
+ if (!options.quiet) console.log(ufanetAnswer);
13278
+ return ufanetAnswer;
13279
+ }
12967
13280
  if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
12968
13281
  const yandexContactAnswer = await buildYandexDirectAnswer(question, context.history || history);
12969
13282
  if (yandexContactAnswer) {
@@ -14561,6 +14874,48 @@ function cleanupCloudSaveText(question) {
14561
14874
  .trim();
14562
14875
  }
14563
14876
 
14877
+ async function buildUfanetDirectAnswer(question, context = {}) {
14878
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
14879
+ if (!/(домофон|уфанет|ufanet|дверь|подъезд)/iu.test(normalized)) return "";
14880
+ if (/(дом\.?ру|dom\.?ru)/iu.test(normalized)) return "Мой домофон Дом.ру пока в разработке. Сейчас реализован Уфанет: /ufanet.";
14881
+ if (/(ростелеком|rostelecom)/iu.test(normalized)) return "Мой домофон Ростелеком пока в разработке. Сейчас реализован Уфанет: /ufanet.";
14882
+ if (/(уведом|оповещ|сообщ).{0,40}(включ|получ|on|вкл)/iu.test(normalized) || /(включ|получ).{0,40}(уведом|оповещ|сообщ).{0,40}(домофон)/iu.test(normalized)) {
14883
+ await setUfanetNotifications(true, { intervalSeconds: extractSecondsFromText(question) || 10 });
14884
+ return "Уведомления Уфанет включены. Когда в CLI придет новый вызов, можно ответить `да`, чтобы открыть, или `нет`, чтобы отменить.";
14885
+ }
14886
+ if (/(уведом|оповещ|сообщ).{0,40}(выключ|отключ|off|выкл)/iu.test(normalized) || /(выключ|отключ).{0,40}(уведом|оповещ|сообщ).{0,40}(домофон)/iu.test(normalized)) {
14887
+ await setUfanetNotifications(false);
14888
+ return "Уведомления Уфанет выключены.";
14889
+ }
14890
+ if (/(статус|подключ|аккаунт|договор)/iu.test(normalized)) {
14891
+ const status = await getUfanetStatus();
14892
+ return `Уфанет: ${status.configured ? "настроен" : "не настроен"}, уведомления ${status.notifications}.`;
14893
+ }
14894
+ if (/(открой|открыть|открывай|пусти|впусти|двер)/iu.test(normalized)) {
14895
+ const id = extractUfanetIntercomId(question);
14896
+ const result = await ufanetOpenSmart({ id, state: context.state });
14897
+ return formatUfanetSmartOpenResult(result);
14898
+ }
14899
+ if (/(истори|звонк|кто звонил|последн)/iu.test(normalized)) {
14900
+ const rows = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
14901
+ if (!rows.results?.length) return "В истории Уфанет звонков не найдено.";
14902
+ return ["История звонков Уфанет:", ...rows.results.map((row, index) => `${index + 1}. ${row.calledAt || "-"} — ${row.address || "-"}${row.uuid ? `, UUID ${row.uuid}` : ""}`)].join("\n");
14903
+ }
14904
+ if (/(камер|rtsp|видео)/iu.test(normalized)) {
14905
+ const rows = await ufanetGetCameras();
14906
+ if (!rows.length) return "Камеры Уфанет не найдены.";
14907
+ return ["Камеры Уфанет:", ...rows.slice(0, 10).map((row, index) => `${index + 1}. ${row.title || row.number || "камера"} — ${row.address || "-"}${row.rtspUrl ? `, ${row.rtspUrl}` : ""}`)].join("\n");
14908
+ }
14909
+ const intercoms = await ufanetGetIntercoms();
14910
+ if (!intercoms.length) return "Доступные домофоны Уфанет не найдены.";
14911
+ return ["Доступные домофоны Уфанет:", ...intercoms.map((item, index) => `${index + 1}. ${item.address || item.name || `Домофон #${item.id}`} — ID ${item.id}`)].join("\n");
14912
+ }
14913
+
14914
+ function extractSecondsFromText(text) {
14915
+ const match = String(text || "").match(/(\d{1,3})\s*(?:сек|seconds|s)\b/iu);
14916
+ return match ? Number(match[1]) : 0;
14917
+ }
14918
+
14564
14919
  function detectDirectDataFields(normalizedQuestion) {
14565
14920
  const fields = [];
14566
14921
  if (/(директ|руководител|заведующ|кто возглавляет)/iu.test(normalizedQuestion)) fields.push("head");
@@ -15179,8 +15534,7 @@ function inferToolPlan(question, options = {}) {
15179
15534
  if (/(ростелеком|rostelecom)/iu.test(normalized)) return { directAnswer: "Мой домофон Ростелеком пока в разработке. Сейчас реализован Уфанет: /ufanet." };
15180
15535
  if (/(открой|открыть|пусти|впусти|двер)/iu.test(normalized)) {
15181
15536
  const id = extractUfanetIntercomId(question);
15182
- if (!id) return { directAnswer: "Для открытия домофона нужен ID. Посмотрите доступные домофоны командой /ufanet intercoms, затем: /ufanet open ID." };
15183
- return { steps: [{ tool: "ufanet_open_intercom", args: { id, confirm: true } }] };
15537
+ return { steps: [{ tool: "ufanet_open_intercom", args: { ...(id ? { id } : {}), confirm: true } }] };
15184
15538
  }
15185
15539
  if (/(истори|звонк|кто\s+звонил|последн)/iu.test(normalized) && !/(ссылк|запис|видео)/iu.test(normalized)) {
15186
15540
  return { steps: [{ tool: "ufanet_call_history", args: { limit: 10 } }] };
@@ -15687,7 +16041,7 @@ async function executeToolPlan(plan, options = {}) {
15687
16041
  outputs.push({ tool: step.tool, rows: current.length });
15688
16042
  } else if (UFANET_TOOLS.includes(step.tool)) {
15689
16043
  await assertPermission("externalApi");
15690
- const result = await executeUfanetTool(step.tool, step.args || {});
16044
+ const result = await executeUfanetTool(step.tool, { ...(step.args || {}), state: options.state });
15691
16045
  current = Array.isArray(result) ? result : [result];
15692
16046
  outputs.push({ tool: step.tool, rows: current.length });
15693
16047
  } else if (USER_SKILL_TOOLS.includes(step.tool)) {
@@ -15845,6 +16199,9 @@ function formatToolResult(result, options) {
15845
16199
  return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
15846
16200
  }
15847
16201
  if (row.date && row.time) return `Сегодня ${row.date}, ${row.time}.`;
16202
+ if (row.type === "select" && Array.isArray(row.choices)) return formatUfanetChoicePrompt(row.choices);
16203
+ if (row.type === "empty") return "Уфанет подключен, но доступных домофонов не найдено.";
16204
+ if (row.type === "opened" && row.result) return formatUfanetOpenResult(row.result, row.choice || {});
15848
16205
  if (row.provider === "ufanet" && (row.status === "opened" || row.status === "not-opened")) return `Уфанет: домофон #${row.id} ${row.status === "opened" ? "открыт" : "не открылся"}.`;
15849
16206
  if (row.provider === "ufanet" && row.uuid && (row.url || row.preview)) return `Уфанет: запись звонка ${row.uuid}\nСсылка: ${row.url || "-"}\nПревью: ${row.preview || "-"}`;
15850
16207
  if (row.rtspUrl) return `Камера Уфанет ${row.title || row.number}: ${row.address || "-"}\nRTSP: ${row.rtspUrl}`;
@@ -91,9 +91,11 @@ iola yandex token delete
91
91
  Мой домофон:
92
92
 
93
93
  ```bash
94
+ iola ufanet
94
95
  iola ufanet setup
95
96
  iola ufanet status
96
97
  iola ufanet intercoms
98
+ iola ufanet open
97
99
  iola ufanet open ID
98
100
  iola ufanet history --limit 10
99
101
  iola ufanet links UUID
@@ -7,12 +7,14 @@
7
7
  Рабочий провайдер:
8
8
 
9
9
  ```bash
10
+ iola ufanet
10
11
  iola ufanet setup
11
12
  iola ufanet status
12
13
  iola ufanet intercoms
13
14
  iola ufanet history
14
15
  iola ufanet cameras
15
16
  iola ufanet links UUID
17
+ iola ufanet open
16
18
  iola ufanet open ID
17
19
  iola ufanet watch
18
20
  iola ufanet notifications on
@@ -21,6 +23,8 @@ iola ufanet notifications status
21
23
  iola ufanet delete
22
24
  ```
23
25
 
26
+ В интерактивном CLI команда `/ufanet` открывает отдельное меню домофона. В нем можно выбрать действие цифрой: открыть домофон, посмотреть список, историю, уведомления, камеры или статус.
27
+
24
28
  При настройке нужны:
25
29
 
26
30
  - номер договора Уфанет;
@@ -42,6 +46,8 @@ UFANET_PASSWORD
42
46
  Доступные сценарии:
43
47
 
44
48
  - показать доступные домофоны;
49
+ - открыть единственный домофон простой фразой `открой домофон`;
50
+ - если домофонов несколько, показать адреса с цифрами и открыть выбранный номер;
45
51
  - открыть домофон по ID;
46
52
  - показать историю звонков;
47
53
  - получить ссылку на запись звонка по UUID;
@@ -51,7 +57,7 @@ UFANET_PASSWORD
51
57
  - включить или выключить настройку уведомлений;
52
58
  - удалить локальное подключение.
53
59
 
54
- Открытие двери всегда требует явного подтверждения пользователя.
60
+ Открытие двери всегда требует явного действия пользователя. Если домофон один, фраза `открой домофон` считается таким действием. Если домофонов несколько, CLI выводит адреса с цифрами, а выбранная цифра считается подтверждением.
55
61
 
56
62
  Уведомления сейчас работают через периодическую проверку истории звонков Уфанета:
57
63
 
@@ -59,7 +65,7 @@ UFANET_PASSWORD
59
65
  iola ufanet watch --seconds 10
60
66
  ```
61
67
 
62
- Остановка режима наблюдения: `Ctrl+C`. Команда `notifications on` сохраняет настройку, а `watch` запускает получение событий в текущей CLI-сессии.
68
+ Остановка режима наблюдения: `Ctrl+C`. Команда `notifications on` сохраняет настройку. В обычном интерактивном CLI при включенных уведомлениях новый вызов выводится с вопросом `Открыть?`; ответы `да`, `да открой`, `открой`, `ок` открывают сопоставленный домофон, `нет` или `отмена` отменяют.
63
69
 
64
70
  ## Дом.ру
65
71