@iola_adm/iola-cli 0.2.46 → 0.2.48

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
@@ -212,14 +212,18 @@ Yandex tools уже доступны: профиль Yandex ID, расширен
212
212
  ```bash
213
213
  iola ufanet setup
214
214
  iola ufanet intercoms
215
+ iola ufanet open
215
216
  iola ufanet open ID
216
217
  iola ufanet history
217
218
  iola ufanet cameras
219
+ iola ufanet watch
220
+ iola ufanet notifications on
221
+ iola ufanet notifications off
218
222
  iola dom_ru
219
223
  iola rostelecom
220
224
  ```
221
225
 
222
- Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков и камеры/RTSP. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
226
+ Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры/RTSP и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Команда `открой домофон` открывает единственный домофон сразу; если домофонов несколько, CLI просит выбрать адрес цифрой. При уведомлении о вызове можно ответить `да`, `да открой` или `нет`. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
223
227
 
224
228
  Инструкция: [Мой домофон](https://github.com/adm-iola/iola-cli/wiki/Мой-домофон).
225
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: ufanet-intercom
3
- description: Мой домофон Уфанет: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры и RTSP.
3
+ description: Мой домофон Уфанет: список домофонов, открытие двери после подтверждения, история звонков, уведомления о новых вызовах, записи звонков, камеры и RTSP.
4
4
  ---
5
5
 
6
- Используй этот skill, когда пользователь явно просит работать с домофоном Уфанет: открыть домофон, показать доступные домофоны, посмотреть историю звонков, получить запись звонка, показать камеры или RTSP.
6
+ Используй этот skill, когда пользователь явно просит работать с домофоном Уфанет: открыть домофон, показать доступные домофоны, посмотреть историю звонков, включить/выключить уведомления о вызовах, получить запись звонка, показать камеры или RTSP.
7
7
 
8
8
  Не смешивай Уфанет с Яндекс-сервисами, городскими открытыми слоями и локальными файлами. Это отдельный личный сервис пользователя.
9
9
 
@@ -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,7 +22,10 @@ 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` - история звонков.
27
+ - `iola ufanet watch` - показывать новые вызовы, пока CLI открыт.
28
+ - `iola ufanet notifications on|off|status` - включить, выключить или проверить настройку уведомлений.
26
29
  - `iola ufanet links UUID` - ссылка на запись звонка.
27
30
  - `iola ufanet cameras` - камеры.
28
31
  - `iola ufanet delete` - удалить локальное подключение.
@@ -30,7 +33,8 @@ description: Мой домофон Уфанет: список домофонов
30
33
  Безопасность:
31
34
 
32
35
  - Для `ufanet_open_intercom` всегда нужен явный запрос пользователя и `confirm=true`.
33
- - Если пользователь просит "открой домофон", но ID домофона неизвестен, сначала покажи список домофонов или попроси выбрать ID.
36
+ - Если пользователь просит "открой домофон", а ID не указан, CLI должен сам получить список домофонов. Если домофон один - открыть его. Если домофонов несколько - показать адреса с цифрами и ждать выбор цифрой; выбранная цифра считается подтверждением.
37
+ - Если включены уведомления и пришел вызов, в сообщении должен быть вопрос "Открыть?". Ответы `да`, `да открой`, `открой`, `ок` открывают сопоставленный домофон. Ответы `нет`, `отмена` отменяют.
34
38
  - Не открывай домофон по косвенному намерению вроде "кто там", "звонят", "посмотри".
35
39
  - Не выводи договор, пароль, JWT-токен и другие секреты.
36
40
  - Если Уфанет не подключен, скажи запустить `/master` и выбрать `Мой домофон Уфанет`, либо команду `iola ufanet setup`.
package/src/cli.js CHANGED
@@ -492,7 +492,7 @@ const DEFAULT_AI_CONFIG = {
492
492
  domophones: {
493
493
  activeProvider: "",
494
494
  providers: {
495
- ufanet: { enabled: false },
495
+ ufanet: { enabled: false, notifications: { enabled: false, intervalSeconds: 10, lastSeen: "" } },
496
496
  domru: { enabled: false, status: "backlog" },
497
497
  rostelecom: { enabled: false, status: "backlog" },
498
498
  },
@@ -600,6 +600,7 @@ const SLASH_COMMANDS = [
600
600
  { command: "/cloud status", description: "облачные диски" },
601
601
  { command: "/yandex", description: "выбор сервисов Yandex Connector" },
602
602
  { command: "/ufanet", description: "Мой домофон Уфанет" },
603
+ { command: "/ufanet watch", description: "уведомления о новых вызовах домофона" },
603
604
  { command: "/dom_ru", description: "Мой домофон Дом.ру (в разработке)" },
604
605
  { command: "/rostelecom", description: "Мой домофон Ростелеком (в разработке)" },
605
606
  { command: "/archive doctor", description: "архиватор" },
@@ -872,7 +873,7 @@ Usage:
872
873
  iola files status|mode|approvals|tree|read|search|write|patch
873
874
  iola cloud setup|status|ls|find|upload|download|share|save|backup
874
875
  iola yandex setup|menu|status|services|enable|disable|oauth-url|token
875
- iola ufanet setup|status|intercoms|open|history|links|cameras|delete
876
+ iola ufanet setup|status|intercoms|open|history|links|cameras|watch|notifications|delete
876
877
  iola dom_ru Мой домофон Дом.ру (в разработке)
877
878
  iola rostelecom Мой домофон Ростелеком (в разработке)
878
879
  iola archive doctor|list|test|extract|create|index
@@ -1186,7 +1187,7 @@ async function startAgentReadline() {
1186
1187
  }
1187
1188
 
1188
1189
  async function startAgentRawInput() {
1189
- 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 };
1190
1191
  const wasRaw = input.isRaw;
1191
1192
  activateRawInput(input);
1192
1193
  setupAgentStatusBar(state);
@@ -1194,6 +1195,7 @@ async function startAgentRawInput() {
1194
1195
  await refreshAgentAiStatus(state);
1195
1196
  const render = () => renderAgentInput(state);
1196
1197
  render();
1198
+ const stopUfanetNotifications = setupAgentUfanetNotifications(state, render);
1197
1199
 
1198
1200
  try {
1199
1201
  while (true) {
@@ -1270,6 +1272,7 @@ async function startAgentRawInput() {
1270
1272
  }
1271
1273
  }
1272
1274
  } finally {
1275
+ stopUfanetNotifications();
1273
1276
  clearAgentInputArea(state);
1274
1277
  finishAgentTerminalLine(state);
1275
1278
  if (!wasRaw) input.setRawMode(false);
@@ -1279,7 +1282,8 @@ async function startAgentRawInput() {
1279
1282
 
1280
1283
  async function handleAgentLine(line, state) {
1281
1284
  if (!line.startsWith("/")) {
1282
- const answer = await aiAsk(state.rawMode ? [line, "--quiet"] : [line], { history: state.history });
1285
+ const pendingAnswer = await handlePendingAgentAction(line, state);
1286
+ const answer = pendingAnswer || await aiAsk(state.rawMode ? [line, "--quiet"] : [line], { history: state.history, state });
1283
1287
  state.history.push({ role: "user", content: line });
1284
1288
  state.history.push({ role: "assistant", content: answer });
1285
1289
  if (state.rawMode) state.pendingOutput = answer;
@@ -1599,6 +1603,21 @@ async function handleAgentLine(line, state) {
1599
1603
  return false;
1600
1604
  }
1601
1605
 
1606
+ if (command === "ufanet") {
1607
+ await handleUfanet(args.length ? args : ["menu"], state);
1608
+ return false;
1609
+ }
1610
+
1611
+ if (command === "dom_ru" || command === "domru") {
1612
+ await handleDomRu(args);
1613
+ return false;
1614
+ }
1615
+
1616
+ if (command === "rostelecom") {
1617
+ await handleRostelecom(args);
1618
+ return false;
1619
+ }
1620
+
1602
1621
  const mapped = {
1603
1622
  health: ["health", args],
1604
1623
  doctor: ["doctor", args],
@@ -1896,6 +1915,131 @@ function flushPendingAgentOutput(state) {
1896
1915
  printAiAnswer(text);
1897
1916
  }
1898
1917
 
1918
+ async function handlePendingAgentAction(line, state) {
1919
+ const action = state?.pendingAction;
1920
+ if (!action) return "";
1921
+ const text = String(line || "").trim();
1922
+ const normalized = text.toLocaleLowerCase("ru-RU");
1923
+ if (/^(нет|не|отмена|cancel|no|n)$/iu.test(normalized)) {
1924
+ state.pendingAction = null;
1925
+ return "Действие отменено.";
1926
+ }
1927
+ if (action.type === "ufanet_select_open") {
1928
+ const number = Number(text.match(/\d+/u)?.[0] || 0);
1929
+ if (!number || number < 1 || number > action.choices.length) {
1930
+ return formatUfanetChoicePrompt(action.choices);
1931
+ }
1932
+ const choice = action.choices[number - 1];
1933
+ state.pendingAction = null;
1934
+ const result = await ufanetOpenIntercom(choice.id, { confirm: true });
1935
+ return formatUfanetOpenResult(result, choice);
1936
+ }
1937
+ if (action.type === "ufanet_confirm_open") {
1938
+ if (!/^(да|д|yes|y|ok|ок|открой|да\s+открой|открывай|пусти)/iu.test(normalized)) {
1939
+ return "Ответьте `да`, чтобы открыть домофон, или `нет`, чтобы отменить.";
1940
+ }
1941
+ state.pendingAction = null;
1942
+ const result = await ufanetOpenIntercom(action.id, { confirm: true });
1943
+ return formatUfanetOpenResult(result, action);
1944
+ }
1945
+ return "";
1946
+ }
1947
+
1948
+ function setupAgentUfanetNotifications(state, render) {
1949
+ if (!input.isTTY) return () => {};
1950
+ let stopped = false;
1951
+ let timer = null;
1952
+ let busy = false;
1953
+ const schedule = (seconds = 10) => {
1954
+ if (stopped) return;
1955
+ timer = setTimeout(loop, normalizeUfanetPollInterval(seconds) * 1000);
1956
+ };
1957
+ const loop = async () => {
1958
+ if (stopped || busy) {
1959
+ schedule(10);
1960
+ return;
1961
+ }
1962
+ busy = true;
1963
+ let nextInterval = 10;
1964
+ try {
1965
+ const config = await loadConfig();
1966
+ const notificationConfig = config.domophones?.providers?.ufanet?.notifications || {};
1967
+ nextInterval = normalizeUfanetPollInterval(notificationConfig.intervalSeconds || 10);
1968
+ if (notificationConfig.enabled) {
1969
+ const history = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
1970
+ const rows = history.results || [];
1971
+ let lastSeen = notificationConfig.lastSeen || "";
1972
+ if (!lastSeen && rows[0]) {
1973
+ await updateUfanetLastSeen(ufanetCallKey(rows[0]));
1974
+ } else {
1975
+ const fresh = [];
1976
+ for (const row of rows) {
1977
+ const key = ufanetCallKey(row);
1978
+ if (!key || key === lastSeen) break;
1979
+ fresh.push(row);
1980
+ }
1981
+ if (fresh.length > 0) {
1982
+ for (const row of fresh.reverse()) {
1983
+ await announceUfanetCallInAgent(row, state, render);
1984
+ }
1985
+ await updateUfanetLastSeen(ufanetCallKey(rows[0]) || lastSeen);
1986
+ }
1987
+ }
1988
+ }
1989
+ } catch (error) {
1990
+ if (process.env.IOLA_DEBUG === "1") {
1991
+ printAgentAsyncMessage(state, render, `Уфанет: ошибка проверки вызовов: ${error instanceof Error ? error.message : String(error)}`);
1992
+ }
1993
+ } finally {
1994
+ busy = false;
1995
+ schedule(nextInterval);
1996
+ }
1997
+ };
1998
+ schedule(2);
1999
+ return () => {
2000
+ stopped = true;
2001
+ if (timer) clearTimeout(timer);
2002
+ };
2003
+ }
2004
+
2005
+ async function announceUfanetCallInAgent(row, state, render) {
2006
+ const match = await resolveUfanetIntercomForCall(row).catch(() => null);
2007
+ if (match?.id) {
2008
+ state.pendingAction = {
2009
+ type: "ufanet_confirm_open",
2010
+ id: match.id,
2011
+ address: match.address || row.address || "",
2012
+ name: match.name || "",
2013
+ createdAt: new Date().toISOString(),
2014
+ };
2015
+ printAgentAsyncMessage(state, render, [
2016
+ "Новый вызов домофона Уфанет:",
2017
+ `Адрес: ${row.address || match.address || "-"}`,
2018
+ `Время: ${row.calledAt || "-"}`,
2019
+ "Открыть? Ответьте `да` или `нет`.",
2020
+ ].join("\n"));
2021
+ return;
2022
+ }
2023
+ const intercoms = await ufanetGetIntercoms().catch(() => []);
2024
+ const choices = intercoms.map((item) => ({ id: item.id, name: item.name, address: item.address }));
2025
+ state.pendingAction = choices.length
2026
+ ? { type: "ufanet_select_open", choices, createdAt: new Date().toISOString() }
2027
+ : null;
2028
+ printAgentAsyncMessage(state, render, [
2029
+ "Новый вызов домофона Уфанет:",
2030
+ `Адрес: ${row.address || "-"}`,
2031
+ `Время: ${row.calledAt || "-"}`,
2032
+ choices.length ? "Открыть? Выберите домофон номером:" : "Не смог сопоставить вызов с домофоном.",
2033
+ choices.length ? formatUfanetChoicePrompt(choices) : "",
2034
+ ].filter(Boolean).join("\n"));
2035
+ }
2036
+
2037
+ function printAgentAsyncMessage(state, render, text) {
2038
+ clearAgentInputArea(state);
2039
+ output.write(`${text}\n`);
2040
+ render();
2041
+ }
2042
+
1899
2043
  function colorSlashSelection(row) {
1900
2044
  if (!output.isTTY || process.env.NO_COLOR === "1") return row;
1901
2045
  return `\x1b[38;5;213m${row}\x1b[0m`;
@@ -3566,7 +3710,7 @@ async function handleRostelecom() {
3566
3710
  console.log("Пока доступна заготовка пункта в городских сервисах. API/авторизация будут добавлены после исследования провайдера.");
3567
3711
  }
3568
3712
 
3569
- async function handleUfanet(args = []) {
3713
+ async function handleUfanet(args = [], agentState = null) {
3570
3714
  const [action = process.stdin.isTTY ? "menu" : "status", target, ...rest] = args;
3571
3715
  const options = parseOptions(rest);
3572
3716
 
@@ -3593,7 +3737,11 @@ async function handleUfanet(args = []) {
3593
3737
 
3594
3738
  if (action === "open") {
3595
3739
  const intercomId = target || options.id || options.intercom;
3596
- if (!intercomId) throw new Error("Укажите ID домофона. Пример: iola ufanet open 123");
3740
+ if (!intercomId) {
3741
+ const result = await ufanetOpenSmart({ state: agentState });
3742
+ console.log(formatUfanetSmartOpenResult(result));
3743
+ return;
3744
+ }
3597
3745
  const ok = options.yes || options.confirm || await confirm(`Открыть домофон Уфанет #${intercomId}? [y/N] `);
3598
3746
  if (!ok) {
3599
3747
  console.log("Открытие отменено.");
@@ -3610,6 +3758,16 @@ async function handleUfanet(args = []) {
3610
3758
  return;
3611
3759
  }
3612
3760
 
3761
+ if (action === "watch" || action === "listen") {
3762
+ await watchUfanetCalls({ ...options, once: options.once, intervalSeconds: options.seconds || options.interval || target });
3763
+ return;
3764
+ }
3765
+
3766
+ if (action === "notifications" || action === "notify") {
3767
+ await handleUfanetNotifications([target, ...rest].filter(Boolean));
3768
+ return;
3769
+ }
3770
+
3613
3771
  if (action === "links" || action === "record" || action === "recording") {
3614
3772
  const uuid = target || options.uuid;
3615
3773
  if (!uuid) throw new Error("Укажите UUID звонка. Пример: iola ufanet links UUID");
@@ -3641,6 +3799,8 @@ async function handleUfanet(args = []) {
3641
3799
  iola ufanet history [--limit 10]
3642
3800
  iola ufanet links UUID
3643
3801
  iola ufanet cameras
3802
+ iola ufanet watch [--seconds 10]
3803
+ iola ufanet notifications on|off|status
3644
3804
  iola ufanet delete`);
3645
3805
  }
3646
3806
 
@@ -3652,6 +3812,20 @@ async function printUfanetMenu() {
3652
3812
  { id: "domru", provider: "Дом.ру", status: "в разработке", command: "iola dom_ru" },
3653
3813
  { id: "rostelecom", provider: "Ростелеком", status: "в разработке", command: "iola rostelecom" },
3654
3814
  ], [["id", "ID"], ["provider", "Провайдер"], ["status", "Статус"], ["command", "Команда"]]);
3815
+ console.log("");
3816
+ console.log("Команды Уфанет:");
3817
+ printTable([
3818
+ { command: "/ufanet status", action: "статус подключения" },
3819
+ { command: "/ufanet intercoms", action: "список доступных домофонов и их ID" },
3820
+ { command: "/ufanet open ID", action: "открыть домофон по ID, только после подтверждения" },
3821
+ { command: "/ufanet history", action: "история последних звонков" },
3822
+ { command: "/ufanet links UUID", action: "ссылка/превью записи звонка по UUID" },
3823
+ { command: "/ufanet cameras", action: "список камер и RTSP-ссылок, если доступны" },
3824
+ { command: "/ufanet watch", action: "показывать новые вызовы, пока CLI открыт" },
3825
+ { command: "/ufanet notifications on", action: "включить уведомления о вызовах в настройках" },
3826
+ { command: "/ufanet notifications off", action: "выключить уведомления о вызовах" },
3827
+ { command: "/ufanet delete", action: "удалить локальное подключение Уфанет" },
3828
+ ], [["command", "Команда"], ["action", "Что делает"]]);
3655
3829
  }
3656
3830
 
3657
3831
  async function setupUfanetConnector() {
@@ -3730,6 +3904,8 @@ async function printUfanetStatus(options = {}) {
3730
3904
  enabled: status.enabled ? "yes" : "no",
3731
3905
  contract: status.contract || "-",
3732
3906
  source: status.source || "-",
3907
+ notifications: status.notifications,
3908
+ intervalSeconds: status.intervalSeconds,
3733
3909
  });
3734
3910
  if (options.check) {
3735
3911
  if (!status.configured) {
@@ -3754,13 +3930,139 @@ async function getUfanetStatus() {
3754
3930
  enabled: Boolean(config.domophones?.providers?.ufanet?.enabled || (config.toolsets?.enabled || []).includes("ufanet")),
3755
3931
  contract: contract ? maskSecret(contract, 2) : "",
3756
3932
  source: contract && process.env.UFANET_CONTRACT ? "env" : contract ? "local" : "",
3933
+ notifications: config.domophones?.providers?.ufanet?.notifications?.enabled ? "on" : "off",
3934
+ intervalSeconds: config.domophones?.providers?.ufanet?.notifications?.intervalSeconds || 10,
3757
3935
  };
3758
3936
  }
3759
3937
 
3938
+ async function handleUfanetNotifications(args = []) {
3939
+ const [action = "status", ...rest] = args;
3940
+ const options = parseOptions(rest);
3941
+ const normalized = String(action || "status").toLocaleLowerCase("ru-RU");
3942
+ if (["on", "enable", "start", "вкл", "включить"].includes(normalized)) {
3943
+ const seconds = Number(options.seconds || options.interval || options.wait || 10);
3944
+ await setUfanetNotifications(true, { intervalSeconds: seconds });
3945
+ console.log(`Уведомления Уфанет включены. Интервал проверки: ${normalizeUfanetPollInterval(seconds)} сек.`);
3946
+ console.log("Чтобы получать события в текущей CLI-сессии, запустите: /ufanet watch");
3947
+ return;
3948
+ }
3949
+ if (["off", "disable", "stop", "выкл", "выключить"].includes(normalized)) {
3950
+ await setUfanetNotifications(false);
3951
+ console.log("Уведомления Уфанет выключены.");
3952
+ return;
3953
+ }
3954
+ const status = await getUfanetStatus();
3955
+ console.log(`Уведомления Уфанет: ${status.notifications}, интервал ${status.intervalSeconds} сек.`);
3956
+ console.log("Команды: /ufanet notifications on, /ufanet notifications off, /ufanet watch");
3957
+ }
3958
+
3959
+ async function setUfanetNotifications(enabled, options = {}) {
3960
+ const config = await loadConfig();
3961
+ const current = config.domophones?.providers?.ufanet || {};
3962
+ const currentNotifications = current.notifications || {};
3963
+ await saveConfig({
3964
+ domophones: {
3965
+ ...(config.domophones || {}),
3966
+ providers: {
3967
+ ...(config.domophones?.providers || {}),
3968
+ ufanet: {
3969
+ ...current,
3970
+ notifications: {
3971
+ ...currentNotifications,
3972
+ enabled: Boolean(enabled),
3973
+ intervalSeconds: normalizeUfanetPollInterval(options.intervalSeconds || currentNotifications.intervalSeconds || 10),
3974
+ },
3975
+ },
3976
+ },
3977
+ },
3978
+ });
3979
+ }
3980
+
3981
+ async function updateUfanetLastSeen(lastSeen) {
3982
+ if (!lastSeen) return;
3983
+ const config = await loadConfig();
3984
+ const current = config.domophones?.providers?.ufanet || {};
3985
+ const currentNotifications = current.notifications || {};
3986
+ await saveConfig({
3987
+ domophones: {
3988
+ ...(config.domophones || {}),
3989
+ providers: {
3990
+ ...(config.domophones?.providers || {}),
3991
+ ufanet: {
3992
+ ...current,
3993
+ notifications: {
3994
+ ...currentNotifications,
3995
+ lastSeen,
3996
+ },
3997
+ },
3998
+ },
3999
+ },
4000
+ });
4001
+ }
4002
+
4003
+ async function watchUfanetCalls(options = {}) {
4004
+ const config = await loadConfig();
4005
+ const notificationConfig = config.domophones?.providers?.ufanet?.notifications || {};
4006
+ const intervalSeconds = normalizeUfanetPollInterval(options.intervalSeconds || notificationConfig.intervalSeconds || 10);
4007
+ const pageSize = Number(options.limit || options["page-size"] || 10);
4008
+ let lastSeen = notificationConfig.lastSeen || "";
4009
+ const initial = await ufanetGetCallHistory({ page: 1, pageSize });
4010
+ const initialRows = initial.results || [];
4011
+ if (!lastSeen && initialRows[0]) {
4012
+ lastSeen = ufanetCallKey(initialRows[0]);
4013
+ await updateUfanetLastSeen(lastSeen);
4014
+ }
4015
+ if (options.once) {
4016
+ printTable(initialRows, [["uuid", "UUID"], ["calledAt", "Когда"], ["address", "Адрес"], ["porch", "Подъезд"], ["flat", "Кв"]]);
4017
+ return;
4018
+ }
4019
+ console.log(`Уфанет: слежу за новыми вызовами каждые ${intervalSeconds} сек. Остановить: Ctrl+C.`);
4020
+ while (true) {
4021
+ await sleep(intervalSeconds * 1000);
4022
+ const history = await ufanetGetCallHistory({ page: 1, pageSize }).catch((error) => {
4023
+ console.error(`Уфанет: ошибка проверки вызовов: ${error instanceof Error ? error.message : String(error)}`);
4024
+ return { results: [] };
4025
+ });
4026
+ const rows = history.results || [];
4027
+ const fresh = [];
4028
+ for (const row of rows) {
4029
+ const key = ufanetCallKey(row);
4030
+ if (!key || key === lastSeen) break;
4031
+ fresh.push(row);
4032
+ }
4033
+ if (fresh.length === 0) continue;
4034
+ for (const row of fresh.reverse()) {
4035
+ console.log("");
4036
+ console.log("Новый вызов домофона Уфанет:");
4037
+ printKeyValue({
4038
+ uuid: row.uuid || "-",
4039
+ calledAt: row.calledAt || "-",
4040
+ address: row.address || "-",
4041
+ porch: row.porch || "-",
4042
+ flat: row.flat || "-",
4043
+ });
4044
+ }
4045
+ lastSeen = ufanetCallKey(rows[0]) || lastSeen;
4046
+ await updateUfanetLastSeen(lastSeen);
4047
+ }
4048
+ }
4049
+
4050
+ function normalizeUfanetPollInterval(value) {
4051
+ const seconds = Number(value || 10);
4052
+ if (!Number.isFinite(seconds)) return 10;
4053
+ return Math.max(5, Math.min(300, Math.round(seconds)));
4054
+ }
4055
+
4056
+ function ufanetCallKey(row = {}) {
4057
+ return String(row.uuid || `${row.calledAt || ""}|${row.address || ""}|${row.porch || ""}|${row.flat || ""}`);
4058
+ }
4059
+
3760
4060
  async function executeUfanetTool(tool, args = {}) {
3761
4061
  if (tool === "ufanet_status") return getUfanetStatus();
3762
4062
  if (tool === "ufanet_intercoms") return ufanetGetIntercoms();
3763
- if (tool === "ufanet_open_intercom") return ufanetOpenIntercom(args.id || args.intercomId || args.intercom_id, args);
4063
+ if (tool === "ufanet_open_intercom") return args.id || args.intercomId || args.intercom_id
4064
+ ? ufanetOpenIntercom(args.id || args.intercomId || args.intercom_id, args)
4065
+ : ufanetOpenSmart({ ...args, state: args.state });
3764
4066
  if (tool === "ufanet_call_history") return ufanetGetCallHistory({ page: args.page || 1, pageSize: args.pageSize || args.page_size || args.limit || 10 });
3765
4067
  if (tool === "ufanet_call_links") return ufanetGetCallLinks(args.uuid || args.id);
3766
4068
  if (tool === "ufanet_cameras") return ufanetGetCameras();
@@ -3854,6 +4156,59 @@ async function ufanetOpenIntercom(intercomId, options = {}) {
3854
4156
  return { provider: "ufanet", status: payload?.result ? "opened" : "not-opened", id: Number(intercomId), result: Boolean(payload?.result) };
3855
4157
  }
3856
4158
 
4159
+ async function ufanetOpenSmart(options = {}) {
4160
+ const intercomId = options.id || options.intercomId || options.intercom_id;
4161
+ if (intercomId) {
4162
+ const result = await ufanetOpenIntercom(intercomId, { confirm: true });
4163
+ return { type: "opened", result, choice: { id: intercomId } };
4164
+ }
4165
+ const intercoms = await ufanetGetIntercoms();
4166
+ if (intercoms.length === 0) return { type: "empty" };
4167
+ const choices = intercoms.map((item) => ({ id: item.id, name: item.name, address: item.address }));
4168
+ if (intercoms.length === 1) {
4169
+ const result = await ufanetOpenIntercom(intercoms[0].id, { confirm: true });
4170
+ return { type: "opened", result, choice: choices[0] };
4171
+ }
4172
+ if (options.state) {
4173
+ options.state.pendingAction = { type: "ufanet_select_open", choices, createdAt: new Date().toISOString() };
4174
+ }
4175
+ return { type: "select", choices };
4176
+ }
4177
+
4178
+ function formatUfanetSmartOpenResult(value) {
4179
+ if (value.type === "empty") return "Уфанет подключен, но доступных домофонов не найдено.";
4180
+ if (value.type === "select") return formatUfanetChoicePrompt(value.choices);
4181
+ if (value.type === "opened") return formatUfanetOpenResult(value.result, value.choice);
4182
+ return "Не удалось выполнить действие с домофоном.";
4183
+ }
4184
+
4185
+ function formatUfanetChoicePrompt(choices = []) {
4186
+ return [
4187
+ "Найдено несколько домофонов. Какой открыть? Ответьте цифрой:",
4188
+ ...choices.map((item, index) => `${index + 1}. ${item.address || item.name || `Домофон #${item.id}`} — ID ${item.id}`),
4189
+ ].join("\n");
4190
+ }
4191
+
4192
+ function formatUfanetOpenResult(result, choice = {}) {
4193
+ const label = choice.address || choice.name || `ID ${result.id}`;
4194
+ return result.result
4195
+ ? `Домофон открыт: ${label}.`
4196
+ : `Уфанет не подтвердил открытие домофона: ${label}.`;
4197
+ }
4198
+
4199
+ async function resolveUfanetIntercomForCall(row = {}) {
4200
+ const address = normalizeGeoText(row.address || "");
4201
+ if (!address) return null;
4202
+ const intercoms = await ufanetGetIntercoms();
4203
+ const matches = intercoms.filter((item) => {
4204
+ const itemAddress = normalizeGeoText(item.address || item.name || "");
4205
+ return itemAddress && (itemAddress.includes(address) || address.includes(itemAddress));
4206
+ });
4207
+ if (matches.length === 1) return matches[0];
4208
+ if (matches.length > 1) return null;
4209
+ return intercoms.length === 1 ? intercoms[0] : null;
4210
+ }
4211
+
3857
4212
  async function ufanetGetCallHistory(options = {}) {
3858
4213
  const page = Math.max(1, Number(options.page || 1));
3859
4214
  const pageSize = Math.max(1, Math.min(100, Number(options.pageSize || 10)));
@@ -12758,6 +13113,7 @@ async function setupIolaLocal(args) {
12758
13113
 
12759
13114
  async function aiAsk(args, context = {}) {
12760
13115
  const options = parseOptions(args);
13116
+ if (context.state) options.state = context.state;
12761
13117
  const question = options._.join(" ").trim();
12762
13118
 
12763
13119
  if (!question) {
@@ -12807,6 +13163,20 @@ async function aiAsk(args, context = {}) {
12807
13163
  if (!options.quiet) console.log(userSkillAnswer);
12808
13164
  return userSkillAnswer;
12809
13165
  }
13166
+ const ufanetAnswer = await buildUfanetDirectAnswer(question, context);
13167
+ if (ufanetAnswer) {
13168
+ if (historyEnabled) {
13169
+ recordAskHistory({ question, answer: ufanetAnswer, providerConfig, dataContext, error: "", sessionId });
13170
+ appendSessionExchange(sessionId, question, ufanetAnswer, dataContext, "");
13171
+ }
13172
+ emitEvent(options, "answer", { length: ufanetAnswer.length, sessionId, direct: true, ufanet: true });
13173
+ if (options.output) {
13174
+ await assertPermission("writeFiles");
13175
+ await writeFile(options.output, ufanetAnswer, "utf8");
13176
+ }
13177
+ if (!options.quiet) console.log(ufanetAnswer);
13178
+ return ufanetAnswer;
13179
+ }
12810
13180
  if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
12811
13181
  const yandexContactAnswer = await buildYandexDirectAnswer(question, context.history || history);
12812
13182
  if (yandexContactAnswer) {
@@ -14404,6 +14774,48 @@ function cleanupCloudSaveText(question) {
14404
14774
  .trim();
14405
14775
  }
14406
14776
 
14777
+ async function buildUfanetDirectAnswer(question, context = {}) {
14778
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
14779
+ if (!/(домофон|уфанет|ufanet|дверь|подъезд)/iu.test(normalized)) return "";
14780
+ if (/(дом\.?ру|dom\.?ru)/iu.test(normalized)) return "Мой домофон Дом.ру пока в разработке. Сейчас реализован Уфанет: /ufanet.";
14781
+ if (/(ростелеком|rostelecom)/iu.test(normalized)) return "Мой домофон Ростелеком пока в разработке. Сейчас реализован Уфанет: /ufanet.";
14782
+ if (/(уведом|оповещ|сообщ).{0,40}(включ|получ|on|вкл)/iu.test(normalized) || /(включ|получ).{0,40}(уведом|оповещ|сообщ).{0,40}(домофон)/iu.test(normalized)) {
14783
+ await setUfanetNotifications(true, { intervalSeconds: extractSecondsFromText(question) || 10 });
14784
+ return "Уведомления Уфанет включены. Когда в CLI придет новый вызов, можно ответить `да`, чтобы открыть, или `нет`, чтобы отменить.";
14785
+ }
14786
+ if (/(уведом|оповещ|сообщ).{0,40}(выключ|отключ|off|выкл)/iu.test(normalized) || /(выключ|отключ).{0,40}(уведом|оповещ|сообщ).{0,40}(домофон)/iu.test(normalized)) {
14787
+ await setUfanetNotifications(false);
14788
+ return "Уведомления Уфанет выключены.";
14789
+ }
14790
+ if (/(статус|подключ|аккаунт|договор)/iu.test(normalized)) {
14791
+ const status = await getUfanetStatus();
14792
+ return `Уфанет: ${status.configured ? "настроен" : "не настроен"}, уведомления ${status.notifications}.`;
14793
+ }
14794
+ if (/(открой|открыть|открывай|пусти|впусти|двер)/iu.test(normalized)) {
14795
+ const id = extractUfanetIntercomId(question);
14796
+ const result = await ufanetOpenSmart({ id, state: context.state });
14797
+ return formatUfanetSmartOpenResult(result);
14798
+ }
14799
+ if (/(истори|звонк|кто звонил|последн)/iu.test(normalized)) {
14800
+ const rows = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
14801
+ if (!rows.results?.length) return "В истории Уфанет звонков не найдено.";
14802
+ return ["История звонков Уфанет:", ...rows.results.map((row, index) => `${index + 1}. ${row.calledAt || "-"} — ${row.address || "-"}${row.uuid ? `, UUID ${row.uuid}` : ""}`)].join("\n");
14803
+ }
14804
+ if (/(камер|rtsp|видео)/iu.test(normalized)) {
14805
+ const rows = await ufanetGetCameras();
14806
+ if (!rows.length) return "Камеры Уфанет не найдены.";
14807
+ return ["Камеры Уфанет:", ...rows.slice(0, 10).map((row, index) => `${index + 1}. ${row.title || row.number || "камера"} — ${row.address || "-"}${row.rtspUrl ? `, ${row.rtspUrl}` : ""}`)].join("\n");
14808
+ }
14809
+ const intercoms = await ufanetGetIntercoms();
14810
+ if (!intercoms.length) return "Доступные домофоны Уфанет не найдены.";
14811
+ return ["Доступные домофоны Уфанет:", ...intercoms.map((item, index) => `${index + 1}. ${item.address || item.name || `Домофон #${item.id}`} — ID ${item.id}`)].join("\n");
14812
+ }
14813
+
14814
+ function extractSecondsFromText(text) {
14815
+ const match = String(text || "").match(/(\d{1,3})\s*(?:сек|seconds|s)\b/iu);
14816
+ return match ? Number(match[1]) : 0;
14817
+ }
14818
+
14407
14819
  function detectDirectDataFields(normalizedQuestion) {
14408
14820
  const fields = [];
14409
14821
  if (/(директ|руководител|заведующ|кто возглавляет)/iu.test(normalizedQuestion)) fields.push("head");
@@ -15022,8 +15434,7 @@ function inferToolPlan(question, options = {}) {
15022
15434
  if (/(ростелеком|rostelecom)/iu.test(normalized)) return { directAnswer: "Мой домофон Ростелеком пока в разработке. Сейчас реализован Уфанет: /ufanet." };
15023
15435
  if (/(открой|открыть|пусти|впусти|двер)/iu.test(normalized)) {
15024
15436
  const id = extractUfanetIntercomId(question);
15025
- if (!id) return { directAnswer: "Для открытия домофона нужен ID. Посмотрите доступные домофоны командой /ufanet intercoms, затем: /ufanet open ID." };
15026
- return { steps: [{ tool: "ufanet_open_intercom", args: { id, confirm: true } }] };
15437
+ return { steps: [{ tool: "ufanet_open_intercom", args: { ...(id ? { id } : {}), confirm: true } }] };
15027
15438
  }
15028
15439
  if (/(истори|звонк|кто\s+звонил|последн)/iu.test(normalized) && !/(ссылк|запис|видео)/iu.test(normalized)) {
15029
15440
  return { steps: [{ tool: "ufanet_call_history", args: { limit: 10 } }] };
@@ -15530,7 +15941,7 @@ async function executeToolPlan(plan, options = {}) {
15530
15941
  outputs.push({ tool: step.tool, rows: current.length });
15531
15942
  } else if (UFANET_TOOLS.includes(step.tool)) {
15532
15943
  await assertPermission("externalApi");
15533
- const result = await executeUfanetTool(step.tool, step.args || {});
15944
+ const result = await executeUfanetTool(step.tool, { ...(step.args || {}), state: options.state });
15534
15945
  current = Array.isArray(result) ? result : [result];
15535
15946
  outputs.push({ tool: step.tool, rows: current.length });
15536
15947
  } else if (USER_SKILL_TOOLS.includes(step.tool)) {
@@ -15688,6 +16099,9 @@ function formatToolResult(result, options) {
15688
16099
  return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
15689
16100
  }
15690
16101
  if (row.date && row.time) return `Сегодня ${row.date}, ${row.time}.`;
16102
+ if (row.type === "select" && Array.isArray(row.choices)) return formatUfanetChoicePrompt(row.choices);
16103
+ if (row.type === "empty") return "Уфанет подключен, но доступных домофонов не найдено.";
16104
+ if (row.type === "opened" && row.result) return formatUfanetOpenResult(row.result, row.choice || {});
15691
16105
  if (row.provider === "ufanet" && (row.status === "opened" || row.status === "not-opened")) return `Уфанет: домофон #${row.id} ${row.status === "opened" ? "открыт" : "не открылся"}.`;
15692
16106
  if (row.provider === "ufanet" && row.uuid && (row.url || row.preview)) return `Уфанет: запись звонка ${row.uuid}\nСсылка: ${row.url || "-"}\nПревью: ${row.preview || "-"}`;
15693
16107
  if (row.rtspUrl) return `Камера Уфанет ${row.title || row.number}: ${row.address || "-"}\nRTSP: ${row.rtspUrl}`;
@@ -17316,7 +17730,7 @@ function parseOptions(args) {
17316
17730
  } else if (arg === "--check" || arg === "--upgrade-node") {
17317
17731
  result.check = true;
17318
17732
  result[arg.slice(2)] = true;
17319
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--instructions" || arg === "--allowed-tools" || arg === "--tool" || arg === "--uses" || arg === "--template" || arg === "--minutes" || arg === "--days" || arg === "--time" || arg === "--horizon" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-url" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address" || arg === "--token" || arg === "--app" || arg === "--tariff" || arg === "--class" || arg === "--level" || arg === "--ref" || arg === "--lang" || arg === "--id" || arg === "--uuid" || arg === "--intercom" || arg === "--page-size") {
17733
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--instructions" || arg === "--allowed-tools" || arg === "--tool" || arg === "--uses" || arg === "--template" || arg === "--minutes" || arg === "--days" || arg === "--time" || arg === "--horizon" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-url" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address" || arg === "--token" || arg === "--app" || arg === "--tariff" || arg === "--class" || arg === "--level" || arg === "--ref" || arg === "--lang" || arg === "--id" || arg === "--uuid" || arg === "--intercom" || arg === "--page-size" || arg === "--seconds" || arg === "--interval") {
17320
17734
  result[arg.slice(2)] = args[index + 1];
17321
17735
  index += 1;
17322
17736
  } else {
@@ -10,7 +10,7 @@ Skills не подмешиваются в каждый запрос целико
10
10
  - `browser-agent` - когда запрос связан с сайтом, URL, страницей, скриншотом или браузером;
11
11
  - `local-model` - инструкции для локальных компактных моделей и tool-планирования;
12
12
  - `yandex-services` - когда запрос связан с Yandex Connector или Yandex Cloud Connector: Яндекс Диск, Почта, Календарь, Контакты, Телемост, Yandex ID, геокодер, YandexGPT или Яндекс Go deeplink;
13
- - `ufanet-intercom` - когда запрос связан с домофоном Уфанет: открыть домофон, история звонков, камеры, записи;
13
+ - `ufanet-intercom` - когда запрос связан с домофоном Уфанет: открыть домофон, история звонков, уведомления о вызовах, камеры, записи;
14
14
  - `user-skills` - когда пользователь просит создать, включить, выключить или удалить собственный skill.
15
15
 
16
16
  Обычный диалог вроде `привет` не получает инструкции про слои, отчеты, файлы и браузер.
@@ -103,4 +103,6 @@ Toolset `ufanet` включает tools для личного домофона
103
103
  - `ufanet_call_links` - ссылка на запись звонка по UUID;
104
104
  - `ufanet_cameras` - камеры и RTSP-ссылки.
105
105
 
106
+ Уведомления о новых вызовах доступны командой `iola ufanet watch`; включение/выключение настройки - `iola ufanet notifications on|off`.
107
+
106
108
  Открытие домофона требует явного подтверждения `confirm=true`. Договор, пароль и JWT хранятся локально и не выводятся в ответах.
@@ -94,10 +94,14 @@ iola yandex token delete
94
94
  iola ufanet setup
95
95
  iola ufanet status
96
96
  iola ufanet intercoms
97
+ iola ufanet open
97
98
  iola ufanet open ID
98
99
  iola ufanet history --limit 10
99
100
  iola ufanet links UUID
100
101
  iola ufanet cameras
102
+ iola ufanet watch --seconds 10
103
+ iola ufanet notifications on
104
+ iola ufanet notifications off
101
105
  iola ufanet delete
102
106
  iola dom_ru
103
107
  iola rostelecom
@@ -13,7 +13,12 @@ iola ufanet intercoms
13
13
  iola ufanet history
14
14
  iola ufanet cameras
15
15
  iola ufanet links UUID
16
+ iola ufanet open
16
17
  iola ufanet open ID
18
+ iola ufanet watch
19
+ iola ufanet notifications on
20
+ iola ufanet notifications off
21
+ iola ufanet notifications status
17
22
  iola ufanet delete
18
23
  ```
19
24
 
@@ -38,14 +43,26 @@ UFANET_PASSWORD
38
43
  Доступные сценарии:
39
44
 
40
45
  - показать доступные домофоны;
46
+ - открыть единственный домофон простой фразой `открой домофон`;
47
+ - если домофонов несколько, показать адреса с цифрами и открыть выбранный номер;
41
48
  - открыть домофон по ID;
42
49
  - показать историю звонков;
43
50
  - получить ссылку на запись звонка по UUID;
44
51
  - показать камеры;
45
52
  - показать RTSP-ссылки камер;
53
+ - получать уведомления о новых вызовах, пока CLI открыт;
54
+ - включить или выключить настройку уведомлений;
46
55
  - удалить локальное подключение.
47
56
 
48
- Открытие двери всегда требует явного подтверждения пользователя.
57
+ Открытие двери всегда требует явного действия пользователя. Если домофон один, фраза `открой домофон` считается таким действием. Если домофонов несколько, CLI выводит адреса с цифрами, а выбранная цифра считается подтверждением.
58
+
59
+ Уведомления сейчас работают через периодическую проверку истории звонков Уфанета:
60
+
61
+ ```bash
62
+ iola ufanet watch --seconds 10
63
+ ```
64
+
65
+ Остановка режима наблюдения: `Ctrl+C`. Команда `notifications on` сохраняет настройку. В обычном интерактивном CLI при включенных уведомлениях новый вызов выводится с вопросом `Открыть?`; ответы `да`, `да открой`, `открой`, `ок` открывают сопоставленный домофон, `нет` или `отмена` отменяют.
49
66
 
50
67
  ## Дом.ру
51
68
 
@@ -429,6 +429,9 @@ iola ufanet intercoms
429
429
  iola ufanet open ID
430
430
  iola ufanet history
431
431
  iola ufanet cameras
432
+ iola ufanet watch
433
+ iola ufanet notifications on
434
+ iola ufanet notifications off
432
435
  ```
433
436
 
434
437
  ### Дом.ру и Ростелеком