dsh-telegram-bot 0.1.1 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +5 -4
  2. package/index.js +467 -67
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ Telegram bot for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
4
4
 
5
5
  > Found a bug or have an idea? [Open an issue](https://github.com/Boobaw/dsh-telegram-bot/issues).
6
6
 
7
- The bot connects to Telegram, enforces the allowlists, and bridges DSH agent sessions to Telegram **DM topics**. The **All** topic stays global; every DSH session gets its own topic. The assistant answer is the first message and streams in place as text arrives; the current reasoning block and a rolling tool-call list update after it, then both transient messages are deleted when the answer is ready. A separate **System** topic mirrors bot lifecycle notifications, which also appear in **All**.
7
+ The bot connects to Telegram, enforces the allowlists, and bridges DSH agent sessions to Telegram **DM topics**. The **All** topic stays global; every DSH session gets its own topic. The assistant answer is the first message and streams in place as text arrives; the current reasoning block and a rolling tool-call list update after it, then both transient messages are deleted when the answer is ready. Bot lifecycle notifications go to **All**.
8
8
 
9
9
  ## Features
10
10
 
@@ -17,14 +17,15 @@ The bot connects to Telegram, enforces the allowlists, and bridges DSH agent ses
17
17
  - **Plugins page** — the bundle page is informational and points to Settings → Telegram. The bundle switch above it is the plugin on/off control; turning it off unmounts the runtime and aborts polling.
18
18
  - **Agent bridge** — an ordinary text message in a session topic starts a DSH agent turn in that session, with assistant text streamed back.
19
19
  - **DM topics** — `/sessions` in **All** is the single place for DSH sessions: choose a workspace, then create a new session in it or open an existing one. The selected session continues in its own topic. Requires **Threaded mode** in @BotFather → Bot Settings.
20
- - **All and System topics** — All is Telegram's default topic and the management hub; System receives bot lifecycle notifications, mirrored into All.
20
+ - **All topic** — Telegram's default topic and the management hub; lifecycle notifications are posted there.
21
21
  - **Thinking and tool calls** — reasoning deltas and tool calls are mirrored into the topic, with independent **Show model thinking** / **Show tool calls** switches. Thinking is a 10-line rolling window; the tool-call message shows the last ten calls.
22
22
  - **Commands** — `/start`, `/help`, `/sessions`, `/stop`, `/status`, `/current`, `/menu`, `/rename`, `/last`, `/restart`, `/id`, `/ping`.
23
23
  - **Approval prompts** — when a tool asks for approval, the session topic gets a styled prompt with **Deny** and **Allow** inline buttons; the decision resolves the exact pending request.
24
+ - **Structured questions** — `ask_user_question` requests render in the session topic with option buttons, multi-select, a custom-answer flow, and plan-review approve/decline choices.
24
25
  - **Session controls** — an inline 2×2 panel in each session topic: **Model** (Flash/Pro), **Effort** (off/low/high/max), **Mode** (permission preset), and **Context** (window, last request usage, and cache-hit percentage). Telegram reply keyboards are chat-wide, so the controls are a per-topic inline panel rather than a bottom keyboard.
25
26
  - **Recovery** — `/last [n]` shows the latest assistant answer(s) from the bound session, useful after switching between Web and Telegram. `/rename <name>` changes the Telegram topic title; `/restart` restarts the Telegram runtime; `/menu` opens the inline session controls.
26
27
  - **Language** — bot messages and the Telegram command menu default to English; a Settings selector switches them to Russian.
27
- - **Formatting** — assistant Markdown (headings, bold, lists, code, links, tables) is converted to Telegram MarkdownV2.
28
+ - **Formatting** — assistant Markdown (headings, bold, lists, code, links, tables) is converted to Telegram MarkdownV2. The same formatting is used for session previews and `/last`.
28
29
  - **Command hints** — the runtime publishes the list with `setMyCommands`, so Telegram shows the commands and descriptions when the user types `/`. The descriptions follow the selected Bot language; English is the default.
29
30
  - English and Russian page copy; Russian is registered as an optional language pack.
30
31
 
@@ -68,7 +69,7 @@ The token field reports only whether a token is configured. A token supplied by
68
69
 
69
70
  1. Create a bot with [@BotFather](https://t.me/BotFather) and save its token in **Settings → Telegram**.
70
71
  2. In @BotFather open the bot's **Bot Settings → Threaded mode** and enable it. Without this, private-chat topics cannot be created.
71
- 3. Open the bot chat, tap the bot name and enable **Topics**. **All** is Telegram's default topic; the bot creates **System** for lifecycle notifications.
72
+ 3. Open the bot chat, tap the bot name and enable **Topics**. **All** is Telegram's default topic.
72
73
  4. Send `/start` in **All**. With an empty user list the bot replies with your user id and chat id, plus the Threaded-mode reminder.
73
74
  5. Add your user id to **Allowed users** and press **Save** (or **Restart bot**).
74
75
  6. In **All** send `/sessions` to create a new session or pick an existing DSH session. The dialog continues in its own topic.
package/index.js CHANGED
@@ -150,6 +150,19 @@ function fitAnswerText(text, limit = ANSWER_MARKDOWN_LIMIT) {
150
150
  return prefix.length === 0 ? '' : `${prefix}…`
151
151
  }
152
152
 
153
+ /**
154
+ * Trim a session preview at a line boundary so formatting is not cut mid-construct.
155
+ * @param text - raw assistant Markdown.
156
+ * @param limit - maximum characters.
157
+ * @returns clipped preview.
158
+ */
159
+ function clipPreview(text, limit = 2500) {
160
+ if (text.length <= limit) return text
161
+ const cut = text.slice(0, limit)
162
+ const newline = cut.lastIndexOf('\n')
163
+ return newline > limit / 2 ? cut.slice(0, newline) : cut
164
+ }
165
+
153
166
  /**
154
167
  * Split raw Markdown into chunks whose converted MarkdownV2 fits Telegram.
155
168
  * @param text - complete answer.
@@ -398,27 +411,23 @@ async function loadState() {
398
411
  return { version: 2, sessions: {}, threads: {} }
399
412
  }
400
413
 
401
- /** Durable control/system topic ids for the bot chat. */
414
+ /** Durable control-chat id for lifecycle notifications. */
402
415
  const TOPICS_PATH = join(STATUS_DIR, 'topics.json')
403
416
 
404
417
  /**
405
- * Read the control/system topic mapping.
406
- * @returns the mapping, or empty ids when absent.
418
+ * Read the control chat used for lifecycle notifications.
419
+ * @returns the chat id, or 0 when absent.
407
420
  */
408
421
  async function loadTopics() {
409
422
  try {
410
423
  const parsed = JSON.parse(await readFile(TOPICS_PATH, 'utf8'))
411
- if (parsed !== null && typeof parsed === 'object') {
412
- return {
413
- chatId: typeof parsed.chatId === 'number' ? parsed.chatId : 0,
414
- allThreadId: typeof parsed.allThreadId === 'number' ? parsed.allThreadId : 0,
415
- systemThreadId: typeof parsed.systemThreadId === 'number' ? parsed.systemThreadId : 0,
416
- }
424
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.chatId === 'number') {
425
+ return { chatId: parsed.chatId }
417
426
  }
418
427
  } catch {
419
- // Topics are optional until /start creates them.
428
+ // The control chat is learned on the first /start.
420
429
  }
421
- return { chatId: 0, allThreadId: 0, systemThreadId: 0 }
430
+ return { chatId: 0 }
422
431
  }
423
432
 
424
433
  /** Last published runtime snapshot, mirrored to a local diagnostics file. */
@@ -430,7 +439,7 @@ let runtimeStatus = {
430
439
  transport: 'https-ipv4',
431
440
  version: '0.3.0',
432
441
  agentBridge: 'v1',
433
- codeRevision: 'threaded-mode',
442
+ codeRevision: 'preview-format',
434
443
  lastPollAt: 0,
435
444
  lastMessageAt: 0,
436
445
  lastChatId: null,
@@ -483,8 +492,8 @@ export function apply(ctx, config) {
483
492
  const chats = new Map()
484
493
  /** Agent API calls use this lifetime; the bot polling lifetime is separate. */
485
494
  const bridgeAbort = new AbortController()
486
- /** Control/System topic ids. */
487
- let topics = { chatId: 0, allThreadId: 0, systemThreadId: 0 }
495
+ /** Control chat id used for lifecycle notifications. */
496
+ let topics = { chatId: 0 }
488
497
  /** Resolves after the mapping and topic files have been read. */
489
498
  const stateReady = Promise.all([
490
499
  loadState().then((loaded) => { state = loaded }),
@@ -539,6 +548,7 @@ export function apply(ctx, config) {
539
548
  toolTimer: null,
540
549
  toolLastText: '',
541
550
  boundAgent: undefined,
551
+ questionAgent: undefined,
542
552
  }
543
553
  chats.set(key, chat)
544
554
  }
@@ -1121,6 +1131,99 @@ export function apply(ctx, config) {
1121
1131
  }
1122
1132
  }
1123
1133
 
1134
+ /**
1135
+ * Send one Markdown-formatted message with an inline keyboard.
1136
+ * @param chatId - target chat.
1137
+ * @param threadId - topic thread.
1138
+ * @param text - raw Markdown body.
1139
+ * @param keyboard - inline keyboard rows.
1140
+ * @param signal - request lifetime.
1141
+ * @returns the Bot API result, or undefined.
1142
+ */
1143
+ async function sendKeyboardMarkdown(chatId, threadId, text, keyboard, signal) {
1144
+ const body = toTelegramMarkdown(text)
1145
+ try {
1146
+ return await call('sendMessage', {
1147
+ chat_id: chatId,
1148
+ ...(threadId === undefined || threadId === 0 ? {} : { message_thread_id: threadId }),
1149
+ text: body,
1150
+ parse_mode: 'MarkdownV2',
1151
+ reply_markup: { inline_keyboard: keyboard },
1152
+ }, signal)
1153
+ } catch (error) {
1154
+ const detail = messageOf(error)
1155
+ if (!signal.aborted) {
1156
+ warn(`MarkdownV2 keyboard send failed, falling back: ${detail}`)
1157
+ await publishStatus({ lastFormatFallback: detail })
1158
+ }
1159
+ return call('sendMessage', {
1160
+ chat_id: chatId,
1161
+ ...(threadId === undefined || threadId === 0 ? {} : { message_thread_id: threadId }),
1162
+ text,
1163
+ reply_markup: { inline_keyboard: keyboard },
1164
+ }, signal).catch(() => undefined)
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Edit one Markdown-formatted message and replace its keyboard.
1170
+ * @param chatId - target chat.
1171
+ * @param threadId - topic thread.
1172
+ * @param messageId - message to edit.
1173
+ * @param text - raw Markdown body.
1174
+ * @param keyboard - inline keyboard rows.
1175
+ * @param signal - request lifetime.
1176
+ * @returns true when the edit succeeded.
1177
+ */
1178
+ async function editKeyboardMarkdown(chatId, threadId, messageId, text, keyboard, signal) {
1179
+ const body = toTelegramMarkdown(text)
1180
+ try {
1181
+ await call('editMessageText', {
1182
+ chat_id: chatId,
1183
+ message_id: messageId,
1184
+ ...(threadId === undefined || threadId === 0 ? {} : { message_thread_id: threadId }),
1185
+ text: body,
1186
+ parse_mode: 'MarkdownV2',
1187
+ reply_markup: { inline_keyboard: keyboard },
1188
+ }, signal)
1189
+ return true
1190
+ } catch (error) {
1191
+ const detail = messageOf(error)
1192
+ if (/not modified/i.test(detail)) return true
1193
+ if (!signal.aborted) {
1194
+ warn(`MarkdownV2 keyboard edit failed, falling back: ${detail}`)
1195
+ await publishStatus({ lastFormatFallback: detail })
1196
+ }
1197
+ try {
1198
+ await call('editMessageText', {
1199
+ chat_id: chatId,
1200
+ message_id: messageId,
1201
+ ...(threadId === undefined || threadId === 0 ? {} : { message_thread_id: threadId }),
1202
+ text,
1203
+ reply_markup: { inline_keyboard: keyboard },
1204
+ }, signal)
1205
+ return true
1206
+ } catch {
1207
+ return false
1208
+ }
1209
+ }
1210
+ }
1211
+
1212
+ /**
1213
+ * Render a Markdown main menu, editing the previous one when possible.
1214
+ * @param chatId - target chat.
1215
+ * @param threadId - topic thread.
1216
+ * @param messageId - panel message to edit.
1217
+ * @param text - raw Markdown body.
1218
+ * @param keyboard - inline keyboard rows.
1219
+ * @param signal - request lifetime.
1220
+ * @returns resolves after the send/edit.
1221
+ */
1222
+ async function renderMenuMarkdown(chatId, threadId, messageId, text, keyboard, signal) {
1223
+ if (messageId !== undefined && await editKeyboardMarkdown(chatId, threadId, messageId, text, keyboard, signal)) return
1224
+ await sendKeyboardMarkdown(chatId, threadId, text, keyboard, signal)
1225
+ }
1226
+
1124
1227
  /**
1125
1228
  * Split text into Telegram-sized chunks.
1126
1229
  * @param text - full response.
@@ -1154,6 +1257,8 @@ export function apply(ctx, config) {
1154
1257
 
1155
1258
  /** Pending inline approval prompts keyed by short callback id. */
1156
1259
  const pendingApprovals = new Map()
1260
+ /** Pending structured user questions keyed by short callback id. */
1261
+ const pendingQuestions = new Map()
1157
1262
 
1158
1263
  /**
1159
1264
  * Resolve one pending approval and update its prompt in place.
@@ -1235,6 +1340,292 @@ export function apply(ctx, config) {
1235
1340
  })
1236
1341
  }
1237
1342
 
1343
+ /**
1344
+ * Find a pending question batch for one Telegram topic.
1345
+ * @param chatId - Telegram chat id.
1346
+ * @param threadId - topic thread.
1347
+ * @returns the pending batch, if any.
1348
+ */
1349
+ function pendingQuestionFor(chatId, threadId) {
1350
+ for (const pending of pendingQuestions.values()) {
1351
+ if (pending.chatId === chatId && pending.threadId === threadId) return pending
1352
+ }
1353
+ return undefined
1354
+ }
1355
+
1356
+ /**
1357
+ * Render the current question prompt as raw Markdown.
1358
+ * @param pending - in-flight question batch.
1359
+ * @returns Markdown body.
1360
+ */
1361
+ function questionText(pending) {
1362
+ const question = pending.questions[pending.index]
1363
+ const lines = []
1364
+ if (question.header !== undefined) lines.push(`**${question.header}**`)
1365
+ if (question.intent?.kind === 'plan-review') lines.push(`**${tr('Plan review', 'Проверка плана')}**`)
1366
+ lines.push(question.question)
1367
+ if (question.detail !== undefined) {
1368
+ const detail = question.detail.length > 3000 ? `${question.detail.slice(0, 2999)}…` : question.detail
1369
+ lines.push('', detail)
1370
+ }
1371
+ lines.push('', `_${tr('Question', 'Вопрос')} ${pending.index + 1}/${pending.questions.length}_`)
1372
+ if (pending.awaitingCustom) lines.push(`**${tr('Reply to this message with your answer.', 'Ответь на это сообщение своим вариантом.')}**`)
1373
+ else if ((question.options ?? []).length === 0) lines.push(`**${tr('Reply to this message with your answer.', 'Ответь на это сообщение своим вариантом.')}**`)
1374
+ return lines.join('\n')
1375
+ }
1376
+
1377
+ /**
1378
+ * Build the inline keyboard for the current question.
1379
+ * @param pending - in-flight question batch.
1380
+ * @returns keyboard rows.
1381
+ */
1382
+ function questionKeyboard(pending) {
1383
+ const question = pending.questions[pending.index]
1384
+ const rows = []
1385
+ for (const [index, option] of (question.options ?? []).slice(0, 8).entries()) {
1386
+ const mark = pending.selected.has(option.label) ? '✅ ' : ''
1387
+ rows.push([{ text: `${mark}${shortLabel(option.label, 50)}`, callback_data: `q:${pending.id}:${index}` }])
1388
+ }
1389
+ if (question.multiSelect === true) {
1390
+ rows.push([{ text: tr('✅ Done', '✅ Готово'), callback_data: `q:${pending.id}:done` }])
1391
+ }
1392
+ if ((question.options ?? []).length > 0) {
1393
+ rows.push([{ text: tr('✍️ Custom answer', '✍️ Свой ответ'), callback_data: `q:${pending.id}:custom` }])
1394
+ }
1395
+ return rows
1396
+ }
1397
+
1398
+ /**
1399
+ * Send or update the current question prompt in its topic.
1400
+ * @param pending - in-flight question batch.
1401
+ * @returns true when Telegram accepted the prompt.
1402
+ */
1403
+ async function sendQuestionPrompt(pending) {
1404
+ const question = pending.questions[pending.index]
1405
+ pending.awaitingCustom = (question.options ?? []).length === 0
1406
+ const keyboard = pending.awaitingCustom ? [] : questionKeyboard(pending)
1407
+ const result = await sendKeyboardMarkdown(
1408
+ pending.chatId,
1409
+ pending.threadId,
1410
+ questionText(pending),
1411
+ keyboard,
1412
+ pending.signal,
1413
+ )
1414
+ if (result?.message_id === undefined) return false
1415
+ pending.messageId = result.message_id
1416
+ return true
1417
+ }
1418
+
1419
+ /**
1420
+ * Finish a question batch and resolve the DSH request.
1421
+ * @param pending - completed batch.
1422
+ */
1423
+ function finishQuestions(pending) {
1424
+ if (!pendingQuestions.delete(pending.id)) return
1425
+ pending.signal?.removeEventListener('abort', pending.onAbort)
1426
+ pending.resolve({ answers: pending.answers })
1427
+ void editKeyboardMarkdown(
1428
+ pending.chatId,
1429
+ pending.threadId,
1430
+ pending.messageId,
1431
+ `✅ **${tr('Answer recorded', 'Ответ принят')}**`,
1432
+ [],
1433
+ bridgeAbort.signal,
1434
+ )
1435
+ }
1436
+
1437
+ /**
1438
+ * Record one answer and advance to the next question.
1439
+ * @param pending - in-flight batch.
1440
+ * @param selected - selected option labels.
1441
+ * @param custom - optional free-text answer.
1442
+ * @param signal - request lifetime.
1443
+ */
1444
+ async function completeQuestion(pending, selected, custom, signal) {
1445
+ if (pending.busy) return
1446
+ pending.busy = true
1447
+ try {
1448
+ const question = pending.questions[pending.index]
1449
+ pending.answers.push({
1450
+ id: question.id,
1451
+ selected,
1452
+ ...(custom === undefined || custom.length === 0 ? {} : { custom }),
1453
+ })
1454
+ const summary = [
1455
+ `✅ **${tr('Answer recorded', 'Ответ принят')}**`,
1456
+ '',
1457
+ question.question,
1458
+ ...(selected.length > 0 ? [`${tr('Selected', 'Выбрано')}: ${selected.join(', ')}`] : []),
1459
+ ...(custom !== undefined && custom.length > 0 ? [`${tr('Custom', 'Свой ответ')}: ${custom}`] : []),
1460
+ ].join('\n')
1461
+ await editKeyboardMarkdown(pending.chatId, pending.threadId, pending.messageId, summary, [], signal)
1462
+ pending.selected = new Set()
1463
+ pending.awaitingCustom = false
1464
+ pending.index += 1
1465
+ if (pending.index < pending.questions.length) {
1466
+ if (!await sendQuestionPrompt(pending)) {
1467
+ pendingQuestions.delete(pending.id)
1468
+ pending.signal?.removeEventListener('abort', pending.onAbort)
1469
+ pending.reject(new Error('Telegram could not display the next question'))
1470
+ }
1471
+ } else {
1472
+ finishQuestions(pending)
1473
+ }
1474
+ } finally {
1475
+ pending.busy = false
1476
+ }
1477
+ }
1478
+
1479
+ /**
1480
+ * Ask the Telegram user one structured question batch.
1481
+ * @param agent - requesting agent.
1482
+ * @param chat - chat bridge state.
1483
+ * @param req - user-questions request.
1484
+ * @param next - downstream answerer chain.
1485
+ * @returns the structured answer.
1486
+ */
1487
+ async function requestUserQuestions(agent, chat, req, next) {
1488
+ if (chat.boundAgent !== agent || typeof chat.sessionId !== 'string') return next()
1489
+ if (pendingQuestionFor(chat.chatId, chat.threadId) !== undefined) return next()
1490
+ const signal = req.signal ?? bridgeAbort.signal
1491
+ if (signal.aborted) return next()
1492
+ const id = randomUUID().replaceAll('-', '').slice(0, 10)
1493
+ let resolvePromise
1494
+ let rejectPromise
1495
+ const promise = new Promise((resolve, reject) => {
1496
+ resolvePromise = resolve
1497
+ rejectPromise = reject
1498
+ })
1499
+ const pending = {
1500
+ id,
1501
+ chatId: chat.chatId,
1502
+ threadId: chat.threadId,
1503
+ signal,
1504
+ questions: Array.isArray(req.questions) ? req.questions : [],
1505
+ index: 0,
1506
+ answers: [],
1507
+ selected: new Set(),
1508
+ awaitingCustom: false,
1509
+ busy: false,
1510
+ messageId: undefined,
1511
+ resolve: resolvePromise,
1512
+ reject: rejectPromise,
1513
+ onAbort: undefined,
1514
+ }
1515
+ if (pending.questions.length === 0) return next()
1516
+ pending.onAbort = () => {
1517
+ if (!pendingQuestions.delete(id)) return
1518
+ signal.removeEventListener('abort', pending.onAbort)
1519
+ rejectPromise(signal.reason ?? new Error('User question cancelled'))
1520
+ if (pending.messageId !== undefined) {
1521
+ void editKeyboardMarkdown(
1522
+ pending.chatId,
1523
+ pending.threadId,
1524
+ pending.messageId,
1525
+ `⏹ **${tr('Question cancelled', 'Вопрос отменён')}**`,
1526
+ [],
1527
+ bridgeAbort.signal,
1528
+ )
1529
+ }
1530
+ }
1531
+ signal.addEventListener('abort', pending.onAbort, { once: true })
1532
+ pendingQuestions.set(id, pending)
1533
+ let sent = false
1534
+ try {
1535
+ sent = await sendQuestionPrompt(pending)
1536
+ } catch (error) {
1537
+ if (!signal.aborted) warn(`user question prompt failed: ${messageOf(error)}`)
1538
+ }
1539
+ if (!sent) {
1540
+ pendingQuestions.delete(id)
1541
+ signal.removeEventListener('abort', pending.onAbort)
1542
+ return next()
1543
+ }
1544
+ return promise
1545
+ }
1546
+
1547
+ /**
1548
+ * Handle one callback belonging to a pending user question.
1549
+ * @param ref - callback payload after `q:`.
1550
+ * @param chatId - Telegram chat id.
1551
+ * @param threadId - topic thread.
1552
+ * @param signal - request lifetime.
1553
+ * @returns true when the callback belonged to a question.
1554
+ */
1555
+ async function handleQuestionCallback(ref, chatId, threadId, signal) {
1556
+ const colon = ref.indexOf(':')
1557
+ const id = colon === -1 ? ref : ref.slice(0, colon)
1558
+ const action = colon === -1 ? '' : ref.slice(colon + 1)
1559
+ const pending = pendingQuestions.get(id)
1560
+ if (pending === undefined || pending.chatId !== chatId || pending.threadId !== threadId) {
1561
+ await send(chatId, tr('This question is already closed.', 'Этот вопрос уже закрыт.'), signal, threadId)
1562
+ return true
1563
+ }
1564
+ if (pending.busy) return true
1565
+ const question = pending.questions[pending.index]
1566
+ if (action === 'custom') {
1567
+ pending.awaitingCustom = true
1568
+ await editKeyboardMarkdown(pending.chatId, pending.threadId, pending.messageId, questionText(pending), [], signal)
1569
+ return true
1570
+ }
1571
+ if (action === 'done') {
1572
+ await completeQuestion(pending, [...pending.selected], undefined, signal)
1573
+ return true
1574
+ }
1575
+ const optionIndex = Number.parseInt(action, 10)
1576
+ if (!Number.isFinite(optionIndex) || question.options?.[optionIndex] === undefined) return true
1577
+ const label = question.options[optionIndex].label
1578
+ if (question.multiSelect === true) {
1579
+ if (pending.selected.has(label)) pending.selected.delete(label)
1580
+ else pending.selected.add(label)
1581
+ await editKeyboardMarkdown(pending.chatId, pending.threadId, pending.messageId, questionText(pending), questionKeyboard(pending), signal)
1582
+ return true
1583
+ }
1584
+ await completeQuestion(pending, [label], undefined, signal)
1585
+ return true
1586
+ }
1587
+
1588
+ /**
1589
+ * Consume a free-text answer for the active question in this topic.
1590
+ * @param chat - chat bridge state.
1591
+ * @param text - user message.
1592
+ * @param signal - request lifetime.
1593
+ * @returns true when the message was an answer.
1594
+ */
1595
+ async function consumeQuestionReply(chat, text, signal) {
1596
+ const command = text.trim().split(/\s+/u)[0]?.toLowerCase()
1597
+ if (BOT_COMMANDS.some((entry) => `/${entry.command}` === command)) return false
1598
+ const pending = pendingQuestionFor(chat.chatId, chat.threadId)
1599
+ if (pending === undefined || !pending.awaitingCustom) return false
1600
+ const question = pending.questions[pending.index]
1601
+ const selected = question.multiSelect === true ? [...pending.selected] : []
1602
+ await completeQuestion(pending, selected, text.trim(), signal)
1603
+ return true
1604
+ }
1605
+
1606
+ /**
1607
+ * Cancel any pending questions in one topic.
1608
+ * @param chat - chat bridge state.
1609
+ */
1610
+ function cancelQuestionsForChat(chat) {
1611
+ for (const pending of [...pendingQuestions.values()]) {
1612
+ if (pending.chatId !== chat.chatId || pending.threadId !== chat.threadId) continue
1613
+ pendingQuestions.delete(pending.id)
1614
+ pending.signal?.removeEventListener('abort', pending.onAbort)
1615
+ pending.reject(new Error('Question cancelled by /stop'))
1616
+ if (pending.messageId !== undefined) {
1617
+ void editKeyboardMarkdown(
1618
+ pending.chatId,
1619
+ pending.threadId,
1620
+ pending.messageId,
1621
+ `⏹ **${tr('Question cancelled', 'Вопрос отменён')}**`,
1622
+ [],
1623
+ bridgeAbort.signal,
1624
+ )
1625
+ }
1626
+ }
1627
+ }
1628
+
1238
1629
  /**
1239
1630
  * Register stream/error listeners on one live agent.
1240
1631
  * @param agent - agent to observe.
@@ -1258,6 +1649,13 @@ export function apply(ctx, config) {
1258
1649
  return requestApproval(agent, chat, req, next)
1259
1650
  }, { prepend: true })
1260
1651
  }
1652
+ if (chat.questionAgent !== agent) {
1653
+ chat.questionAgent = agent
1654
+ agent.ctx.on('user-questions/request', (req, next) => {
1655
+ if (chat.boundAgent !== agent) return next()
1656
+ return requestUserQuestions(agent, chat, req, next)
1657
+ }, { prepend: true })
1658
+ }
1261
1659
  }
1262
1660
 
1263
1661
  /**
@@ -1832,46 +2230,30 @@ export function apply(ctx, config) {
1832
2230
  }
1833
2231
 
1834
2232
  /**
1835
- * Ensure the All and System topics exist for this control chat.
1836
- * @param chatId - chat with Topics enabled.
1837
- * @param signal - request lifetime.
2233
+ * Remember the chat that owns lifecycle notifications (All, thread 0).
2234
+ * @param chatId - control chat id.
1838
2235
  */
1839
- async function ensureTopics(chatId, signal) {
1840
- if (topics.chatId === chatId && topics.systemThreadId !== 0) {
1841
- if (topics.allThreadId !== 0) {
1842
- topics.allThreadId = 0
1843
- await writeFile(TOPICS_PATH, JSON.stringify(topics, null, 2) + '\n', { mode: 0o600 })
1844
- }
1845
- return
1846
- }
1847
- try {
1848
- const systemThreadId = await createTopic(chatId, 'System', signal)
1849
- topics = { chatId, allThreadId: 0, systemThreadId: systemThreadId ?? 0 }
1850
- await mkdir(STATUS_DIR, { recursive: true })
1851
- await writeFile(TOPICS_PATH, JSON.stringify(topics, null, 2) + '\n', { mode: 0o600 })
1852
- } catch (error) {
1853
- if (!signal.aborted) warn(`ensureTopics failed: ${messageOf(error)}`)
1854
- }
2236
+ async function rememberControlChat(chatId) {
2237
+ if (topics.chatId === chatId) return
2238
+ topics = { chatId }
2239
+ await mkdir(STATUS_DIR, { recursive: true })
2240
+ await writeFile(TOPICS_PATH, JSON.stringify(topics, null, 2) + '\n', { mode: 0o600 })
1855
2241
  }
1856
2242
 
1857
2243
  /**
1858
- * Publish a system notification to System and All.
2244
+ * Publish a lifecycle notification into All.
1859
2245
  * @param text - HTML-safe body.
1860
2246
  * @param signal - request lifetime.
1861
2247
  */
1862
- async function notifySystem(text, signal) {
2248
+ async function notifyAll(text, signal) {
1863
2249
  if (topics.chatId === 0) return
1864
- const ids = [...new Set([topics.systemThreadId, topics.allThreadId])]
1865
- for (const threadId of ids) {
1866
- await call('sendMessage', {
1867
- chat_id: topics.chatId,
1868
- ...(threadId === 0 ? {} : { message_thread_id: threadId }),
1869
- text,
1870
- parse_mode: 'HTML',
1871
- }, signal).catch((error) => {
1872
- if (!signal.aborted) warn(`system notification failed: ${messageOf(error)}`)
1873
- })
1874
- }
2250
+ await call('sendMessage', {
2251
+ chat_id: topics.chatId,
2252
+ text,
2253
+ parse_mode: 'HTML',
2254
+ }, signal).catch((error) => {
2255
+ if (!signal.aborted) warn(`lifecycle notification failed: ${messageOf(error)}`)
2256
+ })
1875
2257
  }
1876
2258
 
1877
2259
  /** @returns the display name for a permission preset. */
@@ -2164,10 +2546,30 @@ export function apply(ctx, config) {
2164
2546
  * @param signal - request lifetime.
2165
2547
  * @returns topic record, or undefined.
2166
2548
  */
2549
+ async function deliverSessionTopic(record, chatId, signal) {
2550
+ const preview = await lastAssistantText(record.sessionId)
2551
+ const title = record.title || await sessionTitle(record.sessionId)
2552
+ const delivered = await sendKeyboardMarkdown(
2553
+ chatId,
2554
+ record.threadId,
2555
+ `📂 **${title}**\n\n${preview.length > 0 ? clipPreview(preview) : tr('No assistant messages yet.', 'Пока нет ответов ассистента.')}\n\n${tr('Send a message to continue this session.', 'Напиши сообщение, чтобы продолжить эту сессию.')}`,
2556
+ [],
2557
+ signal,
2558
+ )
2559
+ if (delivered === undefined) return false
2560
+ await sendMenu(chatId, record.threadId, await chatFor(chatId, record.threadId), signal)
2561
+ return true
2562
+ }
2563
+
2167
2564
  async function openTopicForSession(sessionId, chatId, signal) {
2168
2565
  const existing = state.sessions[sessionId]
2169
2566
  if (existing !== undefined && existing.chatId === chatId && typeof existing.threadId === 'number') {
2170
- return existing
2567
+ // Re-open the stored topic and make it visible in Telegram. If the
2568
+ // thread was deleted, drop the stale mapping and create a fresh one.
2569
+ if (await deliverSessionTopic(existing, chatId, signal)) return existing
2570
+ delete state.sessions[sessionId]
2571
+ delete state.threads[`${chatId}:${existing.threadId}`]
2572
+ await persistState()
2171
2573
  }
2172
2574
  const title = await sessionTitle(sessionId)
2173
2575
  const records = await ctx.get('sessionQuery')?.listSessions(signal)
@@ -2178,16 +2580,8 @@ export function apply(ctx, config) {
2178
2580
  state.sessions[sessionId] = record
2179
2581
  state.threads[`${chatId}:${threadId}`] = sessionId
2180
2582
  await persistState()
2181
- await notifySystem(`📂 ${tr('Session opened', 'Открыта сессия')} <code>${escapeHtml(sessionId)}</code>`, signal)
2182
- const preview = await lastAssistantText(sessionId)
2183
- await sendKeyboard(
2184
- chatId,
2185
- threadId,
2186
- `📂 <b>${escapeHtml(title)}</b>\n\n${preview.length > 0 ? escapeHtml(preview.slice(0, 1200)) : tr('No assistant messages yet.', 'Пока нет ответов ассистента.')}\n\n${tr('Send a message to continue this session.', 'Напиши сообщение, чтобы продолжить эту сессию.')}`,
2187
- [],
2188
- signal,
2189
- )
2190
- await sendMenu(chatId, threadId, await chatFor(chatId, threadId), signal)
2583
+ await notifyAll(`📂 ${tr('Session opened', 'Открыта сессия')} <code>${escapeHtml(sessionId)}</code>`, signal)
2584
+ await deliverSessionTopic(record, chatId, signal)
2191
2585
  return record
2192
2586
  }
2193
2587
 
@@ -2277,10 +2671,10 @@ export function apply(ctx, config) {
2277
2671
  const record = records?.find((entry) => entry.header.id === sessionId)
2278
2672
  const title = await sessionTitle(sessionId, record?.header)
2279
2673
  const text =
2280
- `<b>${escapeHtml(title)}</b>\n` +
2281
- `${tr('Workspace', 'Workspace')}: <code>${escapeHtml(record?.header.cwd ?? 'unknown')}</code>\n` +
2674
+ `**${title}**\n` +
2675
+ `${tr('Workspace', 'Workspace')}: \`${String(record?.header.cwd ?? 'unknown').replaceAll('`', '\\`')}\`\n` +
2282
2676
  `${tr('Status', 'Статус')}: ${record?.live ? tr('live', 'активна') : tr('persisted', 'сохранена')}\n\n` +
2283
- `${preview.length > 0 ? escapeHtml(preview.slice(0, 1600)) : tr('No assistant messages yet.', 'Пока нет ответов ассистента.')}`
2677
+ `${preview.length > 0 ? clipPreview(preview) : tr('No assistant messages yet.', 'Пока нет ответов ассистента.')}`
2284
2678
  const workspace = ctx.get('workspaceRegistry')?.list()
2285
2679
  ?.find((entry) => entry.path === record?.header.cwd)
2286
2680
  const rows = []
@@ -2295,7 +2689,7 @@ export function apply(ctx, config) {
2295
2689
  text: tr('⬅️ Back', '⬅️ Назад'),
2296
2690
  callback_data: `w:${workspaceRef}`,
2297
2691
  }])
2298
- await renderMenu(chatId, threadId, messageId, text, rows, signal)
2692
+ await renderMenuMarkdown(chatId, threadId, messageId, text, rows, signal)
2299
2693
  }
2300
2694
 
2301
2695
  /**
@@ -2333,6 +2727,10 @@ export function apply(ctx, config) {
2333
2727
  settleApproval(id, action === 'y' ? 'allowed-once' : 'rejected')
2334
2728
  return
2335
2729
  }
2730
+ if (prefix === 'q') {
2731
+ await handleQuestionCallback(ref, chat.id, threadId, signal)
2732
+ return
2733
+ }
2336
2734
  if (prefix === 'o') {
2337
2735
  const chatState = await chatFor(chat.id, threadId)
2338
2736
  if (typeof chatState.sessionId !== 'string') {
@@ -2521,6 +2919,7 @@ export function apply(ctx, config) {
2521
2919
  }
2522
2920
 
2523
2921
  const chatState = await chatFor(chat.id, threadId)
2922
+ if (await consumeQuestionReply(chatState, text, signal)) return
2524
2923
  const inSession = typeof chatState.sessionId === 'string'
2525
2924
  const parts = text.trim().split(/\s+/u)
2526
2925
  const command = parts[0]?.toLowerCase()
@@ -2547,7 +2946,7 @@ export function apply(ctx, config) {
2547
2946
  }
2548
2947
 
2549
2948
  if (command === '/start' || command === '/help') {
2550
- if (!inSession) await ensureTopics(chat.id, signal)
2949
+ await rememberControlChat(chat.id)
2551
2950
  await send(
2552
2951
  chat.id,
2553
2952
  `<b>DeepSeek Harness Telegram bot</b>\n\n` +
@@ -2613,11 +3012,11 @@ export function apply(ctx, config) {
2613
3012
  }
2614
3013
  const title = await sessionTitle(chatState.sessionId)
2615
3014
  const body = texts
2616
- .map((text, index) => `${limit === 1 ? '' : `<b>${index + 1}.</b> `}${escapeHtml(text.slice(0, 3500))}`)
3015
+ .map((text, index) => `${limit === 1 ? '' : `**${index + 1}.** `}${text.slice(0, 3500)}`)
2617
3016
  .join('\n\n')
2618
- const header = limit === 1 ? tr('Latest assistant answer', 'Последний ответ ассистента') : `${tr('Latest', 'Последние')} ${texts.length} ${tr('answers', 'ответа')}`
2619
- for (const chunk of splitTelegramText(`${header} · ${escapeHtml(title)}\n\n${body}`)) {
2620
- await send(chat.id, chunk, signal, threadId)
3017
+ const header = limit === 1 ? `**${tr('Latest assistant answer', 'Последний ответ ассистента')}**` : `**${tr('Latest', 'Последние')} ${texts.length} ${tr('answers', 'ответа')}**`
3018
+ for (const chunk of splitAnswerText(`${header} · ${title}\n\n${body}`)) {
3019
+ await sendAnswer(chat.id, chunk, signal, threadId)
2621
3020
  }
2622
3021
  return
2623
3022
  }
@@ -2673,6 +3072,7 @@ export function apply(ctx, config) {
2673
3072
  chatState.done = true
2674
3073
  clearEditTimer(chatState)
2675
3074
  clearAuxTimers(chatState)
3075
+ cancelQuestionsForChat(chatState)
2676
3076
  if (chatState.thinkingMessageId !== null) {
2677
3077
  await deleteMessage(chat.id, threadId, chatState.thinkingMessageId)
2678
3078
  chatState.thinkingMessageId = null
@@ -2754,7 +3154,7 @@ export function apply(ctx, config) {
2754
3154
  info(`connected as @${me.username ?? me.id}`)
2755
3155
  await publishStatus({ state: 'running', detail: '', username: me.username ?? String(me.id) })
2756
3156
  await publishCommands(tokenLifetime.signal)
2757
- await notifySystem(`🟢 ${tr('Bot online', 'Бот онлайн')}: @${escapeHtml(me.username ?? String(me.id))}`, tokenLifetime.signal)
3157
+ await notifyAll(`🟢 ${tr('Bot online', 'Бот онлайн')}: @${escapeHtml(me.username ?? String(me.id))}`, tokenLifetime.signal)
2758
3158
  } catch (error) {
2759
3159
  if (!tokenLifetime.signal.aborted) {
2760
3160
  warn(`getMe failed: ${messageOf(error)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-telegram-bot",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Telegram sessions, topics, model controls and approvals for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",