@iola_adm/iola-cli 0.1.59 → 0.1.61

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
@@ -86,6 +86,10 @@ iola review config
86
86
  iola browser status
87
87
  iola gosuslugi status
88
88
  iola gosuslugi connect
89
+ iola gosuslugi whoami
90
+ iola gosuslugi debt
91
+ iola gosuslugi notifications --unread
92
+ iola gosuslugi keepalive
89
93
  ```
90
94
 
91
95
  Локальная модель через Ollama:
@@ -134,6 +138,8 @@ iola version --check
134
138
  - дополнительные stdio MCP-серверы можно добавить в `~/.iola/config.json` в раздел `mcp.servers`;
135
139
  - браузерный runtime через Playwright: чтение страниц, скриншоты, PDF, клики, ввод и eval;
136
140
  - личное локальное подключение Госуслуг через отдельный браузерный профиль на ПК пользователя;
141
+ - read-only tools Госуслуг для агента: ФИО, дата рождения, задолженности и уведомления;
142
+ - keepalive-проверка сессии Госуслуг каждые 30 минут без обхода 2FA;
137
143
  - управляемые локальные файловые операции с режимами `locked`, `read-only`, `workspace-write`, `full-access`;
138
144
  - планы выполнения, traces, tasks, artifacts, snapshots и policy-профили;
139
145
  - экспорт отчетов в Excel/Word-совместимые файлы;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: gosuslugi
3
+ description: Работа с личным аккаунтом Госуслуг пользователя через локальный браузерный профиль.
4
+ ---
5
+
6
+ Используй этот skill только когда пользователь явно спрашивает про свой личный аккаунт Госуслуг: ФИО, дату рождения, задолженности, штрафы, налоги, платежи, уведомления или госпочту.
7
+
8
+ Доступные read-only tools:
9
+
10
+ - `gosuslugi_whoami` - получить ФИО, дату рождения и краткие данные профиля.
11
+ - `gosuslugi_debt` - получить задолженности и суммы к оплате.
12
+ - `gosuslugi_notifications` - получить уведомления, включая новые непрочитанные.
13
+
14
+ Не выполняй юридически значимые действия автоматически. Оплата, отправка заявлений, изменение персональных данных, удаление учетной записи, согласия и отметка уведомлений прочитанными требуют отдельного явного подтверждения пользователя.
15
+
16
+ Если сохраненная сессия Госуслуг протухла или требуется повторная двухфакторная аутентификация, попроси пользователя выполнить `iola gosuslugi connect`.
package/src/cli.js CHANGED
@@ -27,9 +27,10 @@ const LOCAL_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "local.json");
27
27
  const BROWSER_RUNTIME_DIR = path.join(CONFIG_DIR, "browser-runtime");
28
28
  const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "playwright", "package.json");
29
29
  const GOSUSLUGI_BROWSER_PROFILE_DIR = path.join(CONFIG_DIR, "gosuslugi-browser-profile");
30
+ const GOSUSLUGI_BROWSER_LOCK_DIR = path.join(CONFIG_DIR, "gosuslugi-browser-profile.lock");
30
31
  const GOSUSLUGI_DEFAULT_URL = "https://www.gosuslugi.ru/";
31
32
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
32
- const LOCAL_TOOLS = ["search_data", "get_card", "export_report", "file_read", "browser_open"];
33
+ const LOCAL_TOOLS = ["search_data", "get_card", "export_report", "file_read", "browser_open", "gosuslugi_whoami", "gosuslugi_debt", "gosuslugi_notifications"];
33
34
  const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
34
35
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
35
36
  const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
@@ -180,6 +181,9 @@ const DEFAULT_AI_CONFIG = {
180
181
  export_report: true,
181
182
  file_read: false,
182
183
  browser_open: true,
184
+ gosuslugi_whoami: true,
185
+ gosuslugi_debt: true,
186
+ gosuslugi_notifications: true,
183
187
  files_tree: false,
184
188
  files_read: false,
185
189
  files_search: false,
@@ -210,7 +214,7 @@ const DEFAULT_AI_CONFIG = {
210
214
  suggestions: true,
211
215
  },
212
216
  skills: {
213
- enabled: ["open-data", "reports", "local-model", "local-files", "browser-agent"],
217
+ enabled: ["open-data", "reports", "local-model", "local-files", "browser-agent", "gosuslugi"],
214
218
  },
215
219
  daemon: {
216
220
  host: "127.0.0.1",
@@ -284,6 +288,9 @@ const SLASH_COMMANDS = [
284
288
  { command: "/features list", description: "feature flags" },
285
289
  { command: "/gosuslugi status", description: "личное подключение Госуслуг" },
286
290
  { command: "/gosuslugi connect", description: "открыть личный вход Госуслуг" },
291
+ { command: "/gosuslugi debt", description: "задолженности Госуслуг" },
292
+ { command: "/gosuslugi notifications", description: "уведомления Госуслуг" },
293
+ { command: "/gosuslugi keepalive", description: "проверка сессии каждые 30 минут" },
287
294
  { command: "/wiki", description: "ссылки на документацию" },
288
295
  { command: "/context list", description: "локальный контекст проекта" },
289
296
  { command: "/skills list", description: "skills" },
@@ -517,7 +524,7 @@ Usage:
517
524
  iola fork SESSION_ID [TEXT]
518
525
  iola features list|enable|disable
519
526
  iola settings list|get|validate|doctor|init
520
- iola gosuslugi terms|consent|status|connect|open|text|screenshot|logout|configure|login|userinfo
527
+ iola gosuslugi terms|consent|status|check|keepalive|install-keepalive|connect|open|text|screenshot|whoami|debt|notifications|mark-read|logout|configure|login|userinfo
521
528
  iola wiki [open|links]
522
529
  iola context list|show|init
523
530
  iola skills list|show|paths|enable|disable|bundles|bundle|doctor
@@ -2019,6 +2026,25 @@ async function handleDb(args) {
2019
2026
  return;
2020
2027
  }
2021
2028
 
2029
+ if (action === "check") {
2030
+ const result = await gosuslugiCheck(options);
2031
+ if (options.json) printJson(result);
2032
+ else printKeyValue(result);
2033
+ return;
2034
+ }
2035
+
2036
+ if (action === "keepalive") {
2037
+ await gosuslugiKeepalive(options);
2038
+ return;
2039
+ }
2040
+
2041
+ if (action === "install-keepalive") {
2042
+ const id = addCronJob("каждые 30 минут", "gosuslugi check --silent");
2043
+ console.log(`Keepalive-задача добавлена: ${id}`);
2044
+ console.log("Для выполнения запускайте периодически: iola cron tick");
2045
+ return;
2046
+ }
2047
+
2022
2048
  if (action === "reset") {
2023
2049
  const shouldReset = await confirm("Удалить локальную SQLite-БД iola.db? [y/N] ");
2024
2050
  if (!shouldReset) {
@@ -2280,6 +2306,25 @@ async function handleGosuslugi(args) {
2280
2306
  return;
2281
2307
  }
2282
2308
 
2309
+ if (action === "check") {
2310
+ const result = await gosuslugiCheck(options);
2311
+ if (options.json) printJson(result);
2312
+ else printKeyValue(result);
2313
+ return;
2314
+ }
2315
+
2316
+ if (action === "keepalive") {
2317
+ await gosuslugiKeepalive(options);
2318
+ return;
2319
+ }
2320
+
2321
+ if (action === "install-keepalive") {
2322
+ const id = addCronJob("каждые 30 минут", "gosuslugi check --silent");
2323
+ console.log(`Keepalive-задача добавлена: ${id}`);
2324
+ console.log("Для выполнения запускайте периодически: iola cron tick");
2325
+ return;
2326
+ }
2327
+
2283
2328
  if (action === "connect") {
2284
2329
  await gosuslugiBrowserConnect(options);
2285
2330
  return;
@@ -2309,6 +2354,32 @@ async function handleGosuslugi(args) {
2309
2354
  return;
2310
2355
  }
2311
2356
 
2357
+ if (action === "whoami" || action === "profile") {
2358
+ const result = await gosuslugiWhoami(options);
2359
+ if (options.json) printJson(result);
2360
+ else printKeyValue(result.summary);
2361
+ return;
2362
+ }
2363
+
2364
+ if (action === "debt" || action === "debts" || action === "payments") {
2365
+ const result = await gosuslugiDebt(options);
2366
+ if (options.json) printJson(result);
2367
+ else printGosuslugiDebt(result);
2368
+ return;
2369
+ }
2370
+
2371
+ if (action === "notifications" || action === "notices") {
2372
+ const result = await gosuslugiNotifications(options);
2373
+ if (options.json) printJson(result);
2374
+ else printGosuslugiNotifications(result);
2375
+ return;
2376
+ }
2377
+
2378
+ if (action === "mark-read") {
2379
+ await gosuslugiMarkNotificationsRead(options);
2380
+ return;
2381
+ }
2382
+
2312
2383
  if (action === "configure") {
2313
2384
  const current = await loadConfig();
2314
2385
  const next = {
@@ -2357,7 +2428,7 @@ async function handleGosuslugi(args) {
2357
2428
  return;
2358
2429
  }
2359
2430
 
2360
- throw new Error("Команды gosuslugi: terms, consent, status, connect, open, text, screenshot, logout, configure, login, userinfo.");
2431
+ throw new Error("Команды gosuslugi: terms, consent, status, check, keepalive, install-keepalive, connect, open, text, screenshot, whoami, debt, notifications, mark-read, logout, configure, login, userinfo.");
2361
2432
  }
2362
2433
 
2363
2434
  function targetOrDefault(args, options = {}) {
@@ -5743,6 +5814,10 @@ function isCronDue(job) {
5743
5814
  if (normalized.includes("каждый час") || normalized.includes("hourly")) {
5744
5815
  return !lastRun || now.getTime() - lastRun.getTime() >= 60 * 60 * 1000;
5745
5816
  }
5817
+ const everyMinutes = normalized.match(/кажд(?:ые|ую)\s+(\d+)\s*(?:мин|минут)/u) || normalized.match(/every\s+(\d+)\s*(?:m|min|minutes)/u);
5818
+ if (everyMinutes) {
5819
+ return !lastRun || now.getTime() - lastRun.getTime() >= Number(everyMinutes[1]) * 60 * 1000;
5820
+ }
5746
5821
  if (normalized.includes("каждый день") || normalized.includes("daily")) {
5747
5822
  return !lastRun || now.toISOString().slice(0, 10) !== lastRun.toISOString().slice(0, 10);
5748
5823
  }
@@ -6010,6 +6085,12 @@ async function aiAsk(args, context = {}) {
6010
6085
  throw new Error('Текст вопроса обязателен. Пример: iola ai ask "Какие школы есть на улице Петрова?"');
6011
6086
  }
6012
6087
 
6088
+ if (!options.bare && isGosuslugiPersonalIntent(question)) {
6089
+ const answer = await answerGosuslugiQuestion(question, options);
6090
+ if (!options.quiet) console.log(answer);
6091
+ return answer;
6092
+ }
6093
+
6013
6094
  const config = await loadConfig();
6014
6095
  const providerConfig = await resolveUsableAiProfile(config, options);
6015
6096
  if (providerConfig.provider === "codex") await assertPermission("codex");
@@ -6178,7 +6259,7 @@ async function buildLocalToolPlan(question, providerConfig, options) {
6178
6259
  "Ты планировщик CLI iola. Верни только JSON.",
6179
6260
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
6180
6261
  "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
6181
- "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
6262
+ "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}, gosuslugi_whoami {}, gosuslugi_debt {}, gosuslugi_notifications {unread,limit}.",
6182
6263
  "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
6183
6264
  "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
6184
6265
  `Вопрос: ${question}`,
@@ -6208,6 +6289,12 @@ function inferToolPlan(question, options = {}) {
6208
6289
  const steps = [];
6209
6290
  if (normalized.includes("без телефона")) {
6210
6291
  steps.push({ tool: "export_report", args: { name: "missing-phones" } });
6292
+ } else if (/(уведомлен|сообщени|госпочт|непрочитан)/iu.test(normalized)) {
6293
+ steps.push({ tool: "gosuslugi_notifications", args: { unread: /непрочитан|нов/iu.test(normalized), limit: 15 } });
6294
+ } else if (/(задолж|долг|штраф|налог|к оплате|платеж|платёж)/iu.test(normalized)) {
6295
+ steps.push({ tool: "gosuslugi_debt", args: {} });
6296
+ } else if (/(фио|дата рождения|профиль|кто я)/iu.test(normalized) && /госуслуг/iu.test(normalized)) {
6297
+ steps.push({ tool: "gosuslugi_whoami", args: {} });
6211
6298
  } else {
6212
6299
  const query = normalized.match(/петрова|школ[а-яё ]*\d+|сад[а-яё ]*\d+|лицей[а-яё ]*\d+/iu)?.[0] || question;
6213
6300
  steps.push({ tool: "search_data", args: { dataset, query, limit: 20 } });
@@ -6297,6 +6384,18 @@ async function executeToolPlan(plan, options = {}) {
6297
6384
  const text = await runBrowserAutomation("text", { url: step.args?.url, waitMs: Number(step.args?.waitMs || 0), timeout: Number(step.args?.timeout || 30000), viewport: step.args?.viewport || "1366x768" });
6298
6385
  current = [{ url: step.args?.url, text }];
6299
6386
  outputs.push({ tool: step.tool, rows: 1 });
6387
+ } else if (step.tool === "gosuslugi_whoami") {
6388
+ const result = await gosuslugiWhoami(step.args || {});
6389
+ current = [result.summary];
6390
+ outputs.push({ tool: step.tool, rows: 1 });
6391
+ } else if (step.tool === "gosuslugi_debt") {
6392
+ const result = await gosuslugiDebt(step.args || {});
6393
+ current = [{ total: result.total, amount: result.amount, debts: result.debts }];
6394
+ outputs.push({ tool: step.tool, rows: result.debts.length });
6395
+ } else if (step.tool === "gosuslugi_notifications") {
6396
+ const result = await gosuslugiNotifications(step.args || {});
6397
+ current = [{ total: result.total, unread: result.unread, items: result.items }];
6398
+ outputs.push({ tool: step.tool, rows: result.items.length });
6300
6399
  } else if (String(step.tool || "").startsWith("mcp:")) {
6301
6400
  const result = await callConfiguredMcpTool(step.tool, step.args || {});
6302
6401
  current = Array.isArray(result) ? result : [result];
@@ -7094,7 +7193,7 @@ function parseOptions(args) {
7094
7193
 
7095
7194
  for (let index = 0; index < args.length; index += 1) {
7096
7195
  const arg = args[index];
7097
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
7196
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--full" || arg === "--unread" || arg === "--once" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
7098
7197
  result[arg.slice(2)] = true;
7099
7198
  } else if (arg === "--check" || arg === "--upgrade-node") {
7100
7199
  result.check = true;
@@ -7335,7 +7434,8 @@ async function buildSkillsText(config, question = "", options = {}) {
7335
7434
  const chunks = [];
7336
7435
  const selected = selectSkillsForPrompt(config, question, options);
7337
7436
  for (const skill of listSkills(config)) {
7338
- if (!skill.enabled || !selected.has(skill.name)) continue;
7437
+ const active = skill.enabled || (skill.name === "gosuslugi" && config.gosuslugi?.enabled);
7438
+ if (!active || !selected.has(skill.name)) continue;
7339
7439
  const text = await readFile(skill.file, "utf8");
7340
7440
  chunks.push(`## Skill: ${skill.name}\n${stripFrontmatter(text).trim()}`);
7341
7441
  }
@@ -7351,6 +7451,7 @@ function selectSkillsForPrompt(config, question = "", options = {}) {
7351
7451
  if (enabled.has("reports") && /(отчет|отчёт|выгруз|csv|xlsx|качество|провер)/iu.test(normalized)) selected.add("reports");
7352
7452
  if (enabled.has("local-files") && (options.files || /(файл|папк|readme|документ|архив)/iu.test(normalized))) selected.add("local-files");
7353
7453
  if (enabled.has("browser-agent") && /(браузер|сайт|страниц|url|https?:\/\/)/iu.test(normalized)) selected.add("browser-agent");
7454
+ if (enabled.has("gosuslugi") && /(госуслуг|задолж|долг|штраф|налог|к оплате|платеж|платёж|уведомлен|госпочт|фио|дата рождения)/iu.test(normalized)) selected.add("gosuslugi");
7354
7455
  return selected;
7355
7456
  }
7356
7457
 
@@ -7646,9 +7747,281 @@ async function gosuslugiBrowserScreenshot(url = GOSUSLUGI_DEFAULT_URL, outputFil
7646
7747
  });
7647
7748
  }
7648
7749
 
7750
+ async function gosuslugiWhoami(options = {}) {
7751
+ const data = await gosuslugiBrowserApiJson({
7752
+ pageUrl: "https://lk.gosuslugi.ru/settings/account",
7753
+ endpoint: "https://www.gosuslugi.ru/api/lk/v1/users/data",
7754
+ waitMs: Number(options.wait || 3000),
7755
+ });
7756
+ const person = data.person?.person || data.person || data;
7757
+ const summary = {
7758
+ fio: [data.lastName || person.lastName, data.firstName || person.firstName, data.middleName || person.middleName].filter(Boolean).join(" ") || data.formattedName || "-",
7759
+ birthDate: person.birthDate || data.birthDate || "-",
7760
+ status: data.assuranceLevel === "AL20" || person.trusted ? "Подтвержденная учетная запись" : data.assuranceLevel || "-",
7761
+ phone: options.full ? (data.personMobilePhone || data.mobile || "-") : maskPhone(data.personMobilePhone || data.mobile || ""),
7762
+ email: options.full ? (data.personEMail || data.personEmail || data.email || "-") : maskEmail(data.personEMail || data.personEmail || data.email || ""),
7763
+ snils: options.full ? (person.snils || data.personSnils || data.snils || "-") : maskDocument(person.snils || data.personSnils || data.snils || ""),
7764
+ inn: options.full ? (person.inn || data.personINN || data.inn || "-") : maskDocument(person.inn || data.personINN || data.inn || ""),
7765
+ };
7766
+ return {
7767
+ summary,
7768
+ raw: options.full ? redactGosuslugiSensitive(data, { keepPersonal: true }) : undefined,
7769
+ };
7770
+ }
7771
+
7772
+ async function gosuslugiDebt(options = {}) {
7773
+ const data = await gosuslugiBrowserApiJson({
7774
+ pageUrl: "https://www.gosuslugi.ru/pay/forPayment",
7775
+ endpoint: "https://www.gosuslugi.ru/api/pay/v2/informer/fetch",
7776
+ waitMs: Number(options.wait || 5000),
7777
+ });
7778
+ const groups = Array.isArray(data.groups) ? data.groups : [];
7779
+ const debts = groups.flatMap((group) => (group.bills || []).map((bill) => ({
7780
+ group: group.name || group.code || "-",
7781
+ caption: bill.caption || "-",
7782
+ amount: Number(bill.amount || 0),
7783
+ billDate: bill.billDate || "-",
7784
+ supplier: bill.supplierFullName || "-",
7785
+ document: bill.document?.typeName ? `${bill.document.typeName} ${bill.document.number || ""}`.trim() : "-",
7786
+ })));
7787
+ return {
7788
+ total: Number(data.summary?.total || debts.length || 0),
7789
+ amount: Number(data.summary?.amount || debts.reduce((sum, item) => sum + item.amount, 0)),
7790
+ groups: groups.map((group) => ({ name: group.name, code: group.code, total: group.summary?.total || 0, amount: group.summary?.amount || 0 })),
7791
+ debts,
7792
+ };
7793
+ }
7794
+
7795
+ async function gosuslugiNotifications(options = {}) {
7796
+ const types = "ORDER,EQUEUE,PAYMENT,GEPS,BIOMETRICS,ACCOUNT,ACCOUNT_CHILD,PROFILE,APPEAL,CLAIM,ELECTION_INFO,COMPLEX_ORDER,FEEDBACK,ORGANIZATION,BUSINESSMAN,ESIGNATURE,KND_APPEAL,LINKED_ACCOUNT,SIGN,GOSQR,INFO,PERMISSION,LICENSING,LICENSING_APPEAL,CONSTRUCTOR";
7797
+ const pageSize = Number(options.limit || 15);
7798
+ const unread = options.unread ? "true" : "false";
7799
+ const counters = await gosuslugiBrowserApiJson({
7800
+ pageUrl: `https://lk.gosuslugi.ru/notifications?type=${types}`,
7801
+ endpoint: `https://www.gosuslugi.ru/api/lk/v1/feeds/counters?types=${types},PARTNERS&isArchive=false`,
7802
+ waitMs: Number(options.wait || 3000),
7803
+ });
7804
+ const feed = await gosuslugiBrowserApiJson({
7805
+ pageUrl: `https://lk.gosuslugi.ru/notifications?type=${types}`,
7806
+ endpoint: `https://www.gosuslugi.ru/api/lk/v1/feeds/?unread=${unread}&isArchive=false&isHide=false&types=${types}&pageSize=${pageSize}&status=&startDate=&lastFeedId=&lastFeedDate=&q=`,
7807
+ waitMs: Number(options.wait || 3000),
7808
+ });
7809
+ const items = (feed.items || []).map((item) => ({
7810
+ id: item.id,
7811
+ unread: Boolean(item.unread),
7812
+ date: item.date || "-",
7813
+ type: item.feedType || "-",
7814
+ title: item.title || "-",
7815
+ subtitle: item.subTitle || "-",
7816
+ status: item.status || "-",
7817
+ summary: summarizeNotificationData(item.data),
7818
+ }));
7819
+ return {
7820
+ total: counters.total || feed.items?.length || 0,
7821
+ unread: counters.unread || items.filter((item) => item.unread).length,
7822
+ counters: counters.counter || [],
7823
+ hasMore: Boolean(feed.hasMore),
7824
+ items,
7825
+ };
7826
+ }
7827
+
7828
+ async function gosuslugiMarkNotificationsRead(options = {}) {
7829
+ await requireGosuslugiConsent();
7830
+ if (!options.yes) {
7831
+ const ok = await confirm("Отметить уведомления Госуслуг прочитанными? Это изменит состояние личного кабинета. [y/N] ");
7832
+ if (!ok) {
7833
+ console.log("Операция отменена.");
7834
+ return;
7835
+ }
7836
+ }
7837
+ await gosuslugiBrowserClickText({
7838
+ pageUrl: "https://lk.gosuslugi.ru/notifications?type=ORDER,EQUEUE,PAYMENT,GEPS,BIOMETRICS,ACCOUNT,ACCOUNT_CHILD,PROFILE,APPEAL,CLAIM,ELECTION_INFO,COMPLEX_ORDER,FEEDBACK,ORGANIZATION,BUSINESSMAN,ESIGNATURE,KND_APPEAL,LINKED_ACCOUNT,SIGN,GOSQR,INFO,PERMISSION,LICENSING,LICENSING_APPEAL,CONSTRUCTOR",
7839
+ text: "Прочитать все",
7840
+ waitMs: Number(options.wait || 5000),
7841
+ });
7842
+ console.log("Команда отметки прочитанным выполнена. Проверьте статус: iola gosuslugi notifications --unread");
7843
+ }
7844
+
7845
+ async function gosuslugiCheck(options = {}) {
7846
+ try {
7847
+ const result = await gosuslugiWhoami({ wait: options.wait || 2000 });
7848
+ return {
7849
+ status: "ok",
7850
+ authorized: "yes",
7851
+ fio: result.summary.fio,
7852
+ checkedAt: new Date().toISOString(),
7853
+ nextAction: "-",
7854
+ };
7855
+ } catch (error) {
7856
+ const message = error instanceof Error ? error.message : String(error);
7857
+ const result = {
7858
+ status: "needs-login",
7859
+ authorized: "unknown",
7860
+ checkedAt: new Date().toISOString(),
7861
+ nextAction: "iola gosuslugi connect",
7862
+ error: message,
7863
+ };
7864
+ if (!options.silent) {
7865
+ console.error("Сессия Госуслуг недоступна или требует повторный вход.");
7866
+ console.error("Запустите: iola gosuslugi connect");
7867
+ }
7868
+ return result;
7869
+ }
7870
+ }
7871
+
7872
+ async function gosuslugiKeepalive(options = {}) {
7873
+ const intervalMs = parseDurationMs(options.interval || "30m");
7874
+ const once = Boolean(options.once);
7875
+ console.log(`Gosuslugi keepalive запущен. Интервал: ${Math.round(intervalMs / 60000)} мин.`);
7876
+ console.log("Остановить: Ctrl+C");
7877
+ while (true) {
7878
+ const result = await gosuslugiCheck({ silent: true });
7879
+ const line = result.status === "ok"
7880
+ ? `[${result.checkedAt}] Госуслуги: сессия активна (${result.fio || "-"})`
7881
+ : `[${result.checkedAt}] Госуслуги: нужен повторный вход. Запустите: iola gosuslugi connect`;
7882
+ console.log(line);
7883
+ if (once) return;
7884
+ await sleep(intervalMs);
7885
+ }
7886
+ }
7887
+
7888
+ function parseDurationMs(value) {
7889
+ const text = String(value || "30m").trim().toLocaleLowerCase("ru-RU");
7890
+ const match = text.match(/^(\d+(?:[.,]\d+)?)(ms|s|m|h|мин|минут|час|часа|часов)?$/u);
7891
+ if (!match) throw new Error("Интервал задается как 30m, 1800s или 1h.");
7892
+ const amount = Number(match[1].replace(",", "."));
7893
+ const unit = match[2] || "m";
7894
+ if (unit === "ms") return Math.max(1000, amount);
7895
+ if (unit === "s") return Math.max(1000, amount * 1000);
7896
+ if (unit === "h" || unit.startsWith("час")) return Math.max(1000, amount * 60 * 60 * 1000);
7897
+ return Math.max(1000, amount * 60 * 1000);
7898
+ }
7899
+
7900
+ function printGosuslugiDebt(result) {
7901
+ printKeyValue({
7902
+ total: result.total,
7903
+ amount: `${formatRub(result.amount)} Р`,
7904
+ });
7905
+ if (!result.debts.length) {
7906
+ console.log("Задолженности не найдены.");
7907
+ return;
7908
+ }
7909
+ printTable(result.debts.map((item) => ({
7910
+ group: item.group,
7911
+ amount: `${formatRub(item.amount)} Р`,
7912
+ date: item.billDate,
7913
+ caption: item.caption,
7914
+ })), [
7915
+ ["group", "Группа"],
7916
+ ["amount", "Сумма"],
7917
+ ["date", "Дата"],
7918
+ ["caption", "Описание"],
7919
+ ]);
7920
+ }
7921
+
7922
+ function printGosuslugiNotifications(result) {
7923
+ printKeyValue({ total: result.total, unread: result.unread, hasMore: result.hasMore ? "yes" : "no" });
7924
+ printTable(result.items.map((item) => ({
7925
+ unread: item.unread ? "new" : "read",
7926
+ date: item.date,
7927
+ type: item.type,
7928
+ title: item.title,
7929
+ subtitle: item.subtitle,
7930
+ summary: item.summary,
7931
+ })), [
7932
+ ["unread", "Статус"],
7933
+ ["date", "Дата"],
7934
+ ["type", "Тип"],
7935
+ ["title", "Заголовок"],
7936
+ ["subtitle", "Подзаголовок"],
7937
+ ["summary", "Детали"],
7938
+ ]);
7939
+ }
7940
+
7941
+ function summarizeNotificationData(data) {
7942
+ if (!data || typeof data !== "object") return "";
7943
+ const snippets = Array.isArray(data.snippets) ? data.snippets : [];
7944
+ if (snippets.length) {
7945
+ const first = snippets[0];
7946
+ return [first.orgName, first.address, first.date].filter(Boolean).join(" | ");
7947
+ }
7948
+ return [data.messageType, data.messageUuid, data.orderId, data.passCodeEpguCode].filter(Boolean).join(" | ");
7949
+ }
7950
+
7951
+ function formatRub(value) {
7952
+ return Number(value || 0).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
7953
+ }
7954
+
7955
+ function isGosuslugiPersonalIntent(question) {
7956
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
7957
+ return /(госуслуг|задолж|долг|штраф|налог|к оплате|платеж|платёж|уведомлен|госпочт|фио|дата рождения)/iu.test(normalized);
7958
+ }
7959
+
7960
+ async function answerGosuslugiQuestion(question, options = {}) {
7961
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
7962
+ if (/(уведомлен|сообщени|госпочт|непрочитан)/iu.test(normalized)) {
7963
+ const result = await gosuslugiNotifications({ unread: /непрочитан|нов/iu.test(normalized), limit: options.limit || 10 });
7964
+ const lines = [`На Госуслугах: всего уведомлений ${result.total}, непрочитанных ${result.unread}.`];
7965
+ const items = result.items.slice(0, Number(options.limit || 5));
7966
+ if (items.length) {
7967
+ lines.push("");
7968
+ for (const item of items) {
7969
+ lines.push(`- ${item.unread ? "новое" : "прочитано"}: ${item.title} — ${item.subtitle} (${item.date})`);
7970
+ }
7971
+ }
7972
+ return lines.join("\n");
7973
+ }
7974
+ if (/(задолж|долг|штраф|налог|к оплате|платеж|платёж)/iu.test(normalized)) {
7975
+ const result = await gosuslugiDebt(options);
7976
+ if (!result.debts.length) return "На Госуслугах задолженности к оплате не найдены.";
7977
+ const lines = [`На Госуслугах найдено задолженностей: ${result.total}. Общая сумма: ${formatRub(result.amount)} Р.`];
7978
+ for (const item of result.debts) {
7979
+ lines.push(`- ${item.group}: ${formatRub(item.amount)} Р — ${item.caption}`);
7980
+ }
7981
+ return lines.join("\n");
7982
+ }
7983
+ const result = await gosuslugiWhoami(options);
7984
+ return [
7985
+ `ФИО: ${result.summary.fio}`,
7986
+ `Дата рождения: ${result.summary.birthDate}`,
7987
+ `Статус: ${result.summary.status}`,
7988
+ ].join("\n");
7989
+ }
7990
+
7991
+ function maskPhone(value) {
7992
+ const text = String(value || "");
7993
+ return text.replace(/(\+?\d)([\d\s()-]{4,})(\d{2})$/u, "$1***$3") || "-";
7994
+ }
7995
+
7996
+ function maskEmail(value) {
7997
+ const text = String(value || "");
7998
+ const [name, domain] = text.split("@");
7999
+ if (!name || !domain) return text || "-";
8000
+ return `${name.slice(0, 2)}***@${domain}`;
8001
+ }
8002
+
8003
+ function maskDocument(value) {
8004
+ const digits = String(value || "").replace(/\D+/g, "");
8005
+ if (!digits) return "-";
8006
+ return `***${digits.slice(-4)}`;
8007
+ }
8008
+
8009
+ function redactGosuslugiSensitive(value, options = {}) {
8010
+ if (Array.isArray(value)) return value.map((item) => redactGosuslugiSensitive(item, options));
8011
+ if (!value || typeof value !== "object") return value;
8012
+ const result = {};
8013
+ for (const [key, item] of Object.entries(value)) {
8014
+ if (/token|cookie|session|password|secret|jwt|auth/i.test(key)) result[key] = "[redacted]";
8015
+ else if (!options.keepPersonal && /(snils|inn|passport|number|series|address|mobile|email|phone)/i.test(key)) result[key] = "[redacted]";
8016
+ else result[key] = redactGosuslugiSensitive(item, options);
8017
+ }
8018
+ return result;
8019
+ }
8020
+
7649
8021
  async function runPersistentBrowserAutomation(action, params) {
7650
8022
  await ensureBrowserRuntime();
7651
8023
  await mkdir(params.userDataDir, { recursive: true });
8024
+ const releaseLock = params.userDataDir === GOSUSLUGI_BROWSER_PROFILE_DIR ? await acquireDirectoryLock(GOSUSLUGI_BROWSER_LOCK_DIR, 180000) : async () => {};
7652
8025
  const scriptFile = path.join(BROWSER_RUNTIME_DIR, `iola-browser-profile-${Date.now()}-${Math.random().toString(16).slice(2)}.mjs`);
7653
8026
  await writeFile(scriptFile, persistentBrowserAutomationScript(action, params), "utf8");
7654
8027
  try {
@@ -7657,9 +8030,57 @@ async function runPersistentBrowserAutomation(action, params) {
7657
8030
  return result.stdout?.trim() || "";
7658
8031
  } finally {
7659
8032
  await rm(scriptFile, { force: true }).catch(() => {});
8033
+ await releaseLock();
8034
+ }
8035
+ }
8036
+
8037
+ async function acquireDirectoryLock(lockDir, timeoutMs = 60000) {
8038
+ const started = Date.now();
8039
+ while (true) {
8040
+ try {
8041
+ await mkdir(lockDir, { recursive: false });
8042
+ await writeFile(path.join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2), "utf8").catch(() => {});
8043
+ return async () => {
8044
+ await rm(lockDir, { recursive: true, force: true }).catch(() => {});
8045
+ };
8046
+ } catch {
8047
+ if (Date.now() - started > timeoutMs) {
8048
+ throw new Error("Браузерный профиль Госуслуг занят другим процессом. Закройте окно Госуслуг или повторите команду позже.");
8049
+ }
8050
+ await sleep(1000);
8051
+ }
7660
8052
  }
7661
8053
  }
7662
8054
 
8055
+ async function gosuslugiBrowserApiJson(params) {
8056
+ await requireGosuslugiConsent();
8057
+ await ensureBrowserRuntimeForGosuslugi();
8058
+ const raw = await runPersistentBrowserAutomation("api-json", {
8059
+ pageUrl: params.pageUrl || GOSUSLUGI_DEFAULT_URL,
8060
+ endpoint: params.endpoint,
8061
+ userDataDir: GOSUSLUGI_BROWSER_PROFILE_DIR,
8062
+ headed: params.headed !== false,
8063
+ waitMs: Number(params.waitMs || 0),
8064
+ timeout: Number(params.timeout || 60000),
8065
+ viewport: params.viewport || "1366x768",
8066
+ });
8067
+ return JSON.parse(raw);
8068
+ }
8069
+
8070
+ async function gosuslugiBrowserClickText(params) {
8071
+ await requireGosuslugiConsent();
8072
+ await ensureBrowserRuntimeForGosuslugi();
8073
+ return runPersistentBrowserAutomation("click-text", {
8074
+ pageUrl: params.pageUrl || GOSUSLUGI_DEFAULT_URL,
8075
+ text: params.text,
8076
+ userDataDir: GOSUSLUGI_BROWSER_PROFILE_DIR,
8077
+ headed: true,
8078
+ waitMs: Number(params.waitMs || 3000),
8079
+ timeout: Number(params.timeout || 60000),
8080
+ viewport: params.viewport || "1366x768",
8081
+ });
8082
+ }
8083
+
7663
8084
  function persistentBrowserAutomationScript(action, params) {
7664
8085
  return `
7665
8086
  import { chromium } from "playwright";
@@ -7673,7 +8094,7 @@ const context = await chromium.launchPersistentContext(params.userDataDir, {
7673
8094
  context.setDefaultTimeout(params.timeout || 60000);
7674
8095
  const page = context.pages()[0] || await context.newPage();
7675
8096
  try {
7676
- await page.goto(params.url, { waitUntil: "domcontentloaded", timeout: params.timeout || 60000 });
8097
+ await page.goto(params.url || params.pageUrl, { waitUntil: "domcontentloaded", timeout: params.timeout || 60000 });
7677
8098
  if (params.waitMs) await page.waitForTimeout(params.waitMs);
7678
8099
  if (action === "open") {
7679
8100
  if (params.headed) {
@@ -7688,6 +8109,21 @@ try {
7688
8109
  console.log((await page.locator("body").innerText()).trim());
7689
8110
  } else if (action === "screenshot") {
7690
8111
  await page.screenshot({ path: params.output, fullPage: true });
8112
+ } else if (action === "api-json") {
8113
+ const data = await page.evaluate(async (endpoint) => {
8114
+ const response = await fetch(endpoint, {
8115
+ credentials: "include",
8116
+ headers: { accept: "application/json" },
8117
+ });
8118
+ const text = await response.text();
8119
+ if (!response.ok) throw new Error(response.status + " " + response.statusText + ": " + text.slice(0, 500));
8120
+ return JSON.parse(text);
8121
+ }, params.endpoint);
8122
+ console.log(JSON.stringify(data));
8123
+ } else if (action === "click-text") {
8124
+ await page.getByText(params.text, { exact: true }).first().click();
8125
+ if (params.waitMs) await page.waitForTimeout(params.waitMs);
8126
+ console.log((await page.locator("body").innerText()).trim().slice(0, 4000));
7691
8127
  }
7692
8128
  } finally {
7693
8129
  await context.close().catch(() => {});
@@ -7925,6 +8361,9 @@ function mcpTools() {
7925
8361
  { name: "report", description: "Запуск встроенного отчета.", inputSchema: schema({ name: { type: "string" }, format: { type: "string" }, output: { type: "string" } }) },
7926
8362
  { name: "browser.text", description: "Открыть страницу в headless Chromium и вернуть видимый текст.", inputSchema: schema({ url: { type: "string" }, waitMs: { type: "number" } }) },
7927
8363
  { name: "browser.screenshot", description: "Сделать скриншот страницы через Chromium.", inputSchema: schema({ url: { type: "string" }, output: { type: "string" }, waitMs: { type: "number" } }) },
8364
+ { name: "gosuslugi.whoami", description: "Прочитать ФИО и дату рождения из личного профиля Госуслуг через локальный браузерный профиль.", inputSchema: schema({ full: { type: "boolean" } }) },
8365
+ { name: "gosuslugi.debt", description: "Прочитать задолженности и платежи к оплате на Госуслугах.", inputSchema: schema() },
8366
+ { name: "gosuslugi.notifications", description: "Прочитать уведомления Госуслуг.", inputSchema: schema({ unread: { type: "boolean" }, limit: { type: "number" } }) },
7928
8367
  ];
7929
8368
  }
7930
8369
 
@@ -7962,6 +8401,9 @@ async function callMcpTool(name, args = {}) {
7962
8401
  await runBrowserAutomation("screenshot", { url: args.url, output, waitMs: Number(args.waitMs || 0), timeout: Number(args.timeout || 30000), viewport: args.viewport || "1366x768" });
7963
8402
  return { output };
7964
8403
  }
8404
+ if (name === "gosuslugi.whoami") return gosuslugiWhoami(args);
8405
+ if (name === "gosuslugi.debt") return gosuslugiDebt(args);
8406
+ if (name === "gosuslugi.notifications") return gosuslugiNotifications(args);
7965
8407
  return executeRpc(name, { ...args, _: [] });
7966
8408
  }
7967
8409
 
@@ -9198,6 +9640,10 @@ function setConfigValue(config, key, value) {
9198
9640
  current[parts.at(-1)] = value;
9199
9641
  }
9200
9642
 
9643
+ function sleep(ms) {
9644
+ return new Promise((resolve) => setTimeout(resolve, ms));
9645
+ }
9646
+
9201
9647
  async function loadSecrets() {
9202
9648
  try {
9203
9649
  return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
@@ -34,3 +34,9 @@ iola cron delete 1
34
34
 
35
35
  `cron tick` проверяет задачи, которые пора выполнить. Его можно запускать вручную, через Windows Task Scheduler или другой планировщик.
36
36
 
37
+ Пример проверки сессии Госуслуг каждые 30 минут:
38
+
39
+ ```bash
40
+ iola gosuslugi install-keepalive
41
+ iola cron tick
42
+ ```
@@ -95,12 +95,19 @@ iola context list
95
95
  iola settings list
96
96
  iola settings validate
97
97
  iola gosuslugi status
98
+ iola gosuslugi check
99
+ iola gosuslugi keepalive
100
+ iola gosuslugi install-keepalive
98
101
  iola gosuslugi terms
99
102
  iola gosuslugi consent
100
103
  iola gosuslugi connect
101
104
  iola gosuslugi open
102
105
  iola gosuslugi text https://www.gosuslugi.ru/
103
106
  iola gosuslugi screenshot https://www.gosuslugi.ru/ --output gosuslugi.png
107
+ iola gosuslugi whoami
108
+ iola gosuslugi debt
109
+ iola gosuslugi notifications --unread
110
+ iola gosuslugi mark-read
104
111
  iola gosuslugi logout --all
105
112
  iola gosuslugi configure --auth-url URL --token-url URL --client-id ID --scope openid
106
113
  iola gosuslugi login
@@ -45,14 +45,69 @@ iola gosuslugi text https://www.gosuslugi.ru/
45
45
  iola gosuslugi screenshot https://www.gosuslugi.ru/ --output gosuslugi.png
46
46
  ```
47
47
 
48
+ ## Read-only данные
49
+
50
+ Краткие данные профиля:
51
+
52
+ ```bash
53
+ iola gosuslugi whoami
54
+ ```
55
+
56
+ Задолженности и платежи к оплате:
57
+
58
+ ```bash
59
+ iola gosuslugi debt
60
+ ```
61
+
62
+ Уведомления:
63
+
64
+ ```bash
65
+ iola gosuslugi notifications
66
+ iola gosuslugi notifications --unread
67
+ ```
68
+
69
+ Отметить уведомления прочитанными можно только отдельной командой с подтверждением:
70
+
71
+ ```bash
72
+ iola gosuslugi mark-read
73
+ ```
74
+
75
+ AI-агент может использовать read-only tools Госуслуг, если пользователь спрашивает про ФИО, дату рождения, задолженности, штрафы, налоги, платежи или уведомления.
76
+
48
77
  ## Статус
49
78
 
50
79
  ```bash
51
80
  iola gosuslugi status
81
+ iola gosuslugi check
52
82
  ```
53
83
 
54
84
  Команда показывает, принято ли согласие, где лежит локальный профиль и когда он был создан.
55
85
 
86
+ ## Keepalive
87
+
88
+ Сессию Госуслуг нельзя сделать вечной: портал сам управляет сроком жизни входа и может попросить повторную двухфакторную аутентификацию. CLI может только мягко проверять сохраненный профиль.
89
+
90
+ Запуск проверки каждые 30 минут:
91
+
92
+ ```bash
93
+ iola gosuslugi keepalive
94
+ ```
95
+
96
+ Однократная проверка:
97
+
98
+ ```bash
99
+ iola gosuslugi keepalive --once
100
+ ```
101
+
102
+ Добавить локальную cron-задачу CLI:
103
+
104
+ ```bash
105
+ iola gosuslugi install-keepalive
106
+ iola cron tick
107
+ ```
108
+
109
+ `cron tick` нужно запускать системным планировщиком или вручную. Если сессия протухла, CLI попросит выполнить `iola gosuslugi connect`.
110
+
56
111
  ## Отключение
57
112
 
58
113
  Удалить локальное подключение:
@@ -68,6 +123,7 @@ iola gosuslugi logout --all
68
123
  - CLI работает только с тем, что доступно пользователю в локальном браузере.
69
124
  - CLI не извлекает cookies, session tokens или внутренние токены Госуслуг.
70
125
  - Юридически значимые действия должны требовать отдельного подтверждения пользователя.
126
+ - Сессия браузерного профиля может протухнуть. Если Госуслуги попросят повторный вход или 2FA, выполните `iola gosuslugi connect`.
71
127
  - Если Госуслуги попросят повторный вход, код или подтверждение, пользователь проходит его сам в открытом окне.
72
128
 
73
129
  OAuth/OIDC-команды `configure`, `login`, `userinfo` оставлены для случая, если у пользователя есть официально зарегистрированное подключение ЕСИА.