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 +16 -0
- package/bridge.mjs +50 -13
- package/control.mjs +2 -13
- package/i18n.mjs +27 -0
- package/package.json +1 -1
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
${
|
|
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
|
-
`✅
|
|
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
|
|
1730
|
-
|
|
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
|
-
`⏳
|
|
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(
|
|
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