dsh-tg-bridge 1.2.6 → 1.2.8

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
@@ -74,6 +74,21 @@ irm https://raw.githubusercontent.com/aleksvega/dsh-tg-bridge/main/install.ps1 |
74
74
  Id берётся у [@userinfobot](https://t.me/userinfobot), токен — у
75
75
  [@BotFather](https://t.me/BotFather).
76
76
 
77
+ ### Настройки бота в Telegram
78
+
79
+ Три вещи, без которых бот будет молчать в группе или работать наполовину:
80
+
81
+ 1. **Privacy mode → Disable** (`/setprivacy` у BotFather). Иначе бот видит
82
+ только команды, а обычный текст в теме — нет.
83
+ 2. **Темы включены** в настройках группы (Edit → Topics). Без них вся работа
84
+ идёт в один чат, отдельные сессии на тему не создаются.
85
+ 3. **Право Pin Messages** для бота. Нужно только для закрепления первого
86
+ сообщения темы; без него мост продолжит работать, но пин не поставит.
87
+
88
+ Меню команд настраивать руками не нужно: мост ставит его сам при старте, сразу
89
+ на русском, английском и китайском. Полная таблица настроек, права, проверка и
90
+ типичные ошибки — [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md).
91
+
77
92
  **Автозапуск** установщик ставит сам: `launchd` на macOS, `systemd --user` на
78
93
  Linux. На Windows `setup` регистрирует задачу `DSH-TG-Bridge` и минутный сторож
79
94
  `DSH-Bridge-Kick`, который поднимает мост после падения.
@@ -153,6 +168,7 @@ Telegram → bridge.mjs → dsh --profile headless --json → ответ
153
168
 
154
169
  ## Документация
155
170
 
171
+ - [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md) — настройки BotFather, группы и права бота
156
172
  - [docs/STATE.md](docs/STATE.md) — что работает, известные проблемы
157
173
  - [docs/ROADMAP.md](docs/ROADMAP.md) — что дальше
158
174
  - [docs/UX-ANALYSIS.md](docs/UX-ANALYSIS.md) — разбор интерактива
package/bridge.mjs CHANGED
@@ -22,7 +22,7 @@ import dns from 'node:dns';
22
22
  import net from 'node:net';
23
23
  dns.setDefaultResultOrder('ipv4first');
24
24
  net.setDefaultAutoSelectFamily(false);
25
- import { handleControl, CONTROL_COMMANDS, CONTROL_COMMAND_HINT } from './control.mjs';
25
+ import { handleControl, CONTROL_COMMANDS } from './control.mjs';
26
26
  import { renderPrices } from './prices.mjs';
27
27
  import { t, resolveLang } from './i18n.mjs';
28
28
 
@@ -607,6 +607,7 @@ export function toTelegramHtml(input) {
607
607
  async function send(chatId, text, threadId = 0, replyTo = 0) {
608
608
  const html = config.plainText === true ? '' : toTelegramHtml(text);
609
609
  const parts = html ? chunk(html, 3800) : chunk(toPlainText(text) || '(пусто)');
610
+ let lastId = 0;
610
611
  for (const part of parts) {
611
612
  const body = { chat_id: chatId, text: part, disable_web_page_preview: true };
612
613
  if (html) body.parse_mode = 'HTML';
@@ -627,7 +628,9 @@ async function send(chatId, text, threadId = 0, replyTo = 0) {
627
628
  res = await tg('sendMessage', body);
628
629
  }
629
630
  log(`sent chat=${chatId}${threadId ? ` topic=${threadId}` : ''} msg=${res.message_id} len=${part.length}${html ? ' html' : ''}`);
631
+ lastId = res.message_id;
630
632
  }
633
+ return lastId;
631
634
  }
632
635
 
633
636
  /** Remember which topics already got their seed message, so it happens once. */
@@ -676,14 +679,14 @@ async function ensureTopicSeed(chatId, threadId, text) {
676
679
  * reads as a log of what was started instead of a wall of prompts. The full
677
680
  * text lives in the topic itself; this is only the pointer.
678
681
  */
679
- async function logTopicToGeneral(chatId, threadId, text) {
682
+ async function logTopicToGeneral(chatId, threadId, text, lang = 'ru') {
680
683
  if (config.topicLog === false) return;
681
684
  const head = String(text ?? '').replace(/\s+/g, ' ').trim();
682
685
  const preview = head.length > 90 ? `${head.slice(0, 90)}…` : head;
683
686
  try {
684
687
  await tg('sendMessage', {
685
688
  chat_id: chatId,
686
- text: `📌 Новая тема: ${preview}\n→ работа идёт в теме, здесь — лог.`,
689
+ text: t(lang, 'msg.topicLog', { preview }),
687
690
  disable_web_page_preview: true,
688
691
  });
689
692
  log(`topic ${threadId} logged to General`);
@@ -845,10 +848,13 @@ async function startProgress(chatId, threadId, lang = 'ru') {
845
848
  // Three recent steps is enough to see the shape of the work; six lines of
846
849
  // shell commands read as noise on a phone.
847
850
  const tail = steps.slice(-3).map((line) => `• ${line}`).join('\n');
848
- const more = steps.length > 3 ? `\n…и ещё ${steps.length - 3}` : '';
851
+ const more = steps.length > 3 ? `\n${t(lang, 'status.more', { n: steps.length - 3 })}` : '';
849
852
  // The cache line belongs to the outcome, not to every intermediate edit.
850
853
  const cache = final && usage.cacheReadTokens
851
- ? `\n🧠 кэш: ${Math.round((usage.cacheReadTokens / Math.max(1, usage.cacheReadTokens + (usage.inputTokens ?? 0))) * 100)}% (${Math.round(usage.cacheReadTokens / 1000)}k из кэша)`
854
+ ? `\n${t(lang, 'status.cacheLine', {
855
+ pct: Math.round((usage.cacheReadTokens / Math.max(1, usage.cacheReadTokens + (usage.inputTokens ?? 0))) * 100),
856
+ k: Math.round(usage.cacheReadTokens / 1000),
857
+ })}`
852
858
  : '';
853
859
  return (tail ? `${head}\n${tail}${more}${cache}` : `${head}${cache}`);
854
860
  };
@@ -1009,7 +1015,7 @@ async function handleMessage(msg) {
1009
1015
  try {
1010
1016
  const res = await tg('sendMessage', {
1011
1017
  chat_id: chatId,
1012
- text: '🎤 Расшифровываю…',
1018
+ text: t(lang, 'msg.transcribing'),
1013
1019
  disable_web_page_preview: true,
1014
1020
  ...(threadId > 0 ? { message_thread_id: threadId } : {}),
1015
1021
  ...(msg.message_id ? { reply_to_message_id: msg.message_id } : {}),
@@ -1109,7 +1115,7 @@ async function handleMessage(msg) {
1109
1115
  const seed = await ensureTopicSeed(chatId, threadId, text);
1110
1116
  if (seed) {
1111
1117
  log(`topic ${threadId} seeded with message=${seed}`);
1112
- await logTopicToGeneral(chatId, threadId, text);
1118
+ await logTopicToGeneral(chatId, threadId, text, lang);
1113
1119
  }
1114
1120
  }
1115
1121
 
@@ -1150,7 +1156,7 @@ async function handleMessage(msg) {
1150
1156
  chatId,
1151
1157
  `${t(lang, 'msg.unknownCommand', { cmd: head })}
1152
1158
 
1153
- ${CONTROL_COMMAND_HINT}`,
1159
+ ${t(lang, 'help.body')}`,
1154
1160
  threadId,
1155
1161
  msg.message_id
1156
1162
  );
@@ -1395,12 +1401,33 @@ function maybeExitForRestart() {
1395
1401
  setTimeout(() => process.exit(75), 300).unref?.();
1396
1402
  }
1397
1403
 
1404
+ /**
1405
+ * Queue notices, one per topic. A notice exists only while the person is
1406
+ * waiting: the moment the queued turn starts it is deleted, so a long session
1407
+ * does not leave a column of "идёт уже 7 мин" messages behind (it did).
1408
+ */
1409
+ const busyNotices = new Map();
1410
+
1411
+ async function clearBusyNotice(key) {
1412
+ const notice = busyNotices.get(key);
1413
+ if (!notice) return;
1414
+ busyNotices.delete(key);
1415
+ try {
1416
+ await tg('deleteMessage', { chat_id: notice.chatId, message_id: notice.messageId });
1417
+ } catch (error) {
1418
+ log(`busy notice delete failed: ${error.message}`);
1419
+ }
1420
+ }
1421
+
1398
1422
  function enqueue(key, task, onBusy) {
1399
1423
  const prev = queues.get(key) || Promise.resolve();
1400
1424
  const queued = queues.has(key);
1401
1425
  const run = prev
1402
1426
  .then(() => acquire())
1403
- .then(() => {
1427
+ .then(async () => {
1428
+ // The person was told "your message is queued"; that notice has done its
1429
+ // job the moment the turn starts and must not linger in the chat.
1430
+ await clearBusyNotice(key);
1404
1431
  // Remember when the turn actually started: the queue notice has to say
1405
1432
  // "идёт уже 24 мин", not just "занято" — silence is what reads as a hang.
1406
1433
  turnStartedAt.set(key, Date.now());
@@ -1409,6 +1436,7 @@ function enqueue(key, task, onBusy) {
1409
1436
  .catch((error) => log(`turn queue error ${key}: ${error.message}`))
1410
1437
  .finally(() => {
1411
1438
  turnStartedAt.delete(key);
1439
+ clearBusyNotice(key).catch(() => {});
1412
1440
  release();
1413
1441
  if (queues.get(key) === run) queues.delete(key);
1414
1442
  // A deferred restart waits for exactly this moment: the answer is out and
@@ -1667,9 +1695,10 @@ async function main() {
1667
1695
  const notice = JSON.parse(readFileSync(RESTART_NOTICE, 'utf8'));
1668
1696
  unlinkSync(RESTART_NOTICE);
1669
1697
  if (Date.now() - Number(notice.at || 0) < 10 * 60 * 1000) {
1698
+ const lang = resolveLang(state, sessionKey(notice.chatId, notice.threadId || 0), null, config);
1670
1699
  await send(
1671
1700
  notice.chatId,
1672
- `✅ Мост перезапущен — на связи (pid ${process.pid}).\nТема и сессия прежние, можно продолжать.`,
1701
+ `✅ ${t(lang, 'msg.restarted', { pid: process.pid })}`,
1673
1702
  notice.threadId || 0,
1674
1703
  );
1675
1704
  }
@@ -1724,14 +1753,22 @@ async function main() {
1724
1753
  ? async () => {
1725
1754
  await new Promise((resolve) => setTimeout(resolve, Number(config.busyNoticeMs ?? 45000)));
1726
1755
  if (!queues.has(queueKey)) return; // already finished
1756
+ // One notice per topic: a second queued message must not add a
1757
+ // second copy of the same line.
1758
+ if (busyNotices.has(queueKey)) return;
1727
1759
  const started = turnStartedAt.get(queueKey);
1728
1760
  const mins = started ? Math.max(1, Math.round((Date.now() - started) / 60000)) : 0;
1729
- const howLong = mins > 0 ? `идёт уже ${mins} мин` : 'ещё идёт';
1730
- await send(
1761
+ const notice = mins > 0
1762
+ ? t(lang, 'msg.busy.since', { min: mins })
1763
+ : t(lang, 'msg.busy.now');
1764
+ const messageId = await send(
1731
1765
  msg.chat.id,
1732
- `⏳ Предыдущий ход ${howLong} — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.`,
1766
+ `⏳ ${notice}`,
1733
1767
  thread,
1734
1768
  );
1769
+ if (messageId) busyNotices.set(queueKey, { chatId: msg.chat.id, messageId });
1770
+ // The turn may have started while the notice was in flight.
1771
+ if (!queues.has(queueKey)) await clearBusyNotice(queueKey);
1735
1772
  }
1736
1773
  : undefined,
1737
1774
  );
package/control.mjs CHANGED
@@ -349,17 +349,6 @@ export const CONTROL_COMMANDS = [
349
349
  'prices', 'help', 'doctor', 'logs', 'lang',
350
350
  ];
351
351
 
352
- /** One-line menu used when an unknown command arrives. */
353
- export const CONTROL_COMMAND_HINT =
354
- 'Работа: /cd · /new · /stop · /send · /seed\n' +
355
- 'Сессии: /status · /sessions · /resume\n' +
356
- 'Модели: /models · /model · /prices\n' +
357
- 'Расширения: /skills · /soul\n' +
358
- 'Плагины: /plugins [headless] — список; /plugins add|remove|disable|enable <что> — действия\n' +
359
- 'Диагностика: /doctor · /logs\n' +
360
- 'Язык: /lang en|ru|zh\n' +
361
- 'Служебное: /app · /restart · /help';
362
-
363
352
  export async function handleControl(ctx) {
364
353
  const { command, arg, send, reply, log, profile, state, key, config } = ctx;
365
354
  const say = (text) => reply(text);
@@ -549,10 +538,10 @@ export async function handleControl(ctx) {
549
538
 
550
539
  case '/restart': {
551
540
  if (arg !== 'confirm') {
552
- say('Перезапуск моста: /restart confirm. Приложение DSH: /app restart confirm.');
541
+ say(t(ctx.lang || 'ru', 'cmd.restart.prompt'));
553
542
  return true;
554
543
  }
555
- say('Перезапускаю мост…');
544
+ say(t(ctx.lang || 'ru', 'cmd.restart.started'));
556
545
  const bridge = fileURLToPath(new URL('./bridge.mjs', import.meta.url));
557
546
  // Tell the replacement process where to confirm, so the chat does not go
558
547
  // silent after "Перезапускаю мост…" (it did, twice on 2026-10-24).
package/i18n.mjs CHANGED
@@ -25,11 +25,20 @@ const DICT = {
25
25
  'btn.retry': 'Retry',
26
26
  'btn.doctor': 'Doctor',
27
27
  'status.cache': 'cache',
28
+ 'status.more': '…and {n} more',
29
+ 'status.cacheLine': '🧠 cache: {pct}% ({k}k from cache)',
30
+ 'msg.topicLog': '📌 New topic: {preview}\n→ work happens in the topic, this is the log.',
31
+ 'msg.transcribing': '🎤 Transcribing…',
28
32
  'status.steps': 'steps',
29
33
  'status.thinking': 'waiting for the model',
30
34
  'err.timeout': 'no activity for {min} min — turn interrupted',
31
35
  'err.generic': 'something went wrong',
36
+ 'msg.busy.since': '⏳ The previous turn has been running for {min} min — your message is queued.\nIf it stalls, /stop interrupts it and I take yours right away.',
37
+ 'msg.busy.now': '⏳ The previous turn is still running — your message is queued.\nIf it stalls, /stop interrupts it and I take yours right away.',
32
38
  'msg.started': 'Telegram bridge for DSH is running (headless). Send a task; /help for commands.',
39
+ 'msg.restarted': 'Bridge restarted — back online (pid {pid}).\nThe topic and session are the same; keep going.',
40
+ 'cmd.restart.prompt': 'To restart the bridge: /restart confirm. For the DSH app: /app restart confirm.',
41
+ 'cmd.restart.started': 'Restarting the bridge…',
33
42
  'msg.unknownCommand': 'Unknown command: {cmd}. Open /help — it lists everything this bot can do.',
34
43
  'msg.langSet': 'Language set to English.',
35
44
  'cmd.help.title': 'Commands',
@@ -72,11 +81,20 @@ const DICT = {
72
81
  'btn.retry': 'Повторить',
73
82
  'btn.doctor': 'Диагноз',
74
83
  'status.cache': 'кэш',
84
+ 'status.more': '…и ещё {n}',
85
+ 'status.cacheLine': '🧠 кэш: {pct}% ({k}k из кэша)',
86
+ 'msg.topicLog': '📌 Новая тема: {preview}\n→ работа идёт в теме, здесь — лог.',
87
+ 'msg.transcribing': '🎤 Расшифровываю…',
75
88
  'status.steps': 'шагов',
76
89
  'status.thinking': 'жду модель',
77
90
  'err.timeout': 'нет активности {min} мин — ход прерван',
78
91
  'err.generic': 'что-то пошло не так',
92
+ 'msg.busy.since': '⏳ Предыдущий ход идёт уже {min} мин — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.',
93
+ 'msg.busy.now': '⏳ Предыдущий ход ещё идёт — твоё сообщение в очереди.\nЕсли это затянулось, /stop прервёт ход, и я сразу возьмусь за твоим.',
79
94
  'msg.started': 'Telegram-мост DSH запущен (headless). Пиши задачу; /help — команды.',
95
+ 'msg.restarted': 'Мост перезапущен — на связи (pid {pid}).\nТема и сессия прежние, можно продолжать.',
96
+ 'cmd.restart.prompt': 'Перезапуск моста: /restart confirm. Приложение DSH: /app restart confirm.',
97
+ 'cmd.restart.started': 'Перезапускаю мост…',
80
98
  'msg.unknownCommand': 'Неизвестная команда: {cmd}. Открой /help — там всё, что умеет бот.',
81
99
  'msg.langSet': 'Язык переключён на русский.',
82
100
  'cmd.help.title': 'Команды',
@@ -119,11 +137,20 @@ const DICT = {
119
137
  'btn.retry': '重试',
120
138
  'btn.doctor': '诊断',
121
139
  'status.cache': '缓存',
140
+ 'status.more': '…还有 {n} 条',
141
+ 'status.cacheLine': '🧠 缓存:{pct}%({k}k 来自缓存)',
142
+ 'msg.topicLog': '📌 新主题:{preview}\n→ 工作在主题里进行,这里是日志。',
143
+ 'msg.transcribing': '🎤 正在转写…',
122
144
  'status.steps': '步',
123
145
  'status.thinking': '等待模型',
124
146
  'err.timeout': '{min} 分钟无活动 — 已中断',
125
147
  'err.generic': '出现错误',
148
+ 'msg.busy.since': '⏳ 上一轮已运行 {min} 分钟 —— 你的消息在队列中。\n如果太久,/stop 会中断它,我马上处理你的消息。',
149
+ 'msg.busy.now': '⏳ 上一轮还在运行 —— 你的消息在队列中。\n如果太久,/stop 会中断它,我马上处理你的消息。',
126
150
  'msg.started': 'DSH 的 Telegram 桥已启动(headless)。直接发送任务;/help 查看命令。',
151
+ 'msg.restarted': '桥已重启,连接正常(pid {pid})。\n主题和会话不变,可以继续。',
152
+ 'cmd.restart.prompt': '重启桥:/restart confirm。重启 DSH 应用:/app restart confirm。',
153
+ 'cmd.restart.started': '正在重启桥…',
127
154
  'msg.unknownCommand': '未知命令:{cmd}。打开 /help 查看机器人支持的全部命令。',
128
155
  'msg.langSet': '界面语言已切换为中文。',
129
156
  'cmd.help.title': '命令',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tg-bridge",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "Telegram bridge for DeepSeek Harness: text, voice, photos and project-per-topic sessions without opening the desktop app",
5
5
  "type": "module",
6
6
  "main": "bridge.mjs",