dsh-telegram-bot 0.1.0 → 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 (4) hide show
  1. package/README.md +25 -27
  2. package/client.js +7 -50
  3. package/index.js +480 -104
  4. package/package.json +4 -4
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
 
@@ -14,26 +14,27 @@ The bot connects to Telegram, enforces the allowlists, and bridges DSH agent ses
14
14
  - **Allowed chats** — Telegram chat ids or `@channelnames`; empty means private chats only.
15
15
  - **Enable bot** toggle.
16
16
  - **Restart bot** button — reapplies the saved settings and restarts long polling.
17
- - **Quick controls on the Plugins page** — the bundle's detail page shows token state, access counts, and the same restart button. The bundle switch itself is the on/off control; turning the plugin off unmounts it and aborts polling.
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
- - **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.
20
- - **All and System topics** — All is Telegram's default topic and the management hub; System receives bot lifecycle notifications, mirrored into All.
21
- - **Thinking and tool calls** — reasoning deltas and tool calls are mirrored into the topic, with independent **Show model thinking** / **Show tool calls** switches.
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 topic** — Telegram's default topic and the management hub; lifecycle notifications are posted there.
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
- - **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` reposts the bottom controls.
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
- - **Command hints** — the runtime publishes the list with `setMyCommands`, so Telegram shows the commands and descriptions when the user types `/`. English is the default; Russian clients get localized descriptions.
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`.
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
 
31
32
  ## Install
32
33
 
33
- From this checkout:
34
+ From this checkout, run in the repository root:
34
35
 
35
36
  ```sh
36
- npx @deepseek-ai/dsh plugin --profile web add /home/boobaw/dsh-plugin-tg-bot
37
+ npx @deepseek-ai/dsh plugin --profile web add .
37
38
  ```
38
39
 
39
40
  After publishing to npm:
@@ -44,7 +45,7 @@ npx @deepseek-ai/dsh plugin --profile web add dsh-telegram-bot
44
45
 
45
46
  `dsh plugin add` installs the package and selects its bundle in `dsh.profile.bundles` when the package declares `dsh.bundle`. Restart `dsh web` if the running profile does not pick the new bundle up through HMR.
46
47
 
47
- Then open **Settings → Telegram** (or **Plugins → dsh-telegram-bot**) in the Web UI.
48
+ Then open **Settings → Telegram** (or **Plugins → Telegram Bot**) in the Web UI.
48
49
 
49
50
  ## Configure
50
51
 
@@ -67,10 +68,11 @@ The token field reports only whether a token is configured. A token supplied by
67
68
  ### First run
68
69
 
69
70
  1. Create a bot with [@BotFather](https://t.me/BotFather) and save its token in **Settings → Telegram**.
70
- 2. Open the bot chat, tap the bot name and enable **Topics**. **All** is Telegram's default topic; the bot creates **System** for lifecycle notifications.
71
- 3. Send `/start` in **All**. With an empty user list the bot replies with your user id and chat id.
72
- 4. Add your user id to **Allowed users** and press **Save** (or **Restart bot**).
73
- 5. In **All** send `/sessions` to create a new session or pick an existing DSH session. The dialog continues in its own topic.
71
+ 2. In @BotFather open the bot's **Bot Settings → Threaded mode** and enable it. Without this, private-chat topics cannot be created.
72
+ 3. Open the bot chat, tap the bot name and enable **Topics**. **All** is Telegram's default topic.
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.
74
+ 5. Add your user id to **Allowed users** and press **Save** (or **Restart bot**).
75
+ 6. In **All** send `/sessions` to create a new session or pick an existing DSH session. The dialog continues in its own topic.
74
76
 
75
77
  The runtime starts only when **Enable bot** is on, a token is configured, and the profile process is running. It logs connection and polling diagnostics with the `[telegram]` prefix. Runtime state is at `$DSH_HOME/telegram/status.json`, and the topic → session mapping is at `$DSH_HOME/telegram/state.json` (neither contains the token).
76
78
 
@@ -127,15 +129,13 @@ No browser and no port are opened. `Ctrl+C` stops the process; `$DSH_HOME/telegr
127
129
 
128
130
  ## Plugins page
129
131
 
130
- The bundle detail page in **Plugins** renders a compact card with:
132
+ The bundle detail page in **Plugins** is a short informational panel:
131
133
 
132
- - token state and the current number of allowed users/chats;
133
- - **Restart bot**;
134
- - a pointer to **Settings → Telegram** for editing the full lists.
134
+ - it points to **Settings → Telegram** for token, access lists, language and display options;
135
+ - it lists the manual configuration files for headless setups;
136
+ - it does not duplicate the settings controls.
135
137
 
136
- There is deliberately no bot on/off switch on this page: the bundle switch above the card disables the whole plugin, and the runtime's `ctx.effect` cleanup aborts polling when it unmounts. **Settings → Telegram → Enable bot** remains a soft pause that keeps the plugin loaded.
137
-
138
- The full page remains in Settings because allowlist editing needs room for chips and validation.
138
+ The bundle switch above the panel is the plugin on/off control. **Settings → Telegram → Enable bot** is a soft pause that keeps the plugin loaded but stops polling.
139
139
 
140
140
  ## Security
141
141
 
@@ -178,11 +178,9 @@ A Git URL or tarball also works for DSH (`dsh plugin add github:you/dsh-telegram
178
178
 
179
179
  ## Roadmap
180
180
 
181
- 1. Approval and question answerers with inline buttons.
182
- 2. Tool-call cards and richer streaming presentation.
183
- 3. File attachments and voice input.
184
- 4. Model/preset selection from the chat.
185
- 5. Multiple chats and group/topic routing.
181
+ 1. File attachments and voice input.
182
+ 2. Multiple chats and group/topic routing.
183
+ 3. Richer tool-result summaries.
186
184
 
187
185
  ## License
188
186
 
package/client.js CHANGED
@@ -10,7 +10,7 @@ window.__ModuleLoader__.load({
10
10
  const { Button, SettingsSecretField, Switch } = primitives
11
11
  const h = React.createElement
12
12
 
13
- /** Loader row id / settings namespace, equal to the package name. */
13
+ /** Loader row id / settings namespace; the npm package name is dsh-telegram-bot. */
14
14
  const NS = 'telegram-bot'
15
15
  /** Default credential reference holding the Telegram bot token. */
16
16
  const DEFAULT_TOKEN_REF = 'TELEGRAM_BOT_TOKEN'
@@ -51,7 +51,6 @@ window.__ModuleLoader__.load({
51
51
  saving: 'Saving…',
52
52
  saved: 'Saved.',
53
53
  restart: 'Restart bot',
54
- restarting: 'Restarting…',
55
54
  restartRequested: 'Restart requested. Check Telegram or the DSH log.',
56
55
  restartFailed: 'The Host did not accept the restart request.',
57
56
  bundleSummary: 'Telegram bot',
@@ -61,20 +60,6 @@ window.__ModuleLoader__.load({
61
60
  pluginConfigSettings: 'plugin settings (cordis.patch.yml)',
62
61
  pluginConfigToken: 'bot token (credential store)',
63
62
  pluginConfigEnv: 'token fallback for headless deployments',
64
- running: 'Running',
65
- stopped: 'Stopped',
66
- runtimeMissingShort: 'Host not loaded',
67
- on: 'On',
68
- off: 'Off',
69
- pluginRuntimeHint: 'Runtime state from this DSH process. Turning the bundle off stops polling.',
70
- pluginQuickControls: 'Quick controls',
71
- pluginDisplay: 'Display',
72
- bundleSettingsHint: 'Full allowlists are in Settings → Telegram.',
73
- tokenConfiguredShort: 'token configured',
74
- tokenMissingShort: 'no token',
75
- usersShort: 'users',
76
- chatsShort: 'chats',
77
- bundleDisableHint: 'Turning the plugin off in Plugins stops polling.',
78
63
  hostNeedsRestart: 'The Telegram runtime is not loaded in this DSH process yet. Restart dsh web once; after that this page can restart the bot.',
79
64
  discard: 'Discard',
80
65
  saveFailed: 'The Host did not accept these values.',
@@ -121,7 +106,6 @@ window.__ModuleLoader__.load({
121
106
  saving: 'Сохранение…',
122
107
  saved: 'Сохранено.',
123
108
  restart: 'Перезапустить бота',
124
- restarting: 'Перезапуск…',
125
109
  restartRequested: 'Запрошен перезапуск. Проверьте Telegram или лог DSH.',
126
110
  restartFailed: 'Host не принял запрос на перезапуск.',
127
111
  bundleSummary: 'Telegram-бот',
@@ -131,20 +115,6 @@ window.__ModuleLoader__.load({
131
115
  pluginConfigToken: 'токен бота (credential store)',
132
116
  pluginConfigEnv: 'fallback токена для headless',
133
117
  pluginPageLocation: 'Открой Settings → Telegram, чтобы настроить плагин.',
134
- running: 'Работает',
135
- stopped: 'Остановлен',
136
- runtimeMissingShort: 'Host не загружен',
137
- on: 'Вкл',
138
- off: 'Выкл',
139
- pluginRuntimeHint: 'Состояние runtime в этом процессе DSH. Выключение bundle останавливает polling.',
140
- pluginQuickControls: 'Быстрые настройки',
141
- pluginDisplay: 'Отображение',
142
- bundleSettingsHint: 'Полные списки доступа — в Settings → Telegram.',
143
- tokenConfiguredShort: 'токен настроен',
144
- tokenMissingShort: 'нет токена',
145
- usersShort: 'пользователей',
146
- chatsShort: 'чатов',
147
- bundleDisableHint: 'Выключение плагина в Plugins останавливает polling.',
148
118
  hostNeedsRestart: 'Telegram runtime ещё не загружен в этот процесс DSH. Перезапусти dsh web один раз; после этого кнопка рестарта заработает.',
149
119
  discard: 'Отменить',
150
120
  saveFailed: 'Host не принял эти значения.',
@@ -189,22 +159,9 @@ window.__ModuleLoader__.load({
189
159
  .dtb-message{min-height:20px;font-size:12px;line-height:20px}
190
160
  .dtb-messageOk{color:var(--dsw-alias-state-success-primary)}
191
161
  .dtb-messageError{color:var(--dsw-alias-state-error-primary)}
192
- .dtb-bundle{display:flex;flex-direction:column;gap:12px}
193
162
  .dtb-plugin{display:flex;flex-direction:column;gap:16px;padding-top:4px}
194
- .dtb-pluginTop{display:flex;flex-direction:column;gap:8px}
195
- .dtb-bundleMeta{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
196
- .dtb-pluginGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
197
- .dtb-stat{display:flex;flex-direction:column;gap:2px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
198
- .dtb-statLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
199
- .dtb-statValue{font-size:15px;line-height:22px;font-weight:600}
200
- .dtb-pluginSection{display:flex;flex-direction:column;gap:10px}
201
- .dtb-pluginSectionTitle{margin:0;font-size:13px;font-weight:600;line-height:20px}
202
- .dtb-pluginControls{display:flex;flex-direction:column;gap:10px}
203
- .dtb-pluginControl{display:flex;align-items:center;justify-content:space-between;gap:16px}
204
- .dtb-pluginActions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
205
163
  .dtb-notice{margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
206
164
  .dtb-loading{padding:16px 0;color:var(--dsw-alias-label-tertiary);font-size:13px}
207
- .dtb-note{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:17px}
208
165
  @media (max-width:560px){.dtb-toggleRow{align-items:flex-start;flex-direction:column;gap:8px}.dtb-footer{align-items:stretch;flex-direction:column}.dtb-footerActions{justify-content:flex-end}}
209
166
  `
210
167
 
@@ -715,11 +672,11 @@ window.__ModuleLoader__.load({
715
672
 
716
673
 
717
674
  /**
718
- * Compact controls on the Plugins page's bundle detail. The full allowlists
719
- * stay in Settings → Telegram; this card owns the restart and token/status
720
- * summary. The bundle switch itself is the plugin's on/off control.
721
- * @param props - locale reader, settings form, credential operations, view.
722
- * @returns the summary line or the quick controls.
675
+ * Informational card on the Plugins page's bundle detail. All controls live
676
+ * in Settings → Telegram; this card only points there and lists the manual
677
+ * configuration files. The bundle switch is the plugin's on/off control.
678
+ * @param props - locale reader and slot view.
679
+ * @returns the summary line or the settings pointer.
723
680
  */
724
681
  function BundleSettings(props) {
725
682
  const { t, view } = props
@@ -809,7 +766,7 @@ window.__ModuleLoader__.load({
809
766
  name: 'plugins.bundle.config',
810
767
  key: 'dsh-telegram-bot',
811
768
  locale: NS,
812
- inject: () => ({ t, form, token }),
769
+ inject: () => ({ t }),
813
770
  },
814
771
  BundleSettings,
815
772
  ),
package/index.js CHANGED
@@ -1,14 +1,10 @@
1
1
  /**
2
2
  * Host half of dsh-telegram-bot.
3
3
  *
4
- * It owns two things:
5
- * - the editable settings namespace (`enabled`, allowlists, restart request);
6
- * - a deliberately small Telegram long-polling runtime, so the Restart button
7
- * and the allowlists do something real before the full agent bridge lands.
8
- *
9
- * The runtime answers only a handful of diagnostic commands. Wiring Telegram
10
- * chats to DSH agents is the next step; keeping this layer small means a bad
11
- * token or a conflicting poller can never take the Host down.
4
+ * It owns the editable settings namespace, the Telegram long-polling runtime,
5
+ * the DM-topic session browser, and the bridge between Telegram topics and
6
+ * DSH agent sessions. Keeping the runtime isolated means a bad token or a
7
+ * conflicting poller cannot take the Host down.
12
8
  */
13
9
  import z from '@deepseek-ai/schemastery'
14
10
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
@@ -18,13 +14,11 @@ import { homedir } from 'node:os'
18
14
  import { basename, join, resolve } from 'node:path'
19
15
  import telegramifyMarkdown from 'telegramify-markdown'
20
16
 
21
- /** Loader row id; keep it equal to the package name and the client namespace. */
17
+ /** Loader row id and settings namespace; the npm package name stays dsh-telegram-bot. */
22
18
  export const name = 'telegram-bot'
23
19
 
24
20
  /** Default credential reference holding the bot token. */
25
21
  const DEFAULT_TOKEN_REF = 'TELEGRAM_BOT_TOKEN'
26
- /** Telegram Bot API root; the token is appended per request. */
27
- const API_ROOT = 'https://api.telegram.org'
28
22
  /** Long-poll timeout requested from Telegram, in seconds. */
29
23
  const POLL_TIMEOUT_SECONDS = 25
30
24
  /** Pause after a failed poll before trying again, in milliseconds. */
@@ -156,6 +150,19 @@ function fitAnswerText(text, limit = ANSWER_MARKDOWN_LIMIT) {
156
150
  return prefix.length === 0 ? '' : `${prefix}…`
157
151
  }
158
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
+
159
166
  /**
160
167
  * Split raw Markdown into chunks whose converted MarkdownV2 fits Telegram.
161
168
  * @param text - complete answer.
@@ -404,27 +411,23 @@ async function loadState() {
404
411
  return { version: 2, sessions: {}, threads: {} }
405
412
  }
406
413
 
407
- /** Durable control/system topic ids for the bot chat. */
414
+ /** Durable control-chat id for lifecycle notifications. */
408
415
  const TOPICS_PATH = join(STATUS_DIR, 'topics.json')
409
416
 
410
417
  /**
411
- * Read the control/system topic mapping.
412
- * @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.
413
420
  */
414
421
  async function loadTopics() {
415
422
  try {
416
423
  const parsed = JSON.parse(await readFile(TOPICS_PATH, 'utf8'))
417
- if (parsed !== null && typeof parsed === 'object') {
418
- return {
419
- chatId: typeof parsed.chatId === 'number' ? parsed.chatId : 0,
420
- allThreadId: typeof parsed.allThreadId === 'number' ? parsed.allThreadId : 0,
421
- systemThreadId: typeof parsed.systemThreadId === 'number' ? parsed.systemThreadId : 0,
422
- }
424
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.chatId === 'number') {
425
+ return { chatId: parsed.chatId }
423
426
  }
424
427
  } catch {
425
- // Topics are optional until /start creates them.
428
+ // The control chat is learned on the first /start.
426
429
  }
427
- return { chatId: 0, allThreadId: 0, systemThreadId: 0 }
430
+ return { chatId: 0 }
428
431
  }
429
432
 
430
433
  /** Last published runtime snapshot, mirrored to a local diagnostics file. */
@@ -436,7 +439,7 @@ let runtimeStatus = {
436
439
  transport: 'https-ipv4',
437
440
  version: '0.3.0',
438
441
  agentBridge: 'v1',
439
- codeRevision: 'format-4',
442
+ codeRevision: 'preview-format',
440
443
  lastPollAt: 0,
441
444
  lastMessageAt: 0,
442
445
  lastChatId: null,
@@ -489,8 +492,8 @@ export function apply(ctx, config) {
489
492
  const chats = new Map()
490
493
  /** Agent API calls use this lifetime; the bot polling lifetime is separate. */
491
494
  const bridgeAbort = new AbortController()
492
- /** Control/System topic ids. */
493
- let topics = { chatId: 0, allThreadId: 0, systemThreadId: 0 }
495
+ /** Control chat id used for lifecycle notifications. */
496
+ let topics = { chatId: 0 }
494
497
  /** Resolves after the mapping and topic files have been read. */
495
498
  const stateReady = Promise.all([
496
499
  loadState().then((loaded) => { state = loaded }),
@@ -540,12 +543,12 @@ export function apply(ctx, config) {
540
543
  thinkingMessageId: null,
541
544
  thinkingTimer: null,
542
545
  thinkingLastText: '',
543
- thinkingIsStatus: false,
544
546
  tools: [],
545
547
  toolMessageId: null,
546
548
  toolTimer: null,
547
549
  toolLastText: '',
548
550
  boundAgent: undefined,
551
+ questionAgent: undefined,
549
552
  }
550
553
  chats.set(key, chat)
551
554
  }
@@ -577,19 +580,6 @@ export function apply(ctx, config) {
577
580
  await persistState()
578
581
  }
579
582
 
580
- /**
581
- * Remove one topic's session binding without deleting the session.
582
- * @param chat - chat bridge state.
583
- */
584
- async function unbindSession(chat) {
585
- const sessionId = chat.sessionId
586
- chat.sessionId = undefined
587
- chat.boundAgent = undefined
588
- delete state.threads[chat.key]
589
- if (typeof sessionId === 'string') delete state.sessions[sessionId]
590
- await persistState()
591
- }
592
-
593
583
  /** @returns the configured session working directory. */
594
584
  function cwdOf() {
595
585
  const configured = readLive(config.cwd, process.cwd())
@@ -1004,7 +994,7 @@ export function apply(ctx, config) {
1004
994
  }
1005
995
 
1006
996
  /**
1007
- * Send one HTML-safe diagnostic message.
997
+ * Send one HTML-formatted message.
1008
998
  * @param chatId - target chat.
1009
999
  * @param text - message body.
1010
1000
  * @param signal - runtime lifetime.
@@ -1141,6 +1131,99 @@ export function apply(ctx, config) {
1141
1131
  }
1142
1132
  }
1143
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
+
1144
1227
  /**
1145
1228
  * Split text into Telegram-sized chunks.
1146
1229
  * @param text - full response.
@@ -1174,6 +1257,8 @@ export function apply(ctx, config) {
1174
1257
 
1175
1258
  /** Pending inline approval prompts keyed by short callback id. */
1176
1259
  const pendingApprovals = new Map()
1260
+ /** Pending structured user questions keyed by short callback id. */
1261
+ const pendingQuestions = new Map()
1177
1262
 
1178
1263
  /**
1179
1264
  * Resolve one pending approval and update its prompt in place.
@@ -1255,6 +1340,292 @@ export function apply(ctx, config) {
1255
1340
  })
1256
1341
  }
1257
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
+
1258
1629
  /**
1259
1630
  * Register stream/error listeners on one live agent.
1260
1631
  * @param agent - agent to observe.
@@ -1278,6 +1649,13 @@ export function apply(ctx, config) {
1278
1649
  return requestApproval(agent, chat, req, next)
1279
1650
  }, { prepend: true })
1280
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
+ }
1281
1659
  }
1282
1660
 
1283
1661
  /**
@@ -1499,7 +1877,6 @@ export function apply(ctx, config) {
1499
1877
  chat.thinkingText = ''
1500
1878
  chat.thinkingLastText = ''
1501
1879
  chat.thinkingMessageId = null
1502
- chat.thinkingIsStatus = false
1503
1880
  chat.tools = []
1504
1881
  chat.toolLastText = ''
1505
1882
  chat.toolMessageId = null
@@ -1625,7 +2002,6 @@ export function apply(ctx, config) {
1625
2002
  }
1626
2003
  if (frame.type === 'chunk' && frame.chunk?.type === 'reasoning-delta') {
1627
2004
  if (readLive(config.showThinking, true) !== true) return
1628
- chat.thinkingIsStatus = false
1629
2005
  chat.thinkingText += frame.chunk.text
1630
2006
  scheduleThinking(chat)
1631
2007
  return
@@ -1669,7 +2045,7 @@ export function apply(ctx, config) {
1669
2045
  }
1670
2046
 
1671
2047
  /**
1672
- * Flush the final assistant text, replacing or splitting the placeholder.
2048
+ * Flush the final assistant text into the first answer message and split it.
1673
2049
  * @param chat - chat bridge state.
1674
2050
  */
1675
2051
  async function finalizeChat(chat) {
@@ -1854,46 +2230,30 @@ export function apply(ctx, config) {
1854
2230
  }
1855
2231
 
1856
2232
  /**
1857
- * Ensure the All and System topics exist for this control chat.
1858
- * @param chatId - chat with Topics enabled.
1859
- * @param signal - request lifetime.
2233
+ * Remember the chat that owns lifecycle notifications (All, thread 0).
2234
+ * @param chatId - control chat id.
1860
2235
  */
1861
- async function ensureTopics(chatId, signal) {
1862
- if (topics.chatId === chatId && topics.systemThreadId !== 0) {
1863
- if (topics.allThreadId !== 0) {
1864
- topics.allThreadId = 0
1865
- await writeFile(TOPICS_PATH, JSON.stringify(topics, null, 2) + '\n', { mode: 0o600 })
1866
- }
1867
- return
1868
- }
1869
- try {
1870
- const systemThreadId = await createTopic(chatId, 'System', signal)
1871
- topics = { chatId, allThreadId: 0, systemThreadId: systemThreadId ?? 0 }
1872
- await mkdir(STATUS_DIR, { recursive: true })
1873
- await writeFile(TOPICS_PATH, JSON.stringify(topics, null, 2) + '\n', { mode: 0o600 })
1874
- } catch (error) {
1875
- if (!signal.aborted) warn(`ensureTopics failed: ${messageOf(error)}`)
1876
- }
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 })
1877
2241
  }
1878
2242
 
1879
2243
  /**
1880
- * Publish a system notification to System and All.
2244
+ * Publish a lifecycle notification into All.
1881
2245
  * @param text - HTML-safe body.
1882
2246
  * @param signal - request lifetime.
1883
2247
  */
1884
- async function notifySystem(text, signal) {
2248
+ async function notifyAll(text, signal) {
1885
2249
  if (topics.chatId === 0) return
1886
- const ids = [...new Set([topics.systemThreadId, topics.allThreadId])]
1887
- for (const threadId of ids) {
1888
- await call('sendMessage', {
1889
- chat_id: topics.chatId,
1890
- ...(threadId === 0 ? {} : { message_thread_id: threadId }),
1891
- text,
1892
- parse_mode: 'HTML',
1893
- }, signal).catch((error) => {
1894
- if (!signal.aborted) warn(`system notification failed: ${messageOf(error)}`)
1895
- })
1896
- }
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
+ })
1897
2257
  }
1898
2258
 
1899
2259
  /** @returns the display name for a permission preset. */
@@ -1922,7 +2282,7 @@ export function apply(ctx, config) {
1922
2282
  return [
1923
2283
  [
1924
2284
  { text: `🤖 ${modelLabel(current.model)}`, callback_data: 'o:model' },
1925
- { text: `🧠 ${tr('Effort', 'Режим')}: ${effortLabel(current.reasoningEffort)}`, callback_data: 'o:effort' },
2285
+ { text: `🧠 ${tr('Effort', 'Усилие')}: ${effortLabel(current.reasoningEffort)}`, callback_data: 'o:effort' },
1926
2286
  ],
1927
2287
  [
1928
2288
  { text: `🛡 ${permissionLabel(permission)}`, callback_data: 'o:mode' },
@@ -2103,7 +2463,7 @@ export function apply(ctx, config) {
2103
2463
  const message = messageOf(error).toLowerCase()
2104
2464
  if (message.includes('duplicate') || message.includes('already')) continue
2105
2465
  if (message.includes('forum') || message.includes('topic')) {
2106
- throw new Error(tr('Enable Topics in the bot chat: tap the bot name → Topics.', 'Включи Topics в чате с ботом: имя бота сверху → Topics.'))
2466
+ throw new Error(tr('Enable Threaded mode in @BotFather → Bot Settings, then enable Topics in the bot chat (tap the bot name → Topics).', 'Включи Threaded mode в @BotFather → Bot Settings, затем включи Topics в чате с ботом (имя бота сверху → Topics).'))
2107
2467
  }
2108
2468
  throw error
2109
2469
  }
@@ -2186,10 +2546,30 @@ export function apply(ctx, config) {
2186
2546
  * @param signal - request lifetime.
2187
2547
  * @returns topic record, or undefined.
2188
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
+
2189
2564
  async function openTopicForSession(sessionId, chatId, signal) {
2190
2565
  const existing = state.sessions[sessionId]
2191
2566
  if (existing !== undefined && existing.chatId === chatId && typeof existing.threadId === 'number') {
2192
- 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()
2193
2573
  }
2194
2574
  const title = await sessionTitle(sessionId)
2195
2575
  const records = await ctx.get('sessionQuery')?.listSessions(signal)
@@ -2200,16 +2580,8 @@ export function apply(ctx, config) {
2200
2580
  state.sessions[sessionId] = record
2201
2581
  state.threads[`${chatId}:${threadId}`] = sessionId
2202
2582
  await persistState()
2203
- await notifySystem(`📂 ${tr('Session opened', 'Открыта сессия')} <code>${escapeHtml(sessionId)}</code>`, signal)
2204
- const preview = await lastAssistantText(sessionId)
2205
- await sendKeyboard(
2206
- chatId,
2207
- threadId,
2208
- `📂 <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.', 'Напиши сообщение, чтобы продолжить эту сессию.')}`,
2209
- [],
2210
- signal,
2211
- )
2212
- 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)
2213
2585
  return record
2214
2586
  }
2215
2587
 
@@ -2299,10 +2671,10 @@ export function apply(ctx, config) {
2299
2671
  const record = records?.find((entry) => entry.header.id === sessionId)
2300
2672
  const title = await sessionTitle(sessionId, record?.header)
2301
2673
  const text =
2302
- `<b>${escapeHtml(title)}</b>\n` +
2303
- `${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` +
2304
2676
  `${tr('Status', 'Статус')}: ${record?.live ? tr('live', 'активна') : tr('persisted', 'сохранена')}\n\n` +
2305
- `${preview.length > 0 ? escapeHtml(preview.slice(0, 1600)) : tr('No assistant messages yet.', 'Пока нет ответов ассистента.')}`
2677
+ `${preview.length > 0 ? clipPreview(preview) : tr('No assistant messages yet.', 'Пока нет ответов ассистента.')}`
2306
2678
  const workspace = ctx.get('workspaceRegistry')?.list()
2307
2679
  ?.find((entry) => entry.path === record?.header.cwd)
2308
2680
  const rows = []
@@ -2317,7 +2689,7 @@ export function apply(ctx, config) {
2317
2689
  text: tr('⬅️ Back', '⬅️ Назад'),
2318
2690
  callback_data: `w:${workspaceRef}`,
2319
2691
  }])
2320
- await renderMenu(chatId, threadId, messageId, text, rows, signal)
2692
+ await renderMenuMarkdown(chatId, threadId, messageId, text, rows, signal)
2321
2693
  }
2322
2694
 
2323
2695
  /**
@@ -2355,6 +2727,10 @@ export function apply(ctx, config) {
2355
2727
  settleApproval(id, action === 'y' ? 'allowed-once' : 'rejected')
2356
2728
  return
2357
2729
  }
2730
+ if (prefix === 'q') {
2731
+ await handleQuestionCallback(ref, chat.id, threadId, signal)
2732
+ return
2733
+ }
2358
2734
  if (prefix === 'o') {
2359
2735
  const chatState = await chatFor(chat.id, threadId)
2360
2736
  if (typeof chatState.sessionId !== 'string') {
@@ -2484,8 +2860,7 @@ export function apply(ctx, config) {
2484
2860
  }
2485
2861
 
2486
2862
  /**
2487
- * Answer one authorized message. This is intentionally a diagnostic surface
2488
- * until the DSH Agent bridge replaces it.
2863
+ * Handle one authorized Telegram message and dispatch its command or prompt.
2489
2864
  * @param message - Telegram message.
2490
2865
  * @param signal - runtime lifetime.
2491
2866
  * @param type - Telegram update key, for diagnostics.
@@ -2533,8 +2908,8 @@ export function apply(ctx, config) {
2533
2908
  await send(
2534
2909
  chat.id,
2535
2910
  tr(
2536
- `Setup mode.\nYour user id: <code>${from.id}</code>\nYour chat id: <code>${chat.id}</code>\n\nAdd the user id to <b>Settings → Telegram → Allowed users</b>, then press Restart.`,
2537
- `Режим настройки.\nТвой user id: <code>${from.id}</code>\nТвой chat id: <code>${chat.id}</code>\n\nДобавь user id в <b>Settings → Telegram → Allowed users</b> и нажми Restart.`,
2911
+ `Setup mode.\nYour user id: <code>${from.id}</code>\nYour chat id: <code>${chat.id}</code>\n\nAdd the user id to <b>Settings → Telegram → Allowed users</b>, then press Restart.\n\nBefore opening sessions, enable <b>Threaded mode</b> in @BotFather → Bot Settings.`,
2912
+ `Режим настройки.\nТвой user id: <code>${from.id}</code>\nТвой chat id: <code>${chat.id}</code>\n\nДобавь user id в <b>Settings → Telegram → Allowed users</b> и нажми Restart.\n\nПеред открытием сессий включи <b>Threaded mode</b> в @BotFather → Bot Settings.`,
2538
2913
  ),
2539
2914
  signal,
2540
2915
  threadId,
@@ -2544,6 +2919,7 @@ export function apply(ctx, config) {
2544
2919
  }
2545
2920
 
2546
2921
  const chatState = await chatFor(chat.id, threadId)
2922
+ if (await consumeQuestionReply(chatState, text, signal)) return
2547
2923
  const inSession = typeof chatState.sessionId === 'string'
2548
2924
  const parts = text.trim().split(/\s+/u)
2549
2925
  const command = parts[0]?.toLowerCase()
@@ -2570,7 +2946,7 @@ export function apply(ctx, config) {
2570
2946
  }
2571
2947
 
2572
2948
  if (command === '/start' || command === '/help') {
2573
- if (!inSession) await ensureTopics(chat.id, signal)
2949
+ await rememberControlChat(chat.id)
2574
2950
  await send(
2575
2951
  chat.id,
2576
2952
  `<b>DeepSeek Harness Telegram bot</b>\n\n` +
@@ -2581,6 +2957,7 @@ export function apply(ctx, config) {
2581
2957
  (inSession
2582
2958
  ? `${tr('Current session', 'Текущая сессия')}: <code>${chatState.sessionId}</code>\n`
2583
2959
  : `${tr('Global topic. Choose a session or create a new one.', 'Глобальный чат. Выбери сессию или создай новую.')}\n`) +
2960
+ `\n<b>${tr('First run', 'Первый запуск')}:</b> @BotFather → Bot Settings → <b>Threaded mode</b> → ${tr('Enable', 'Включить')}.\n` +
2584
2961
  `${tr('Commands', 'Команды')}: /sessions, /current, /menu, /rename, /last, /stop, /restart, /status, /id, /ping.`,
2585
2962
  signal,
2586
2963
  threadId,
@@ -2635,11 +3012,11 @@ export function apply(ctx, config) {
2635
3012
  }
2636
3013
  const title = await sessionTitle(chatState.sessionId)
2637
3014
  const body = texts
2638
- .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)}`)
2639
3016
  .join('\n\n')
2640
- const header = limit === 1 ? tr('Latest assistant answer', 'Последний ответ ассистента') : `${tr('Latest', 'Последние')} ${texts.length} ${tr('answers', 'ответа')}`
2641
- for (const chunk of splitTelegramText(`${header} · ${escapeHtml(title)}\n\n${body}`)) {
2642
- 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)
2643
3020
  }
2644
3021
  return
2645
3022
  }
@@ -2695,6 +3072,7 @@ export function apply(ctx, config) {
2695
3072
  chatState.done = true
2696
3073
  clearEditTimer(chatState)
2697
3074
  clearAuxTimers(chatState)
3075
+ cancelQuestionsForChat(chatState)
2698
3076
  if (chatState.thinkingMessageId !== null) {
2699
3077
  await deleteMessage(chat.id, threadId, chatState.thinkingMessageId)
2700
3078
  chatState.thinkingMessageId = null
@@ -2776,7 +3154,7 @@ export function apply(ctx, config) {
2776
3154
  info(`connected as @${me.username ?? me.id}`)
2777
3155
  await publishStatus({ state: 'running', detail: '', username: me.username ?? String(me.id) })
2778
3156
  await publishCommands(tokenLifetime.signal)
2779
- 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)
2780
3158
  } catch (error) {
2781
3159
  if (!tokenLifetime.signal.aborted) {
2782
3160
  warn(`getMe failed: ${messageOf(error)}`)
@@ -2944,5 +3322,3 @@ export function apply(ctx, config) {
2944
3322
  'dsh-telegram-bot: runtime',
2945
3323
  )
2946
3324
  }
2947
-
2948
- export { preprocessTables, toTelegramMarkdown, splitAnswerText, fitAnswerText }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-telegram-bot",
3
- "version": "0.1.0",
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",
@@ -52,10 +52,10 @@
52
52
  "license": "MIT",
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "git+https://github.com/boobaw/dsh-telegram-bot.git"
55
+ "url": "git+https://github.com/Boobaw/dsh-telegram-bot.git"
56
56
  },
57
- "homepage": "https://github.com/boobaw/dsh-telegram-bot#readme",
57
+ "homepage": "https://github.com/Boobaw/dsh-telegram-bot#readme",
58
58
  "bugs": {
59
- "url": "https://github.com/boobaw/dsh-telegram-bot/issues"
59
+ "url": "https://github.com/Boobaw/dsh-telegram-bot/issues"
60
60
  }
61
61
  }