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