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.
- package/README.md +25 -27
- package/client.js +7 -50
- package/index.js +480 -104
- 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.
|
|
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
|
-
- **
|
|
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
|
|
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`
|
|
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 `/`.
|
|
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
|
|
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 →
|
|
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.
|
|
71
|
-
3.
|
|
72
|
-
4.
|
|
73
|
-
5.
|
|
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**
|
|
132
|
+
The bundle detail page in **Plugins** is a short informational panel:
|
|
131
133
|
|
|
132
|
-
-
|
|
133
|
-
-
|
|
134
|
-
-
|
|
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
|
-
|
|
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.
|
|
182
|
-
2.
|
|
183
|
-
3.
|
|
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
|
|
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
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* @param props - locale reader
|
|
722
|
-
* @returns the summary line or the
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
412
|
-
* @returns the
|
|
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
|
-
//
|
|
428
|
+
// The control chat is learned on the first /start.
|
|
426
429
|
}
|
|
427
|
-
return { chatId: 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
|
|
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
|
|
493
|
-
let topics = { chatId: 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-
|
|
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
|
|
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
|
-
*
|
|
1858
|
-
* @param chatId - chat
|
|
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
|
|
1862
|
-
if (topics.chatId === chatId
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
|
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
|
|
2248
|
+
async function notifyAll(text, signal) {
|
|
1885
2249
|
if (topics.chatId === 0) return
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
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', '
|
|
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
|
|
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
|
-
|
|
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
|
|
2204
|
-
|
|
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
|
-
|
|
2303
|
-
`${tr('Workspace', 'Workspace')}:
|
|
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 ?
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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 ? '' :
|
|
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', 'Последний ответ ассистента') :
|
|
2641
|
-
for (const chunk of
|
|
2642
|
-
await
|
|
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
|
|
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.
|
|
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/
|
|
55
|
+
"url": "git+https://github.com/Boobaw/dsh-telegram-bot.git"
|
|
56
56
|
},
|
|
57
|
-
"homepage": "https://github.com/
|
|
57
|
+
"homepage": "https://github.com/Boobaw/dsh-telegram-bot#readme",
|
|
58
58
|
"bugs": {
|
|
59
|
-
"url": "https://github.com/
|
|
59
|
+
"url": "https://github.com/Boobaw/dsh-telegram-bot/issues"
|
|
60
60
|
}
|
|
61
61
|
}
|