@iola_adm/iola-cli 0.2.47 → 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,6 +212,7 @@ 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
@@ -222,7 +223,7 @@ iola dom_ru
222
223
  iola rostelecom
223
224
  ```
224
225
 
225
- Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры/RTSP и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
226
+ Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры/RTSP и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Команда `открой домофон` открывает единственный домофон сразу; если домофонов несколько, CLI просит выбрать адрес цифрой. При уведомлении о вызове можно ответить `да`, `да открой` или `нет`. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
226
227
 
227
228
  Инструкция: [Мой домофон](https://github.com/adm-iola/iola-cli/wiki/Мой-домофон).
228
229
 
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.48",
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,7 +1282,8 @@ 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
+ const pendingAnswer = await handlePendingAgentAction(line, state);
1286
+ const answer = pendingAnswer || await aiAsk(state.rawMode ? [line, "--quiet"] : [line], { history: state.history, state });
1284
1287
  state.history.push({ role: "user", content: line });
1285
1288
  state.history.push({ role: "assistant", content: answer });
1286
1289
  if (state.rawMode) state.pendingOutput = answer;
@@ -1600,6 +1603,21 @@ async function handleAgentLine(line, state) {
1600
1603
  return false;
1601
1604
  }
1602
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
+
1603
1621
  const mapped = {
1604
1622
  health: ["health", args],
1605
1623
  doctor: ["doctor", args],
@@ -1615,10 +1633,6 @@ async function handleAgentLine(line, state) {
1615
1633
  files: ["files", args],
1616
1634
  archive: ["archive", args],
1617
1635
  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
1636
  changes: ["changes", args],
1623
1637
  index: ["index", args],
1624
1638
  reports: ["reports", args],
@@ -1901,6 +1915,131 @@ function flushPendingAgentOutput(state) {
1901
1915
  printAiAnswer(text);
1902
1916
  }
1903
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
+
1904
2043
  function colorSlashSelection(row) {
1905
2044
  if (!output.isTTY || process.env.NO_COLOR === "1") return row;
1906
2045
  return `\x1b[38;5;213m${row}\x1b[0m`;
@@ -3571,7 +3710,7 @@ async function handleRostelecom() {
3571
3710
  console.log("Пока доступна заготовка пункта в городских сервисах. API/авторизация будут добавлены после исследования провайдера.");
3572
3711
  }
3573
3712
 
3574
- async function handleUfanet(args = []) {
3713
+ async function handleUfanet(args = [], agentState = null) {
3575
3714
  const [action = process.stdin.isTTY ? "menu" : "status", target, ...rest] = args;
3576
3715
  const options = parseOptions(rest);
3577
3716
 
@@ -3598,7 +3737,11 @@ async function handleUfanet(args = []) {
3598
3737
 
3599
3738
  if (action === "open") {
3600
3739
  const intercomId = target || options.id || options.intercom;
3601
- 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
+ }
3602
3745
  const ok = options.yes || options.confirm || await confirm(`Открыть домофон Уфанет #${intercomId}? [y/N] `);
3603
3746
  if (!ok) {
3604
3747
  console.log("Открытие отменено.");
@@ -3917,7 +4060,9 @@ function ufanetCallKey(row = {}) {
3917
4060
  async function executeUfanetTool(tool, args = {}) {
3918
4061
  if (tool === "ufanet_status") return getUfanetStatus();
3919
4062
  if (tool === "ufanet_intercoms") return ufanetGetIntercoms();
3920
- 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 });
3921
4066
  if (tool === "ufanet_call_history") return ufanetGetCallHistory({ page: args.page || 1, pageSize: args.pageSize || args.page_size || args.limit || 10 });
3922
4067
  if (tool === "ufanet_call_links") return ufanetGetCallLinks(args.uuid || args.id);
3923
4068
  if (tool === "ufanet_cameras") return ufanetGetCameras();
@@ -4011,6 +4156,59 @@ async function ufanetOpenIntercom(intercomId, options = {}) {
4011
4156
  return { provider: "ufanet", status: payload?.result ? "opened" : "not-opened", id: Number(intercomId), result: Boolean(payload?.result) };
4012
4157
  }
4013
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
+
4014
4212
  async function ufanetGetCallHistory(options = {}) {
4015
4213
  const page = Math.max(1, Number(options.page || 1));
4016
4214
  const pageSize = Math.max(1, Math.min(100, Number(options.pageSize || 10)));
@@ -12915,6 +13113,7 @@ async function setupIolaLocal(args) {
12915
13113
 
12916
13114
  async function aiAsk(args, context = {}) {
12917
13115
  const options = parseOptions(args);
13116
+ if (context.state) options.state = context.state;
12918
13117
  const question = options._.join(" ").trim();
12919
13118
 
12920
13119
  if (!question) {
@@ -12964,6 +13163,20 @@ async function aiAsk(args, context = {}) {
12964
13163
  if (!options.quiet) console.log(userSkillAnswer);
12965
13164
  return userSkillAnswer;
12966
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
+ }
12967
13180
  if (/(контакт|адресн)/iu.test(question) && !isExplicitYandexDiskPathDelete(question)) {
12968
13181
  const yandexContactAnswer = await buildYandexDirectAnswer(question, context.history || history);
12969
13182
  if (yandexContactAnswer) {
@@ -14561,6 +14774,48 @@ function cleanupCloudSaveText(question) {
14561
14774
  .trim();
14562
14775
  }
14563
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
+
14564
14819
  function detectDirectDataFields(normalizedQuestion) {
14565
14820
  const fields = [];
14566
14821
  if (/(директ|руководител|заведующ|кто возглавляет)/iu.test(normalizedQuestion)) fields.push("head");
@@ -15179,8 +15434,7 @@ function inferToolPlan(question, options = {}) {
15179
15434
  if (/(ростелеком|rostelecom)/iu.test(normalized)) return { directAnswer: "Мой домофон Ростелеком пока в разработке. Сейчас реализован Уфанет: /ufanet." };
15180
15435
  if (/(открой|открыть|пусти|впусти|двер)/iu.test(normalized)) {
15181
15436
  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 } }] };
15437
+ return { steps: [{ tool: "ufanet_open_intercom", args: { ...(id ? { id } : {}), confirm: true } }] };
15184
15438
  }
15185
15439
  if (/(истори|звонк|кто\s+звонил|последн)/iu.test(normalized) && !/(ссылк|запис|видео)/iu.test(normalized)) {
15186
15440
  return { steps: [{ tool: "ufanet_call_history", args: { limit: 10 } }] };
@@ -15687,7 +15941,7 @@ async function executeToolPlan(plan, options = {}) {
15687
15941
  outputs.push({ tool: step.tool, rows: current.length });
15688
15942
  } else if (UFANET_TOOLS.includes(step.tool)) {
15689
15943
  await assertPermission("externalApi");
15690
- const result = await executeUfanetTool(step.tool, step.args || {});
15944
+ const result = await executeUfanetTool(step.tool, { ...(step.args || {}), state: options.state });
15691
15945
  current = Array.isArray(result) ? result : [result];
15692
15946
  outputs.push({ tool: step.tool, rows: current.length });
15693
15947
  } else if (USER_SKILL_TOOLS.includes(step.tool)) {
@@ -15845,6 +16099,9 @@ function formatToolResult(result, options) {
15845
16099
  return `${name}: ${row.field} = ${row.value ?? "не указано"}`;
15846
16100
  }
15847
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 || {});
15848
16105
  if (row.provider === "ufanet" && (row.status === "opened" || row.status === "not-opened")) return `Уфанет: домофон #${row.id} ${row.status === "opened" ? "открыт" : "не открылся"}.`;
15849
16106
  if (row.provider === "ufanet" && row.uuid && (row.url || row.preview)) return `Уфанет: запись звонка ${row.uuid}\nСсылка: ${row.url || "-"}\nПревью: ${row.preview || "-"}`;
15850
16107
  if (row.rtspUrl) return `Камера Уфанет ${row.title || row.number}: ${row.address || "-"}\nRTSP: ${row.rtspUrl}`;
@@ -94,6 +94,7 @@ 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
@@ -13,6 +13,7 @@ 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
17
18
  iola ufanet watch
18
19
  iola ufanet notifications on
@@ -42,6 +43,8 @@ UFANET_PASSWORD
42
43
  Доступные сценарии:
43
44
 
44
45
  - показать доступные домофоны;
46
+ - открыть единственный домофон простой фразой `открой домофон`;
47
+ - если домофонов несколько, показать адреса с цифрами и открыть выбранный номер;
45
48
  - открыть домофон по ID;
46
49
  - показать историю звонков;
47
50
  - получить ссылку на запись звонка по UUID;
@@ -51,7 +54,7 @@ UFANET_PASSWORD
51
54
  - включить или выключить настройку уведомлений;
52
55
  - удалить локальное подключение.
53
56
 
54
- Открытие двери всегда требует явного подтверждения пользователя.
57
+ Открытие двери всегда требует явного действия пользователя. Если домофон один, фраза `открой домофон` считается таким действием. Если домофонов несколько, CLI выводит адреса с цифрами, а выбранная цифра считается подтверждением.
55
58
 
56
59
  Уведомления сейчас работают через периодическую проверку истории звонков Уфанета:
57
60
 
@@ -59,7 +62,7 @@ UFANET_PASSWORD
59
62
  iola ufanet watch --seconds 10
60
63
  ```
61
64
 
62
- Остановка режима наблюдения: `Ctrl+C`. Команда `notifications on` сохраняет настройку, а `watch` запускает получение событий в текущей CLI-сессии.
65
+ Остановка режима наблюдения: `Ctrl+C`. Команда `notifications on` сохраняет настройку. В обычном интерактивном CLI при включенных уведомлениях новый вызов выводится с вопросом `Открыть?`; ответы `да`, `да открой`, `открой`, `ок` открывают сопоставленный домофон, `нет` или `отмена` отменяют.
63
66
 
64
67
  ## Дом.ру
65
68