dsh-telegram-multiagent 1.0.0

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 ADDED
@@ -0,0 +1,107 @@
1
+ # dsh-telegram-multiagent
2
+
3
+ A Telegram channel for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): message
4
+ your agent from a phone, get answers back, keep one conversation per chat.
5
+
6
+ The harness ships no messenger channel — there is no channel abstraction in the product at all. This
7
+ plugin builds one out of the two ends the harness does give you:
8
+
9
+ ```
10
+ in: ctx.agents.create/resume(...) → agent.send(message)
11
+ out: ctx.on('session/event', ...) → send to Telegram
12
+ ```
13
+
14
+ One chat = one session = one agent. Sessions live independently, the way the core intends.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ # inside your profile directory ($DSH_HOME/profiles/<name>)
20
+ pnpm add dsh-telegram-multiagent
21
+ ```
22
+
23
+ Then add one row to your **agent preset** (`agent.cordis.yml`), not to the profile patch layer —
24
+ see "Where to put the row" below:
25
+
26
+ ```yaml
27
+ - insert:
28
+ - name: dsh-telegram-multiagent
29
+ config:
30
+ agentName: my-agent # log label only
31
+ tokenFile: /etc/dsh/bot.token # preferred: a file readable by this agent
32
+ appDir: /opt/my-agent/app # where THIS agent's harness is installed
33
+ workspace: /opt/my-agent/work
34
+ allowedUsers: [123456789] # empty = everyone; you do not want that
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ | Field | Required | What it does |
40
+ |---|---|---|
41
+ | `tokenFile` | one of these | Path to a file holding the bot token. Preferred: the secret belongs to the machine, the config only carries a path. |
42
+ | `token` / `tokenEnv` | one of these | Literal token, or the name of an env var. For debugging. |
43
+ | `appDir` | yes | Directory where this agent's harness is installed. Platform packages are resolved from **here**, not from the plugin's own location — see "Why appDir" below. |
44
+ | `agentName` | no | Label in log lines. Useful when several bots run on one machine. |
45
+ | `allowedUsers` | no | Numeric user ids allowed to talk to the agent. **Empty means everyone** — an agent usually has real access to the machine, so set it. |
46
+ | `workspace` | no | Working directory handed to the agent session. |
47
+ | `preset` | no | Agent preset to mount for sessions created by this channel. |
48
+ | `provider` / `model` | no | Fallback if the deployment has no default model service. |
49
+ | `a2aDir` | no | Directory for a file-based agent-to-agent channel (`in/`, `out/`). Omit and no such channel exists. |
50
+ | `a2aSession` | no | Session id used for that channel. Default `a2a`. |
51
+ | `transcribeCommand` | no | External command for voice messages: `<cmd> <audio-file> auto` → transcript on stdout. Omit and voice is politely refused. |
52
+
53
+ ## Four things that cost us a day
54
+
55
+ Each of these fails **while looking like success**. They are commented inline in the source; this is
56
+ the short version.
57
+
58
+ **1. Polling belongs to the bot, not to the mount.** The harness mounts a composition more than once
59
+ per process and unmounts the extra one. With a single shared `running` flag you get two pollers
60
+ fighting over one bot and Telegram cuts both with `Conflict`. With a polite "new mount asks the old
61
+ one to step aside" you get *no* poller at all — because the new mount is the one that gets unmounted.
62
+ The fix is reference counting: the first mount starts polling, the last unmount stops it.
63
+
64
+ **2. Delete the incoming message only after it reached the agent.** Reading a file and unlinking it
65
+ immediately loses the message whenever the handoff fails — and the handoff *will* fail, see (3).
66
+ From the outside that is indistinguishable from "the bot ignored me".
67
+
68
+ **3. The agent factory appears later than the channel.** The first message can arrive before the
69
+ harness has registered it, and you get `no agent factory registered`. Wait for the platform instead
70
+ of assuming it is ready.
71
+
72
+ **4. A session already on disk must be resumed, not created.** Calling `create()` with an existing
73
+ session id makes the persistence layer abort *every* turn with an id-collision error. Externally:
74
+ "accepted the message and went quiet" — the turn honestly starts and dies in milliseconds. It works
75
+ until the first restart, which is what makes it nasty. Use `resume()` when the session exists.
76
+
77
+ ## Where to put the row
78
+
79
+ In the `web` profile the common-plane tools are **disabled on purpose**; the toolset comes from the
80
+ agent preset. A plugin row placed in the profile patch layer composes without a single error, is
81
+ listed as mounted — and never reaches the agent. Put the row in the agent preset.
82
+
83
+ The preset is also picked up when a **session is created**: editing the file does not affect a
84
+ running session. Restart the platform, or change the default preset in settings (hot-reloaded, takes
85
+ effect for the next created session).
86
+
87
+ ## Why `appDir`
88
+
89
+ The plugin resolves `@deepseek-ai/dsh-llm` and `@deepseek-ai/dsh-agent` from the directory you pass
90
+ in `appDir`, not from its own location. That is deliberate: the module is meant to live once on a
91
+ machine and serve several agents, each with its own harness installation. Hard-linking it to one
92
+ agent's `node_modules` would mean that removing *that* agent breaks the channel for everybody else.
93
+
94
+ ## Security notes
95
+
96
+ - `allowedUsers` empty means anyone who finds the bot talks to an agent that usually has shell access
97
+ to the machine. Set it.
98
+ - A rejected stranger is logged and reported to the owner. A silent refusal hides a security event.
99
+ - The token is read from a file at startup; the config carries a path, not a secret.
100
+
101
+ ## Status
102
+
103
+ Written for our own fleet and running in production on several agents. The harness is young
104
+ (`0.1.0-rc`) and its plugin API moves; expect to adapt. Inline comments are currently in Russian —
105
+ they carry the reasoning behind each non-obvious line, and a translation is welcome.
106
+
107
+ MIT.
@@ -0,0 +1,19 @@
1
+ # Bundle patch: what `dsh plugin add` composes into the profile.
2
+ #
3
+ # 🔴 Note for anyone copying this: in the `web` profile the common-plane tools
4
+ # are disabled on purpose and the agent's toolset comes from its preset. If your
5
+ # agent must use tools, the channel row belongs in the AGENT PRESET, not here —
6
+ # a row in the profile layer composes cleanly, reports as mounted, and never
7
+ # reaches the agent. This file exists so the plugin is installable in one
8
+ # command; see the README for the preset placement.
9
+ - insert:
10
+ - name: dsh-telegram-multiagent
11
+ config:
12
+ # Path to a file holding the bot token. Preferred over a literal token.
13
+ tokenFile: /etc/dsh/bot.token
14
+ # Where THIS agent's harness is installed — platform packages resolve
15
+ # from here, so one shared module can serve several agents.
16
+ appDir: /opt/agent/app
17
+ # Empty means everyone can talk to an agent that usually has shell
18
+ # access. Set it.
19
+ allowedUsers: []
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "dsh-telegram-multiagent",
3
+ "version": "1.0.0",
4
+ "description": "Telegram channel for DeepSeek Harness — one shared module serving several agents on a machine, each with its own bot, token file and allow-list.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "files": [
8
+ "src",
9
+ "cordis.patch.yml",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "dsh",
14
+ "dsh-plugin",
15
+ "deepseek-harness",
16
+ "telegram",
17
+ "agent",
18
+ "channel"
19
+ ],
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/iia-arg/dsh-plugins.git",
24
+ "directory": "packages/telegram-channel"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ }
34
+ }
package/src/client.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Telegram Bot API — минимальный клиент, БЕЗ зависимостей.
3
+ *
4
+ * 🔴 Почему руками, а не готовой библиотекой: плагин dsh обязан быть
5
+ * самодостаточным бандлом и НЕ ТАЩИТЬ второй экземпляр фреймворка Cordis.
6
+ * Любая крупная библиотека рискует притащить свой. Здесь только fetch.
7
+ */
8
+
9
+ const API = 'https://api.telegram.org/bot';
10
+
11
+ export class TelegramClient {
12
+ constructor(token, log) {
13
+ this.token = token;
14
+ this.log = log ?? (() => {});
15
+ this.offset = 0;
16
+ }
17
+
18
+ async call(method, payload, timeoutMs = 15000) {
19
+ const ctl = new AbortController();
20
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
21
+ try {
22
+ const res = await fetch(`${API}${this.token}/${method}`, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify(payload ?? {}),
26
+ signal: ctl.signal,
27
+ });
28
+ const data = await res.json();
29
+ if (!data.ok) throw new Error(`${method}: ${data.description ?? 'неизвестная ошибка'}`);
30
+ return data.result;
31
+ } finally {
32
+ clearTimeout(t);
33
+ }
34
+ }
35
+
36
+ /** Длинный опрос. Возвращает пришедшие обновления и двигает смещение. */
37
+ async poll(timeoutSec = 25) {
38
+ // timeout запроса больше, чем у сервера, иначе рвём его же длинный опрос
39
+ const updates = await this.call('getUpdates',
40
+ { offset: this.offset, timeout: timeoutSec, allowed_updates: ['message'] },
41
+ (timeoutSec + 10) * 1000);
42
+ for (const u of updates) {
43
+ if (u.update_id >= this.offset) this.offset = u.update_id + 1;
44
+ }
45
+ return updates;
46
+ }
47
+
48
+ /**
49
+ * Отправка с нарезкой: у Telegram предел 4096 знаков на сообщение.
50
+ * Режем по границам абзацев и строк, чтобы не рвать слова и разметку.
51
+ */
52
+ async send(chatId, text) {
53
+ const LIMIT = 4000;
54
+ const chunks = [];
55
+ let rest = String(text ?? '').trim();
56
+ if (!rest) return;
57
+ while (rest.length > LIMIT) {
58
+ let cut = rest.lastIndexOf('\n\n', LIMIT);
59
+ if (cut < LIMIT * 0.5) cut = rest.lastIndexOf('\n', LIMIT);
60
+ if (cut < LIMIT * 0.5) cut = rest.lastIndexOf(' ', LIMIT);
61
+ if (cut < LIMIT * 0.5) cut = LIMIT;
62
+ chunks.push(rest.slice(0, cut));
63
+ rest = rest.slice(cut).trimStart();
64
+ }
65
+ if (rest) chunks.push(rest);
66
+ for (const c of chunks) {
67
+ await this.call('sendMessage', { chat_id: chatId, text: c });
68
+ }
69
+ }
70
+
71
+ async typing(chatId) {
72
+ try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing' }, 8000); }
73
+ catch { /* индикатор необязателен, молча пропускаем */ }
74
+ }
75
+
76
+ async whoAmI() { return this.call('getMe'); }
77
+
78
+ /** Возвращает file_path для скачивания (нужен для голосовых). */
79
+ async getFilePath(fileId) {
80
+ const file = await this.call('getFile', { file_id: fileId });
81
+ return file.file_path;
82
+ }
83
+ }
package/src/index.js ADDED
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Канал Telegram для DeepSeek Harness — ОБЩИЙ МОДУЛЬ МАШИНЫ.
3
+ *
4
+ * Написан нами, а не взят из каталога плагинов, сознательно: к боту заходят
5
+ * живые люди, а чужие плагины в этой экосистеме публикуются в том числе без
6
+ * исходников, и проверять там нечего.
7
+ *
8
+ * 🔴 ОДИН КОД — МНОГО АГЕНТОВ (требование владельца 19.08.2026). Раньше модуль
9
+ * назывался именем агента и стоял в его личном каталоге; второму агенту канал
10
+ * пришлось бы копировать, и дальше две копии расходятся молча. Теперь код
11
+ * лежит в системном каталоге в ЕДИНСТВЕННОМ экземпляре, а всё различающее
12
+ * агентов живёт в НАСТРОЙКЕ: токен, кто допущен, модель, рабочий каталог, имя
13
+ * в журнале, каталог межагентского обмена. Подключение нового бота — строка в
14
+ * слое профиля плюс файл токена, без единой правки кода.
15
+ *
16
+ * ГДЕ ТОКЕН. Никогда в конфиге агента и не в переменной окружения по умолчанию:
17
+ * `tokenFile` — путь к файлу, который читает только этот агент (как сделано с
18
+ * подпиской). Значение через `token`/переменную оставлено для отладки.
19
+ *
20
+ * УСТРОЙСТВО. Своего понятия «канал» у dsh нет — контракт складывается из двух концов:
21
+ * вход: ctx.agents.create(...) → agent.followup(сообщение)
22
+ * выход: ctx.on('session/event') → отправка в Telegram
23
+ * Один чат = одна сессия = один агент. Сессии живут независимо, как и задумано ядром.
24
+ */
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import os from 'node:os';
29
+ import { execFileSync } from 'node:child_process';
30
+ import { TelegramClient } from './client.js';
31
+ import { createRequire } from 'node:module';
32
+
33
+ /**
34
+ * 🔴 ПАКЕТЫ ПЛАТФОРМЫ РАЗРЕШАЕМ ОТ КАТАЛОГА АГЕНТА, А НЕ ОТ СВОЕГО.
35
+ * Код общий и лежит в системном каталоге, где никакой платформы рядом нет.
36
+ * Обычный импорт искал бы её рядом с собой и падал, а привязка к установке
37
+ * одного агента (симлинк на его node_modules) означала бы, что снос ЕГО
38
+ * платформы ломает канал у ВСЕХ остальных. Поэтому каждый агент передаёт
39
+ * `appDir` — свою установку, — и связь остаётся его собственной.
40
+ * `installModelSelection` берём из dsh-agent, а не dsh-session: проверено по
41
+ * импортам самого продукта.
42
+ */
43
+ let platformCache = null;
44
+ function platformOf(config, log) {
45
+ if (platformCache) return platformCache;
46
+ const from = config.appDir || process.env.DSH_APP_DIR;
47
+ if (!from) {
48
+ log('🔴 не задан appDir: канал не знает, где установлена платформа этого агента');
49
+ return null;
50
+ }
51
+ try {
52
+ const req = createRequire(path.join(from, 'разрешение-зависимостей.js'));
53
+ platformCache = {
54
+ createUserMessage: req('@deepseek-ai/dsh-llm').createUserMessage,
55
+ installModelSelection: req('@deepseek-ai/dsh-agent').installModelSelection,
56
+ };
57
+ return platformCache;
58
+ } catch (e) {
59
+ log(`🔴 пакеты платформы не разрешились от ${from}: ${e?.message ?? e}`);
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Опросы Telegram, живущие в ЭТОМ процессе, по токену бота. Нужен именно
66
+ * общий на модуль реестр: платформа монтирует состав несколько раз, и без него
67
+ * два опроса одного бота дерутся, а Telegram рвёт обоих ошибкой Conflict.
68
+ */
69
+ const ACTIVE_POLLERS = new Map();
70
+
71
+ export const name = 'dsh-telegram-channel';
72
+ export const inject = ['agents'];
73
+
74
+ /**
75
+ * Токен бота: файл (наш способ) → значение в конфиге → переменная окружения.
76
+ * Файл предпочтителен: секрет принадлежит машине, права выдаются агенту, а в
77
+ * составе профиля лежит только ПУТЬ, который не жалко ни в журнале, ни в копии.
78
+ */
79
+ function readTokenFrom(config, log) {
80
+ if (config.tokenFile) {
81
+ try {
82
+ const t = fs.readFileSync(config.tokenFile, 'utf8').trim();
83
+ if (t) return t;
84
+ log(`🔴 файл токена ${config.tokenFile} пуст`);
85
+ } catch (e) {
86
+ // Не глотать: «бот молчит» и «мне не дали прочитать токен» — разные беды,
87
+ // а выглядят снаружи одинаково.
88
+ log(`🔴 не прочитан файл токена ${config.tokenFile}: ${e?.message ?? e}`);
89
+ }
90
+ return null;
91
+ }
92
+ const t = config.token || process.env[config.tokenEnv || 'DSH_TELEGRAM_BOT_TOKEN'];
93
+ if (t) return t;
94
+ log('🔴 нет токена: задайте config.tokenFile (предпочтительно), config.token или переменную окружения');
95
+ return null;
96
+ }
97
+
98
+ export function apply(ctx, config = {}) {
99
+ // Имя агента — только для журнала: при нескольких ботах на машине нужно
100
+ // видеть, чья строка. К логике отношения не имеет.
101
+ const who = config.agentName || 'telegram';
102
+ const log = (m) => console.error(`[${who}] ${m}`);
103
+
104
+ const token = readTokenFrom(config, log);
105
+ if (!token) return;
106
+
107
+ const platform = platformOf(config, log);
108
+ if (!platform) return; // без пакетов платформы канал бесполезен — молчать нельзя, выше уже сказано почему
109
+
110
+ // Белый список: пусто = пускать всех. Понятия «пользователь» в ядре нет,
111
+ // ограничение доступа целиком на нашей стороне.
112
+ const allowed = new Set((config.allowedUsers ?? []).map(Number));
113
+ const isAllowed = (userId) => allowed.size === 0 || allowed.has(Number(userId));
114
+
115
+ const tg = new TelegramClient(token, log);
116
+ const chats = new Map(); // chatId → { handle, sessionId }
117
+ const sessionToChat = new Map(); // sessionId → chatId
118
+
119
+ /** Один чат — один агент. Создаём лениво, при первом сообщении. */
120
+ // ── ДОБИТЬ ПОБУДКУ (обосновано замером 18.08.2026, а не догадкой).
121
+ // В dsh-agent-loop, wakeDriver(): если агент НЕ в простое и это не maintenance
122
+ // и не пробуждение после отмены — флаг побудки НЕ запоминается, функция просто
123
+ // выходит. Наблюдали живьём: агент завис в вызове инструмента, сообщение легло
124
+ // в очередь next-turn в 20:06:46, побудка пропала, хода не началось вовсе —
125
+ // ни ошибки, ни turn/start, полная тишина.
126
+ //
127
+ // Поэтому после отправки следим: как только агент освободился, а сообщение всё
128
+ // ещё в очереди — будим повторно.
129
+ //
130
+ // 🔴 НИКАКИХ ТИХИХ ВЫХОДОВ: каждая ветка что-то пишет в журнал. Ровно на этом
131
+ // обжглись сегодня — `agent.inbox?.hasPending` при недоступном inbox даёт
132
+ // undefined, условие «всё хорошо» проходит, и отказ маскируется под успех.
133
+ function nudgeUntilClaimed(agent, tag, budgetMs = 120000) {
134
+ void (async () => {
135
+ const deadline = Date.now() + budgetMs;
136
+ let woke = 0;
137
+ while (Date.now() < deadline) {
138
+ await new Promise((r) => setTimeout(r, 500));
139
+ if (typeof agent?.inbox?.hasPending !== 'boolean') {
140
+ log(`${tag} 🔴 очередь агента недоступна — добивать побудку нечем`);
141
+ return;
142
+ }
143
+ if (agent.status === 'running') continue;
144
+ if (!agent.inbox.hasPending) {
145
+ if (woke) log(`${tag} сообщение забрано после ${woke} повторных побудок`);
146
+ return;
147
+ }
148
+ if (typeof agent.wakeDriver !== 'function') {
149
+ log(`${tag} 🔴 сообщение висит в очереди, а wakeDriver недоступен`);
150
+ return;
151
+ }
152
+ agent.wakeDriver();
153
+ woke += 1;
154
+ }
155
+ log(`${tag} 🔴 за ${budgetMs} мс сообщение так и не забрали (побудок: ${woke})`);
156
+ })();
157
+ }
158
+
159
+ /**
160
+ * 🔴 ФАБРИКА АГЕНТОВ ПОЯВЛЯЕТСЯ ПОЗЖЕ КАНАЛА (поймано 19.08.2026 инструментовкой).
161
+ * Канал монтируется раньше, чем платформа регистрирует фабрику, и первое же
162
+ * сообщение падало с «no agent factory registered». Снаружи — «написал боту,
163
+ * он молчит», причём вопрос к тому моменту уже был удалён из входящих.
164
+ * Поэтому ждём готовности платформы, а не считаем её данностью.
165
+ */
166
+ async function withFactoryRetry(tag, fn, tries = 60) {
167
+ for (let i = 0; i < tries; i++) {
168
+ try {
169
+ return await fn();
170
+ } catch (e) {
171
+ const why = String(e?.message ?? e);
172
+ if (!why.includes('no agent factory registered')) throw e; // другая беда — наверх, не глотать
173
+ if (i === 0) log(`${tag} фабрика агентов ещё не зарегистрирована — жду платформу`);
174
+ await new Promise((r) => setTimeout(r, 1000));
175
+ }
176
+ }
177
+ throw new Error(`фабрика агентов не появилась за ${tries} с`);
178
+ }
179
+
180
+ async function agentFor(chatId) {
181
+ const key = String(chatId);
182
+ const found = chats.get(key);
183
+ if (found) return found;
184
+ const sessionId = `telegram-${key}`;
185
+ // 🔴 Модель берём из штатного сервиса agentDefaultModel, а НЕ хардкодом,
186
+ // и передаём setup с installModelSelection — ровно так создаёт агента сам
187
+ // продукт (образец в dsh-headless). Без setup агент собирается неполным:
188
+ // в запрос к модели не попадает поле tools вообще, и агент физически не
189
+ // может вызвать ни один инструмент, хотя они смонтированы (проверено
190
+ // 2026-08-18 по событию request/header в журнале сессии).
191
+ const presets = ctx.get('agentPresets');
192
+ const presetId = presets ? (await presets.resolve(config.preset)).id : undefined;
193
+ const defaultModel = ctx.get('agentDefaultModel');
194
+ const selection = defaultModel?.currentSelection?.() ?? {
195
+ provider: config.provider,
196
+ model: config.model,
197
+ };
198
+ const setupFn = async (agentCtx) => {
199
+ platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
200
+ if (presets && presetId) await presets.mount(agentCtx, presetId);
201
+ };
202
+
203
+ // 🔴 СЕССИЮ, КОТОРАЯ УЖЕ ЛЕЖИТ НА ДИСКЕ, НАДО ПРОДОЛЖАТЬ, А НЕ СОЗДАВАТЬ ЗАНОВО.
204
+ // Раньше здесь всегда звался create() с тем же sessionId. Продукт при этом
205
+ // заводит НОВУЮ живую сессию, её семя не сходится с сохранёнными событиями,
206
+ // и dsh-session-persistence обрывает КАЖДЫЙ ход ошибкой:
207
+ // session "..." is already persisted with N event(s) that do not match
208
+ // this live session (id collision)
209
+ // Снаружи это выглядело как «принял сообщение и замолчал»: ход честно
210
+ // начинался и умирал за 7 мс. Работало только пока сессии не было на диске —
211
+ // то есть до первого перезапуска. Поймано 18.08.2026 печатью event.data
212
+ // события turn/end (раньше плагин печатал только тип события).
213
+ // Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
214
+ // продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
215
+ const persistence = ctx.get('sessionPersistence');
216
+ let handle;
217
+ if (persistence) {
218
+ let onDisk = false;
219
+ try {
220
+ onDisk = (await persistence.list()).some((h) => h.id === sessionId);
221
+ } catch (e) {
222
+ log(`не смогла перечислить сохранённые сессии: ${e?.message ?? e}`);
223
+ }
224
+ if (onDisk) {
225
+ try {
226
+ handle = await ctx.agents.resume({
227
+ resumeSessionId: sessionId,
228
+ agentOptions: { provider: selection.provider, model: selection.model },
229
+ setup: setupFn,
230
+ });
231
+ log(`сессия ${sessionId} продолжена с диска (история сохранена)`);
232
+ } catch (e) {
233
+ log(`🔴 продолжить сессию ${sessionId} не удалось: ${e?.message ?? e}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ if (handle === undefined) handle = await withFactoryRetry(`[${key}]`, () => ctx.agents.create({
239
+ sessionId,
240
+ meta: { cwd: config.workspace ?? process.cwd() },
241
+ agentOptions: { provider: selection.provider, model: selection.model },
242
+ // 🔴 ПРЕСЕТ ОБЯЗАТЕЛЕН, иначе агент остаётся БЕЗ ИНСТРУМЕНТОВ.
243
+ // В профиле web инструменты на общем плане ВЫКЛЮЧЕНЫ намеренно
244
+ // (в его сборке 9 строк с disabled: true) — набор приходит из
245
+ // агент-пресета, и его надо примонтировать явно. Без монтирования
246
+ // в запрос к модели не попадает поле tools вообще: агент отвечает
247
+ // «сейчас выполню команду» и не выполняет ничего, ход при этом
248
+ // честно завершается completed. Образец — composeAgent в dsh-host-apiproxy.
249
+ agentPreset: presetId,
250
+ setup: setupFn,
251
+ }));
252
+ const entry = { handle, sessionId };
253
+ chats.set(key, entry);
254
+ sessionToChat.set(sessionId, chatId);
255
+ log(`создан агент для чата ${key} (сессия ${sessionId})`);
256
+ return entry;
257
+ }
258
+
259
+ // ── ВЫХОД: события сессии → сообщения в Telegram
260
+ ctx.on('session/event', (session, event) => {
261
+ // 🔴 DEBUG 2026-08-18: все типы событий
262
+ log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
263
+ const chatId = sessionToChat.get(String(session?.id ?? ''));
264
+ if (chatId === undefined) {
265
+ if (event.type === 'assistant/message') log(`[event] chatId undefined для session ${session?.id} — игнорирую`);
266
+ return;
267
+ }
268
+ try {
269
+ if (event.type === 'turn/start') {
270
+ void tg.typing(chatId);
271
+ } else if (event.type === 'turn/end' && event.data?.reason?.kind === 'error') {
272
+ // 🔴 18.08.2026: ход может оборваться с внятной ошибкой, и она приходит
273
+ // ИМЕННО ЗДЕСЬ, в event.data.reason. Раньше плагин печатал только тип
274
+ // события и выбрасывал содержимое — снаружи это выглядело как «принял
275
+ // и замолчал», и на выдумывание причин ушёл вечер. Настоящая ошибка
276
+ // (столкновение идентификаторов сессии) лежала в потоке всё это время.
277
+ // Правило: причину обрыва показывать ВСЕГДА, и в журнал, и собеседнику.
278
+ const why = event.data.reason.error?.message ?? 'без описания';
279
+ log(`🔴 ход оборван ошибкой: ${why}`);
280
+ if (chatId === A2A_CHAT) {
281
+ try {
282
+ fs.mkdirSync(A2A_OUT, { recursive: true });
283
+ fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), `🔴 ход оборван ошибкой: ${why}`);
284
+ } catch { /* канал и так сломан, писать больше некуда */ }
285
+ } else {
286
+ void tg.send(chatId, `Не смог выполнить ход: ${why}`);
287
+ }
288
+ } else if (event.type === 'assistant/message' && chatId === A2A_CHAT) {
289
+ // ответ для координатора — в файл, Telegram тут ни при чём
290
+ const blocks = event.data?.message?.content ?? [];
291
+ const text = blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim();
292
+ if (text) {
293
+ try {
294
+ fs.mkdirSync(A2A_OUT, { recursive: true });
295
+ fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), text);
296
+ log(`[a2a] ответ координатору записан (${text.length} знаков)`);
297
+ } catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
298
+ }
299
+ } else if (event.type === 'assistant/message') {
300
+ // Берём только видимый текст: рассуждения модели наружу не отдаём.
301
+ const blocks = event.data?.message?.content ?? [];
302
+ const text = blocks
303
+ .filter((b) => b?.type === 'text')
304
+ .map((b) => b.text)
305
+ .join('\n')
306
+ .trim();
307
+ if (text) {
308
+ log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
309
+ tg.send(chatId, text).catch((e) => log(`🔴 send к chatId=${chatId} не удался: ${e?.message ?? e}`));
310
+ }
311
+ }
312
+ } catch (e) {
313
+ log(`ошибка обработки события для чата ${chatId}: ${e?.message ?? e}`);
314
+ }
315
+ });
316
+
317
+ // ── ВХОД: длинный опрос Telegram → сообщения агенту
318
+ // Скачивает файл Telegram по file_id и возвращает временный путь.
319
+ async function downloadTelegramFile(fileId) {
320
+ const filePath = await tg.getFilePath(fileId);
321
+ const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
322
+ const ext = path.extname(filePath) || '.oga';
323
+ const tmp = path.join(os.tmpdir(), `dsh-voice-${Date.now()}${ext}`);
324
+ // curl доступен на хосте; fetch тоже можно, но curl проще для бинарника.
325
+ execFileSync('curl', ['-s', '-o', tmp, url]);
326
+ return tmp;
327
+ }
328
+
329
+ // Транскрибирует голосовое сообщение через нашу локальную цепочку.
330
+ // Возвращает строку с текстом или null при ошибке.
331
+ async function transcribeVoice(fileId) {
332
+ let tmpAudio = null;
333
+ try {
334
+ tmpAudio = await downloadTelegramFile(fileId);
335
+ // внешняя команда расшифровки, задаётся настройкой transcribeCommand
336
+ if (!config.transcribeCommand) {
337
+ log('голосовые не расшифровываются: не задан config.transcribeCommand');
338
+ return null;
339
+ }
340
+ const transcript = execFileSync(config.transcribeCommand,
341
+ [tmpAudio, 'auto'], { timeout: 120_000 }).toString().trim();
342
+ return transcript || null;
343
+ } catch (e) {
344
+ log(`transcribeVoice: ошибка: ${e?.message ?? e}`);
345
+ return null;
346
+ } finally {
347
+ if (tmpAudio) try { fs.unlinkSync(tmpAudio); } catch { /* уже удалён */ }
348
+ }
349
+ }
350
+
351
+ async function handleUpdate(u) {
352
+ const msg = u.message;
353
+ // Принимаем текст И голосовые/аудио. Всё остальное молча пропускаем.
354
+ const isVoice = !!(msg?.voice || msg?.audio);
355
+ if (!msg?.text && !isVoice) return;
356
+ const chatId = msg.chat.id;
357
+ const userId = msg.from?.id;
358
+
359
+ let text;
360
+ if (isVoice) {
361
+ const fileId = (msg.voice ?? msg.audio).file_id;
362
+ void tg.typing(chatId);
363
+ const transcript = await transcribeVoice(fileId);
364
+ if (!transcript) {
365
+ await tg.send(chatId, '⚠️ Не смог расшифровать голосовое сообщение. Попробуйте ещё раз.');
366
+ return;
367
+ }
368
+ text = `[Voice transcript]: ${transcript}`;
369
+ log(`голосовое расшифровано: ${transcript.length} знаков`);
370
+ } else {
371
+ text = msg.text.trim();
372
+ }
373
+
374
+ if (!isAllowed(userId)) {
375
+ // 🔴 Чужой стучится к агенту, у которого полный доступ к серверу.
376
+ // Отказываем И поднимаем тревогу владельцу — молча отказывать нельзя:
377
+ // сам факт попытки это событие безопасности, а не бытовая мелочь.
378
+ const who = msg.from ?? {};
379
+ const alert = `🔴 Чужой написал агенту\n` +
380
+ `id: ${userId}\n` +
381
+ `имя: ${who.first_name ?? '?'} ${who.last_name ?? ''}`.trim() + `\n` +
382
+ `ник: ${who.username ? '@' + who.username : 'нет'}\n` +
383
+ `чат: ${chatId}\n` +
384
+ `текст: ${text.slice(0, 200)}`;
385
+ log(`ОТКАЗ чужому ${userId} (@${who.username ?? '—'}): ${text.slice(0, 60)}`);
386
+ for (const owner of allowed) {
387
+ try { await tg.send(owner, alert); } catch (e) { log(`тревога не ушла: ${e?.message ?? e}`); }
388
+ }
389
+ await tg.send(chatId, 'Извините, у меня нет разрешения с вами работать.');
390
+ return;
391
+ }
392
+
393
+ // Команды обрабатываем сами, до агента.
394
+ if (text === '/start' || text === '/help') {
395
+ await tg.send(chatId, 'агент на связи. Пишите задачу обычным сообщением.\n' +
396
+ '/new — начать разговор заново.');
397
+ return;
398
+ }
399
+ if (text === '/new') {
400
+ const old = chats.get(String(chatId));
401
+ if (old) {
402
+ try { await old.handle.dispose(); } catch { /* уже мог отвалиться */ }
403
+ chats.delete(String(chatId));
404
+ sessionToChat.delete(old.sessionId);
405
+ }
406
+ await tg.send(chatId, 'Начал заново.');
407
+ return;
408
+ }
409
+
410
+ const { handle } = await agentFor(chatId);
411
+ // 🔴 ТОЛЬКО send(), НЕ followup(). Проверено на установленной версии 0.1.0-rc.7:
412
+ // followup объявлен в описании типов, но В КОДЕ ЕГО НЕТ — вызов молча не делает
413
+ // ничего, и снаружи это выглядит как «бот принял сообщение и замолчал».
414
+ // Правильный вызов подсмотрен в самом продукте: this.send(input, "next-turn", true).
415
+ // next-turn — обычный следующий ход;
416
+ // true — разбудить исполнителя, иначе сообщение будет ждать вечно.
417
+ const userMsg = platform.createUserMessage({
418
+ content: [{ type: 'text', text }],
419
+ source: { kind: 'user' },
420
+ });
421
+ try {
422
+ // 🔴 Ждём, пока агент домотает replay. send() во время replay молча
423
+ // игнорируется (wakeDriver не срабатывает) — снаружи это выглядит как
424
+ // «бот принял сообщение и замолчал». Диагноз 18.08.2026.
425
+ // Ожидание ОБЯЗАНО быть с пределом: whenIdle() не обязан разрешиться,
426
+ // а ожидание без границы превращает «не потеряли» в «висим вечно».
427
+ await Promise.race([
428
+ handle.agent.whenIdle(),
429
+ new Promise((r) => setTimeout(r, 15000)),
430
+ ]);
431
+ handle.agent.send(userMsg, 'next-turn', true);
432
+ nudgeUntilClaimed(handle.agent, '');
433
+ log(`сообщение передано агенту чата ${chatId} (${text.length} знаков)`);
434
+ } catch (e) {
435
+ log(`🔴 не удалось передать сообщение агенту: ${e?.message ?? e}`);
436
+ await tg.send(chatId, 'Не смог передать ваше сообщение агенту. Разбираюсь.');
437
+ }
438
+ }
439
+
440
+ // ── КАНАЛ АГЕНТ↔АГЕНТ (координатор ↔ агент), через файлы.
441
+ //
442
+ // Telegram тут не годится: у агента нет и не может быть учётной записи
443
+ // пользователя, а бот боту не пишет — так устроен мессенджер. Поэтому связь
444
+ // через каталог обмена, он же работает поверх ssh с любой машины фермы.
445
+ //
446
+ // входящие мне: <a2aDir>/in/*.txt — кладёт координатор, я передаю агенту
447
+ // исходящие ей: <a2aDir>/out/*.txt — пишет агент, забирает координатор
448
+ // Каталог обмена задаётся настройкой: у каждого агента он свой. Не задан —
449
+ // межагентского канала у этого агента просто нет, и опрос не ведётся вовсе
450
+ // (молчаливое создание чужих каталогов было бы хуже отсутствия связи).
451
+ const A2A_DIR = config.a2aDir || null;
452
+ const A2A_IN = A2A_DIR ? path.join(A2A_DIR, 'in') : null;
453
+ const A2A_OUT = A2A_DIR ? path.join(A2A_DIR, 'out') : null;
454
+ const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
455
+
456
+ async function pollA2A() {
457
+ if (!A2A_DIR) return;
458
+ let files = [];
459
+ try {
460
+ fs.mkdirSync(A2A_IN, { recursive: true });
461
+ fs.mkdirSync(A2A_OUT, { recursive: true });
462
+ files = fs.readdirSync(A2A_IN).filter((f) => f.endsWith('.txt')).sort();
463
+ } catch { return; }
464
+ for (const f of files) {
465
+ const full = path.join(A2A_IN, f);
466
+ let text = '';
467
+ try {
468
+ text = fs.readFileSync(full, 'utf-8').trim();
469
+ } catch (e) {
470
+ // 🔴 НЕ ГЛОТАТЬ. Раньше здесь стоял `catch { continue; }` — и файл,
471
+ // который нам не по правам (например 600 root:root), молча оставался
472
+ // лежать во входящих. Снаружи это «агент не отвечает», а на деле
473
+ // мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
474
+ log(`[a2a] 🔴 не смогла прочитать ${f}: ${e?.message ?? e} (права: попробуй chown dsh:dsh + chmod 644)`);
475
+ continue;
476
+ }
477
+ if (!text) continue;
478
+ try {
479
+ const { handle } = await agentFor(A2A_CHAT);
480
+ handle.agent.send(platform.createUserMessage({
481
+ content: [{ type: 'text', text }],
482
+ source: { kind: 'user' },
483
+ }), 'next-turn', true);
484
+ nudgeUntilClaimed(handle.agent, '[a2a]');
485
+ // 🔴 УДАЛЯЕМ ТОЛЬКО ПОСЛЕ УСПЕШНОЙ ПЕРЕДАЧИ (19.08.2026). Раньше файл
486
+ // стирался сразу после чтения — и когда передача падала (фабрика
487
+ // агентов ещё не поднялась), вопрос исчезал молча: ни ответа, ни следа.
488
+ try { fs.unlinkSync(full); } catch { /* уже убрали — не беда */ }
489
+ log(`[a2a] принято (${text.length} знаков)`);
490
+ } catch (e) {
491
+ log(`[a2a] 🔴 не удалось передать агенту: ${e?.message ?? e} — файл ${f} ОСТАВЛЕН во входящих, попробую снова`);
492
+ }
493
+ }
494
+ }
495
+
496
+ async function loop(run) {
497
+ while (run.alive) {
498
+ try {
499
+ await pollA2A();
500
+ const updates = await tg.poll(25);
501
+ for (const u of updates) {
502
+ try { await handleUpdate(u); }
503
+ catch (e) { log(`ошибка обработки обновления: ${e?.message ?? e}`); }
504
+ }
505
+ } catch (e) {
506
+ // Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
507
+ log(`опрос не удался: ${e?.message ?? e}`);
508
+ await new Promise((r) => setTimeout(r, 3000));
509
+ }
510
+ }
511
+ run.finished = true;
512
+ }
513
+
514
+ // ctx.effect — регистрация с обязательным откатом: при выгрузке плагина
515
+ // опрос корректно останавливается, а не остаётся висеть.
516
+ //
517
+ // 🔴 ОДИН ОПРОС НА БОТА, СЧЁТЧИК МОНТАЖЕЙ — А НЕ ЭСТАФЕТА (19.08.2026, три
518
+ // неверные попытки подряд, поэтому пишу подробно).
519
+ //
520
+ // Платформа за один запуск процесса монтирует канал ДВАЖДЫ и второй монтаж
521
+ // тут же снимает. Что из этого выходило:
522
+ // 1) общий флаг `running`: откат гасил его, но второй цикл жил дальше →
523
+ // два опроса одного бота → Telegram рвёт обоих ошибкой Conflict;
524
+ // 2) «своя жизнь у каждого запуска» + вежливая передача эстафеты: новый
525
+ // монтаж просил прежний уступить, а сам был снят платформой → не
526
+ // опрашивал НИКТО, при этом в журнале честно значилось «подключён как».
527
+ // Снаружи неотличимо от работающего канала — бот просто молчит.
528
+ //
529
+ // Верная модель: опрос принадлежит БОТУ, а не монтажу. Монтажи лишь считают
530
+ // ссылки: первый поднимает опрос, последний снятый — гасит. Снятие ОДНОГО из
531
+ // двух монтажей ничего не останавливает.
532
+ ctx.effect(() => {
533
+ let state = ACTIVE_POLLERS.get(token);
534
+ if (!state) {
535
+ state = { refs: 0, run: null };
536
+ ACTIVE_POLLERS.set(token, state);
537
+ }
538
+ state.refs += 1;
539
+
540
+ if (!state.run) {
541
+ const run = { alive: true, finished: false };
542
+ state.run = run;
543
+ void (async () => {
544
+ tg.whoAmI()
545
+ .then((me) => log(`подключён как @${me.username} (${me.first_name})`))
546
+ .catch((e) => log(`не удалось представиться Telegram: ${e?.message ?? e}`));
547
+ log(`опрос запущен (монтажей: ${state.refs})`);
548
+ await loop(run);
549
+ log('опрос завершён');
550
+ })();
551
+ } else {
552
+ log(`опрос уже идёт — второй не поднимаю (монтажей: ${state.refs})`);
553
+ }
554
+
555
+ return () => {
556
+ state.refs -= 1;
557
+ if (state.refs > 0) {
558
+ log(`монтаж снят, опрос продолжается (осталось монтажей: ${state.refs})`);
559
+ return;
560
+ }
561
+ if (state.run) {
562
+ state.run.alive = false;
563
+ state.run = null;
564
+ }
565
+ log('снят последний монтаж — опрос остановлен');
566
+ };
567
+ }, 'dsh-telegram-channel.poll');
568
+ }