@soffits/dsh-telegram-bot 0.0.0-stage → 0.4.0
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/CREDITS.md +24 -0
- package/LICENSE +22 -0
- package/MAINTAINING.md +39 -0
- package/README.md +293 -2
- package/bin/dsh-telegram-send.js +110 -0
- package/client.js +809 -0
- package/cordis.patch.yml +8 -0
- package/docs/bot-setting.gif +0 -0
- package/icon.svg +12 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/package.json +72 -4
- package/src/admission.js +21 -0
- package/src/app.js +6286 -0
- package/src/client.js +809 -0
- package/src/config.js +73 -0
- package/src/constants.js +80 -0
- package/src/errors.js +16 -0
- package/src/final-text.js +45 -0
- package/src/formatting.js +390 -0
- package/src/index.js +24 -0
- package/src/message-context.js +339 -0
- package/src/models.js +257 -0
- package/src/outbox.js +160 -0
- package/src/sent-log.js +112 -0
- package/src/state.js +49 -0
- package/src/status.js +43 -0
- package/src/steer.js +104 -0
- package/src/stickers.js +185 -0
- package/src/topics.js +211 -0
- package/src/update-ledger.js +58 -0
- package/src/utils.js +139 -0
- package/src/voice.js +151 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Editable plugin configuration schema. */
|
|
2
|
+
import z from '@deepseek-ai/schemastery'
|
|
3
|
+
import { DEFAULT_TOKEN_REF } from './constants.js'
|
|
4
|
+
|
|
5
|
+
export const Config = z.object({
|
|
6
|
+
/**
|
|
7
|
+
* Credential reference that holds the bot token. The token itself lives in
|
|
8
|
+
* the credential store (or the matching environment variable for headless
|
|
9
|
+
* deployments); this config only names the reference.
|
|
10
|
+
*/
|
|
11
|
+
tokenRef: z.string().default(DEFAULT_TOKEN_REF).volatile(),
|
|
12
|
+
/** Whether the Telegram runtime should poll while this profile runs. */
|
|
13
|
+
enabled: z.boolean().default(true).volatile(),
|
|
14
|
+
/** Language of the bot's Telegram messages. */
|
|
15
|
+
language: z.string().default('en').volatile(),
|
|
16
|
+
/**
|
|
17
|
+
* Telegram user ids or @usernames allowed to talk to the bot.
|
|
18
|
+
* Empty means nobody, except the one-time setup hint described below.
|
|
19
|
+
*/
|
|
20
|
+
allowedUsers: z.array(z.string()).default([]).volatile(),
|
|
21
|
+
/**
|
|
22
|
+
* Telegram chat ids or @channelnames the bot may answer in.
|
|
23
|
+
* Empty means private chats only.
|
|
24
|
+
*/
|
|
25
|
+
allowedChats: z.array(z.string()).default([]).volatile(),
|
|
26
|
+
/**
|
|
27
|
+
* Chat id (or @channelname) that gets lifecycle notices such as "Telegram
|
|
28
|
+
* bot enabled". Empty means the private chat of the first allowed user.
|
|
29
|
+
*/
|
|
30
|
+
controlChat: z.string().default('').volatile(),
|
|
31
|
+
/**
|
|
32
|
+
* Monotonic value the Restart button writes. Any settings update restarts the
|
|
33
|
+
* runtime, but this field lets the button request a restart even when every
|
|
34
|
+
* other field is unchanged.
|
|
35
|
+
*/
|
|
36
|
+
restartRequest: z.number().default(0).volatile(),
|
|
37
|
+
/** Working directory for Telegram-created DSH sessions. */
|
|
38
|
+
cwd: z.string().default(process.cwd()).volatile(),
|
|
39
|
+
/** Agent preset mounted for Telegram-created sessions when presets exist. */
|
|
40
|
+
agentPreset: z.string().default('standard').volatile(),
|
|
41
|
+
/** Mirror model reasoning deltas into the session topic. */
|
|
42
|
+
showThinking: z.boolean().default(true).volatile(),
|
|
43
|
+
/** Mirror tool calls into the session topic. */
|
|
44
|
+
showToolCalls: z.boolean().default(true).volatile(),
|
|
45
|
+
/**
|
|
46
|
+
* Also show turns started outside Telegram (Web UI, scheduled tasks, other
|
|
47
|
+
* plugins) in the topic bound to their session.
|
|
48
|
+
*/
|
|
49
|
+
mirrorTurns: z.boolean().default(true).volatile(),
|
|
50
|
+
/**
|
|
51
|
+
* Telegram messages sent while the session's turn runs: `steer` sends them
|
|
52
|
+
* into that turn (the agent sees them at its next step, like a message typed
|
|
53
|
+
* in the Web UI during a turn); `queue` holds them until the turn ends.
|
|
54
|
+
*/
|
|
55
|
+
busyMessages: z.union(['steer', 'queue']).default('steer').volatile(),
|
|
56
|
+
/**
|
|
57
|
+
* How voice messages, audio files and round video messages are turned into
|
|
58
|
+
* text: `dsh` uses the DSH speech-to-text service (decoded with ffmpeg),
|
|
59
|
+
* `command` runs `transcribeCommand`, `off` passes only the file.
|
|
60
|
+
*/
|
|
61
|
+
transcriber: z.union(['dsh', 'command', 'off']).default('dsh').volatile(),
|
|
62
|
+
/** Language hint for the DSH speech-to-text service; empty uses its setting. */
|
|
63
|
+
transcribeLanguage: z.string().default('').volatile(),
|
|
64
|
+
/**
|
|
65
|
+
* Program and arguments for `transcriber: command`, run without a shell.
|
|
66
|
+
* `{file}` is replaced by the recording's path (appended when absent); the
|
|
67
|
+
* transcript is read from stdout.
|
|
68
|
+
*/
|
|
69
|
+
transcribeCommand: z.array(z.string()).default([]).volatile(),
|
|
70
|
+
/** ffmpeg program used to decode recordings for the DSH service. */
|
|
71
|
+
ffmpegPath: z.string().default('ffmpeg').volatile(),
|
|
72
|
+
})
|
|
73
|
+
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Shared constants and Telegram command menus. */
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/** Default credential reference holding the bot token. */
|
|
6
|
+
export const DEFAULT_TOKEN_REF = 'TELEGRAM_BOT_TOKEN'
|
|
7
|
+
/** Long-poll timeout requested from Telegram, in seconds. */
|
|
8
|
+
export const POLL_TIMEOUT_SECONDS = 25
|
|
9
|
+
/** Pause after a failed poll before trying again, in milliseconds. */
|
|
10
|
+
export const RETRY_DELAY_MS = 3000
|
|
11
|
+
/** Coalesce a settings write and a token write into one restart. */
|
|
12
|
+
export const RESTART_DEBOUNCE_MS = 300
|
|
13
|
+
/**
|
|
14
|
+
* Forwarding several messages delivers one update per message. Forwards that
|
|
15
|
+
* arrive in the same topic within this window become a single prompt.
|
|
16
|
+
*/
|
|
17
|
+
export const FORWARD_BATCH_MS = 1200
|
|
18
|
+
|
|
19
|
+
/** Loader row id and settings namespace; the npm package name is @soffits/dsh-telegram-bot. */
|
|
20
|
+
export const name = 'telegram-bot'
|
|
21
|
+
|
|
22
|
+
/** Command menu shown by Telegram clients while the user types `/`. */
|
|
23
|
+
const BOT_COMMANDS = [
|
|
24
|
+
{ command: 'start', description: 'Start the bot and show help' },
|
|
25
|
+
{ command: 'help', description: 'List commands' },
|
|
26
|
+
{ command: 'stop', description: 'Stop the current task' },
|
|
27
|
+
{ command: 'clear', description: 'Clear queued Telegram messages' },
|
|
28
|
+
{ command: 'status', description: 'Bot and session status' },
|
|
29
|
+
{ command: 'sessions', description: 'Browse workspaces and sessions' },
|
|
30
|
+
{ command: 'new', description: 'Start a new session in this topic' },
|
|
31
|
+
{ command: 'workspace', description: 'Switch this topic to another workspace' },
|
|
32
|
+
{ command: 'current', description: 'Show the topic session' },
|
|
33
|
+
{ command: 'menu', description: 'Show the model and session controls' },
|
|
34
|
+
{ command: 'rename', description: 'Rename this Telegram topic' },
|
|
35
|
+
{ command: 'topic', description: 'Manage topics: delete, wipe' },
|
|
36
|
+
{ command: 'fork', description: 'Fork this session into a new topic' },
|
|
37
|
+
{ command: 'last', description: 'Show the latest assistant messages' },
|
|
38
|
+
{ command: 'goal', description: 'Goal: /goal <objective>, edit, pause, resume, clear' },
|
|
39
|
+
{ command: 'restart', description: 'Restart the Telegram bot runtime' },
|
|
40
|
+
{ command: 'id', description: 'Show user and chat ids' },
|
|
41
|
+
{ command: 'ping', description: 'Check connectivity' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** Russian command menu for clients with language_code=ru. */
|
|
45
|
+
const BOT_COMMANDS_RU = [
|
|
46
|
+
{ command: 'start', description: 'Запустить бота и показать справку' },
|
|
47
|
+
{ command: 'help', description: 'Список команд' },
|
|
48
|
+
{ command: 'stop', description: 'Остановить текущую задачу' },
|
|
49
|
+
{ command: 'clear', description: 'Очистить очередь сообщений' },
|
|
50
|
+
{ command: 'status', description: 'Статус бота и сессии' },
|
|
51
|
+
{ command: 'sessions', description: 'Выбрать workspace и сессию' },
|
|
52
|
+
{ command: 'new', description: 'Новая сессия в этом топике' },
|
|
53
|
+
{ command: 'workspace', description: 'Сменить workspace этого топика' },
|
|
54
|
+
{ command: 'current', description: 'Показать сессию этого топика' },
|
|
55
|
+
{ command: 'menu', description: 'Показать меню модели и сессии' },
|
|
56
|
+
{ command: 'rename', description: 'Переименовать Telegram-топик' },
|
|
57
|
+
{ command: 'topic', description: 'Управление топиками: delete, wipe' },
|
|
58
|
+
{ command: 'fork', description: 'Форкнуть сессию в новый topic' },
|
|
59
|
+
{ command: 'last', description: 'Показать последние ответы ассистента' },
|
|
60
|
+
{ command: 'goal', description: 'Цель: /goal <цель>, edit, pause, resume, clear' },
|
|
61
|
+
{ command: 'restart', description: 'Перезапустить Telegram-бота' },
|
|
62
|
+
{ command: 'id', description: 'Показать user id и chat id' },
|
|
63
|
+
{ command: 'ping', description: 'Проверить связь' },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/** Bound one Telegram message body to stay under the 4096-character API limit. */
|
|
67
|
+
const MAX_TELEGRAM_TEXT = 3800
|
|
68
|
+
/** A final answer of exactly this text posts nothing in the topic. */
|
|
69
|
+
const SILENT_REPLY = '[SILENT]'
|
|
70
|
+
/** Host-local diagnostics directory; no secrets are written here. */
|
|
71
|
+
const STATUS_DIR = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'telegram')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
BOT_COMMANDS,
|
|
76
|
+
BOT_COMMANDS_RU,
|
|
77
|
+
MAX_TELEGRAM_TEXT,
|
|
78
|
+
SILENT_REPLY,
|
|
79
|
+
STATUS_DIR,
|
|
80
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Error classification and diagnostics policy. */
|
|
2
|
+
import { isTelegramParseError } from './formatting.js'
|
|
3
|
+
import { messageOf } from './utils.js'
|
|
4
|
+
|
|
5
|
+
/** Classify a failure for diagnostics without mixing it with formatting errors. */
|
|
6
|
+
function classifyError(error) {
|
|
7
|
+
const detail = messageOf(error)
|
|
8
|
+
if (isTelegramParseError(detail)) return 'format'
|
|
9
|
+
if (error?.telegram === true) return 'telegram'
|
|
10
|
+
if (/agent|turn|session|approval|question/iu.test(detail)) return 'agent'
|
|
11
|
+
if (/state|storage|write|read|ENOENT|EACCES|EPERM/iu.test(detail)) return 'storage'
|
|
12
|
+
return 'runtime'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export { classifyError }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text a finished turn leaves in its topic.
|
|
3
|
+
*
|
|
4
|
+
* Text written between tool calls ("Now the board entries.") streams into the
|
|
5
|
+
* live reply like everything else, but it is working narration, not part of
|
|
6
|
+
* the answer. So the final reply keeps, from each assistant message of the
|
|
7
|
+
* turn, only an answer: the last text block of a message that called no tool.
|
|
8
|
+
* A message that called a tool is mid-chain, so its text goes. The last block,
|
|
9
|
+
* not all of them: Claude Code through dsh-oh-my-claude puts every text block
|
|
10
|
+
* of its own tool loop into one message, with its tools recorded beside it.
|
|
11
|
+
* Answers of several tool-free messages in one turn (a steer answered after a
|
|
12
|
+
* first answer) are all kept, in order.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The answer one assistant message holds, or '' when it holds none.
|
|
17
|
+
* @param message - an assistant message (`{ content: [...] }`).
|
|
18
|
+
* @returns the last text block when the message called no tool.
|
|
19
|
+
*/
|
|
20
|
+
export function messageAnswer(message) {
|
|
21
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
22
|
+
if (content.some((block) => block?.type === 'tool-call')) return ''
|
|
23
|
+
const texts = content
|
|
24
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
25
|
+
.map((block) => block.text.trim())
|
|
26
|
+
.filter((text) => text.length > 0)
|
|
27
|
+
return texts.at(-1) ?? ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The answer of one turn, read from the session's durable events.
|
|
32
|
+
* @param events - session events (`{ type, data }`).
|
|
33
|
+
* @param turn - DSH turn number.
|
|
34
|
+
* @returns the answers of the turn's messages joined by blank lines, or '' when none.
|
|
35
|
+
*/
|
|
36
|
+
export function turnAnswer(events, turn) {
|
|
37
|
+
if (!Array.isArray(events) || turn === undefined) return ''
|
|
38
|
+
const answers = []
|
|
39
|
+
for (const event of events) {
|
|
40
|
+
if (event?.type !== 'assistant/message' || event.data?.turn !== turn) continue
|
|
41
|
+
const answer = messageAnswer(event.data?.message ?? event.data)
|
|
42
|
+
if (answer.length > 0) answers.push(answer)
|
|
43
|
+
}
|
|
44
|
+
return answers.join('\n\n')
|
|
45
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/** Markdown/Telegram formatting helpers. */
|
|
2
|
+
import telegramifyMarkdown from 'telegramify-markdown'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Split one Markdown table row into trimmed cells.
|
|
6
|
+
* @param line - table row.
|
|
7
|
+
* @returns cells without the outer pipes.
|
|
8
|
+
*/
|
|
9
|
+
function splitTableRow(line) {
|
|
10
|
+
return line.replace(/^\s*\|/u, '').replace(/\|\s*$/u, '').split('|').map((cell) => cell.trim())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether a line is a Markdown table separator.
|
|
15
|
+
* @param line - candidate line.
|
|
16
|
+
* @returns true for `|---|:---:|` rows.
|
|
17
|
+
*/
|
|
18
|
+
function isTableSeparator(line) {
|
|
19
|
+
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/u.test(line)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether a line looks like a Markdown table row.
|
|
24
|
+
* @param line - candidate line.
|
|
25
|
+
* @returns true when it has at least one inner pipe.
|
|
26
|
+
*/
|
|
27
|
+
function isTableRow(line) {
|
|
28
|
+
return line.includes('|') && line.trim().length > 1 && !line.includes('\u0000')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert Markdown tables into compact Telegram-friendly lists.
|
|
33
|
+
* Telegram Markdown has no table syntax, so each row becomes one bullet.
|
|
34
|
+
* @param markdown - raw assistant text.
|
|
35
|
+
* @returns text with tables expanded to headings and bullets.
|
|
36
|
+
*/
|
|
37
|
+
function preprocessTables(markdown) {
|
|
38
|
+
const lines = markdown.split('\n')
|
|
39
|
+
const out = []
|
|
40
|
+
let fenced = false
|
|
41
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
42
|
+
const line = lines[index]
|
|
43
|
+
if (/^\s*```/u.test(line)) fenced = !fenced
|
|
44
|
+
if (!fenced && isTableRow(line) && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
|
|
45
|
+
const headers = splitTableRow(line)
|
|
46
|
+
out.push(`**${headers.join(' · ')}**`)
|
|
47
|
+
index += 2
|
|
48
|
+
while (index < lines.length && isTableRow(lines[index]) && !isTableSeparator(lines[index])) {
|
|
49
|
+
out.push(`- ${splitTableRow(lines[index]).join(' · ')}`)
|
|
50
|
+
index += 1
|
|
51
|
+
}
|
|
52
|
+
index -= 1
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
out.push(line)
|
|
56
|
+
}
|
|
57
|
+
return out.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Replace Markdown links to relative workspace paths with their labels.
|
|
62
|
+
* Telegram cannot open relative paths, and telegramify-markdown double-escapes
|
|
63
|
+
* their labels. Absolute http/tg/mailto links are preserved.
|
|
64
|
+
* @param markdown - raw assistant text.
|
|
65
|
+
* @returns text without relative links.
|
|
66
|
+
*/
|
|
67
|
+
function preprocessRelativeLinks(markdown) {
|
|
68
|
+
const strip = (value) => value.replace(/\[([^\]]+)\]\(([^)\s]+)\)/gu, (whole, label, target) => {
|
|
69
|
+
if (/^(?:https?:|tg:|mailto:)/iu.test(target)) return whole
|
|
70
|
+
return label
|
|
71
|
+
})
|
|
72
|
+
const out = []
|
|
73
|
+
let fenced = false
|
|
74
|
+
for (const line of markdown.split('\n')) {
|
|
75
|
+
if (/^\s*```/u.test(line)) {
|
|
76
|
+
fenced = !fenced
|
|
77
|
+
out.push(line)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (fenced) {
|
|
81
|
+
out.push(line)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
out.push(line.split(/(`[^`]*`)/gu).map((part) => part.startsWith('`') ? part : strip(part)).join(''))
|
|
85
|
+
}
|
|
86
|
+
return out.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Convert assistant Markdown to Telegram MarkdownV2.
|
|
91
|
+
* @param markdown - raw assistant text.
|
|
92
|
+
* @returns MarkdownV2 text, or the original text if conversion fails.
|
|
93
|
+
*/
|
|
94
|
+
function toTelegramMarkdown(markdown) {
|
|
95
|
+
try {
|
|
96
|
+
return telegramifyMarkdown(preprocessTables(preprocessRelativeLinks(markdown)), 'escape')
|
|
97
|
+
} catch {
|
|
98
|
+
return markdown
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Maximum converted MarkdownV2 length accepted by Telegram. */
|
|
103
|
+
export const ANSWER_MARKDOWN_LIMIT = 3900
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find the longest prefix whose converted MarkdownV2 fits Telegram.
|
|
107
|
+
* @param text - raw Markdown.
|
|
108
|
+
* @param limit - converted character budget.
|
|
109
|
+
* @returns a safe partial prefix.
|
|
110
|
+
*/
|
|
111
|
+
function fitAnswerText(text, limit = ANSWER_MARKDOWN_LIMIT) {
|
|
112
|
+
if (text.length === 0) return ''
|
|
113
|
+
if (toTelegramMarkdown(text).length <= limit) return text
|
|
114
|
+
let low = 0
|
|
115
|
+
let high = text.length
|
|
116
|
+
while (low < high) {
|
|
117
|
+
const middle = Math.ceil((low + high) / 2)
|
|
118
|
+
if (toTelegramMarkdown(text.slice(0, middle)).length <= limit) low = middle
|
|
119
|
+
else high = middle - 1
|
|
120
|
+
}
|
|
121
|
+
let cut = low
|
|
122
|
+
const boundary = Math.max(text.lastIndexOf('\n', cut), text.lastIndexOf(' ', cut))
|
|
123
|
+
if (boundary > cut / 2) cut = boundary
|
|
124
|
+
const prefix = text.slice(0, cut).trimEnd()
|
|
125
|
+
return prefix.length === 0 ? '' : `${prefix}…`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse streamed tool arguments when they form complete JSON.
|
|
130
|
+
* @param raw - raw argument string.
|
|
131
|
+
* @returns the object, or undefined while the stream is incomplete.
|
|
132
|
+
*/
|
|
133
|
+
function parseToolArguments(raw) {
|
|
134
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) return undefined
|
|
135
|
+
try {
|
|
136
|
+
const value = JSON.parse(raw)
|
|
137
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Convert one tool argument value to a compact single-line label.
|
|
145
|
+
* @param value - argument value.
|
|
146
|
+
* @returns display text.
|
|
147
|
+
*/
|
|
148
|
+
function compactToolValue(value) {
|
|
149
|
+
if (typeof value === 'string') return value.replace(/\s+/gu, ' ').trim()
|
|
150
|
+
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`
|
|
151
|
+
if (value !== null && typeof value === 'object') {
|
|
152
|
+
return Object.entries(value).slice(0, 3).map(([key, item]) => `${key}=${compactToolValue(item)}`).join(' · ')
|
|
153
|
+
}
|
|
154
|
+
return String(value)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pick one argument by its possible names.
|
|
159
|
+
* @param args - parsed tool arguments.
|
|
160
|
+
* @param names - candidate keys.
|
|
161
|
+
* @returns the first present value.
|
|
162
|
+
*/
|
|
163
|
+
function toolArg(args, ...names) {
|
|
164
|
+
if (args === undefined) return undefined
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
if (args[name] !== undefined && args[name] !== null) return args[name]
|
|
167
|
+
}
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Wrap text into fixed-width visual lines without dropping content.
|
|
173
|
+
* @param text - source text.
|
|
174
|
+
* @param width - target line width.
|
|
175
|
+
* @returns wrapped lines.
|
|
176
|
+
*/
|
|
177
|
+
function wrapVisualLines(text, width = 52) {
|
|
178
|
+
const lines = []
|
|
179
|
+
for (const sourceLine of String(text).split('\n')) {
|
|
180
|
+
const words = sourceLine.replace(/\s+/gu, ' ').trim().split(' ').filter(Boolean)
|
|
181
|
+
let current = ''
|
|
182
|
+
for (let word of words) {
|
|
183
|
+
while (word.length > width) {
|
|
184
|
+
if (current.length > 0) {
|
|
185
|
+
lines.push(current)
|
|
186
|
+
current = ''
|
|
187
|
+
}
|
|
188
|
+
lines.push(word.slice(0, width))
|
|
189
|
+
word = word.slice(width)
|
|
190
|
+
}
|
|
191
|
+
if (word.length === 0) continue
|
|
192
|
+
if (current.length === 0) current = word
|
|
193
|
+
else if (current.length + 1 + word.length <= width) current += ` ${word}`
|
|
194
|
+
else {
|
|
195
|
+
lines.push(current)
|
|
196
|
+
current = word
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (current.length > 0) lines.push(current)
|
|
200
|
+
}
|
|
201
|
+
return lines
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Strip HTML tags for the plain-text fallback.
|
|
206
|
+
* @param value - HTML body.
|
|
207
|
+
* @returns readable plain text.
|
|
208
|
+
*/
|
|
209
|
+
function htmlToPlain(value) {
|
|
210
|
+
return String(value)
|
|
211
|
+
.replace(/<[^>]*>/gu, '')
|
|
212
|
+
.replaceAll('<', '<')
|
|
213
|
+
.replaceAll('>', '>')
|
|
214
|
+
.replaceAll('&', '&')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether Telegram rejected the entity markup rather than the request itself.
|
|
219
|
+
* @param detail - Telegram error description.
|
|
220
|
+
* @returns true when a plain-text retry is appropriate.
|
|
221
|
+
*/
|
|
222
|
+
function isTelegramParseError(detail) {
|
|
223
|
+
return /can't parse entities|unsupported start tag|can't find end tag|reserved and must be escaped|parse entities/iu.test(String(detail))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether a Telegram thread id denotes the General / All topic. */
|
|
227
|
+
function isAllThread(threadId) {
|
|
228
|
+
return threadId === undefined || threadId === null || threadId === 0 || threadId === 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Normalize a Telegram command token, dropping an optional @botname suffix. */
|
|
232
|
+
function normalizeCommand(token) {
|
|
233
|
+
return String(token ?? '')
|
|
234
|
+
.replace(/[\u200B-\u200D\uFEFF]/gu, '')
|
|
235
|
+
.trim()
|
|
236
|
+
.toLowerCase()
|
|
237
|
+
.split('@')[0]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Decide where an ordinary (non-command) message belongs.
|
|
242
|
+
* @param {{ threadId?: number|null, inSession: boolean, hasAttachment?: boolean }} input
|
|
243
|
+
* @returns {'new-topic'|'current-topic'|'continue'|'command'}
|
|
244
|
+
*/
|
|
245
|
+
function routeMessage({ threadId, inSession, hasAttachment = false }) {
|
|
246
|
+
if (isAllThread(threadId)) return 'new-topic'
|
|
247
|
+
if (hasAttachment) return inSession ? 'continue' : 'current-topic'
|
|
248
|
+
return inSession ? 'continue' : 'current-topic'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Trim a session preview at a line boundary so formatting is not cut mid-construct.
|
|
253
|
+
* @param text - raw assistant Markdown.
|
|
254
|
+
* @param limit - maximum characters.
|
|
255
|
+
* @returns clipped preview.
|
|
256
|
+
*/
|
|
257
|
+
function clipPreview(text, limit = 2500) {
|
|
258
|
+
if (text.length <= limit) return text
|
|
259
|
+
const cut = text.slice(0, limit)
|
|
260
|
+
const newline = cut.lastIndexOf('\n')
|
|
261
|
+
return newline > limit / 2 ? cut.slice(0, newline) : cut
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Split raw Markdown into chunks whose converted MarkdownV2 fits Telegram.
|
|
266
|
+
* @param text - complete answer.
|
|
267
|
+
* @param limit - converted character budget.
|
|
268
|
+
* @returns chunks in reading order.
|
|
269
|
+
*/
|
|
270
|
+
/** Return the last unclosed fenced code block in a Markdown chunk. */
|
|
271
|
+
function openFenceAtEnd(text) {
|
|
272
|
+
let open
|
|
273
|
+
for (const line of text.split('\n')) {
|
|
274
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line)
|
|
275
|
+
if (match === null) continue
|
|
276
|
+
if (open === undefined) {
|
|
277
|
+
open = { marker: match[1], info: match[2].trim() }
|
|
278
|
+
} else if (match[1][0] === open.marker[0] && match[1].length >= open.marker.length && match[2].trim().length === 0) {
|
|
279
|
+
open = undefined
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return open
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Wrap reasoning to the phone width and keep only the last lines. */
|
|
286
|
+
function wrapThinkingLines(text, width = 43, maxLines = 9) {
|
|
287
|
+
// Hard character wrapping is prefix-stable: growing text never reflows the
|
|
288
|
+
// lines above, so only the bottom of the window changes.
|
|
289
|
+
const lines = []
|
|
290
|
+
for (const source of String(text).split('\n')) {
|
|
291
|
+
if (source.length === 0) {
|
|
292
|
+
lines.push('')
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
for (let index = 0; index < source.length; index += width) {
|
|
296
|
+
lines.push(source.slice(index, index + width))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const tail = lines.slice(-maxLines)
|
|
300
|
+
if (tail.length > 0) tail[tail.length - 1] = tail.at(-1).slice(0, 30).trimEnd()
|
|
301
|
+
return tail.join('\n')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function splitAnswerText(text, limit = ANSWER_MARKDOWN_LIMIT) {
|
|
305
|
+
const chunks = []
|
|
306
|
+
let rest = text.trim()
|
|
307
|
+
while (rest.length > 0) {
|
|
308
|
+
if (toTelegramMarkdown(rest).length <= limit) {
|
|
309
|
+
chunks.push(rest)
|
|
310
|
+
break
|
|
311
|
+
}
|
|
312
|
+
let cut = Math.min(rest.length, 2200)
|
|
313
|
+
const boundary = Math.max(rest.lastIndexOf('\n', cut), rest.lastIndexOf(' ', cut))
|
|
314
|
+
if (boundary > cut / 2) cut = boundary
|
|
315
|
+
while (cut > 200 && toTelegramMarkdown(rest.slice(0, cut)).length > limit) {
|
|
316
|
+
cut = Math.floor(cut * 0.8)
|
|
317
|
+
const nextBoundary = Math.max(rest.lastIndexOf('\n', cut), rest.lastIndexOf(' ', cut))
|
|
318
|
+
if (nextBoundary > cut / 2) cut = nextBoundary
|
|
319
|
+
}
|
|
320
|
+
if (cut < 1) cut = Math.min(rest.length, 500)
|
|
321
|
+
let chunk = rest.slice(0, cut).trimEnd()
|
|
322
|
+
if (chunk.length === 0) break
|
|
323
|
+
let tail = rest.slice(cut).trimStart()
|
|
324
|
+
const fence = openFenceAtEnd(chunk)
|
|
325
|
+
if (fence !== undefined) {
|
|
326
|
+
chunk += `\n${fence.marker}`
|
|
327
|
+
tail = `${fence.marker}${fence.info}\n${tail}`
|
|
328
|
+
}
|
|
329
|
+
chunks.push(chunk)
|
|
330
|
+
rest = tail
|
|
331
|
+
}
|
|
332
|
+
return chunks
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Local files shown as Markdown images (``,
|
|
336
|
+
* `` or `file://` URLs), outside code.
|
|
337
|
+
* Telegram cannot show these inline, so the bridge uploads them.
|
|
338
|
+
* @param markdown - assistant text.
|
|
339
|
+
* @returns absolute paths, in order, without repeats.
|
|
340
|
+
*/
|
|
341
|
+
function localImagePaths(markdown) {
|
|
342
|
+
const found = []
|
|
343
|
+
const pattern = /!\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^)\s]+))(?:\s+"[^"\n]*")?\s*\)/gu
|
|
344
|
+
const take = (text) => {
|
|
345
|
+
for (const match of text.matchAll(pattern)) {
|
|
346
|
+
let target = (match[1] ?? match[2]).trim()
|
|
347
|
+
if (/^file:\/\//iu.test(target)) {
|
|
348
|
+
try {
|
|
349
|
+
target = decodeURIComponent(new URL(target).pathname)
|
|
350
|
+
} catch {
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (target.startsWith('/') && !found.includes(target)) found.push(target)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
let fenced = false
|
|
358
|
+
for (const line of String(markdown ?? '').split('\n')) {
|
|
359
|
+
if (/^\s*```/u.test(line)) {
|
|
360
|
+
fenced = !fenced
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (fenced) continue
|
|
364
|
+
take(line.split(/`[^`]*`/gu).join(' '))
|
|
365
|
+
}
|
|
366
|
+
return found
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export {
|
|
370
|
+
localImagePaths,
|
|
371
|
+
splitTableRow,
|
|
372
|
+
isTableSeparator,
|
|
373
|
+
isTableRow,
|
|
374
|
+
preprocessTables,
|
|
375
|
+
preprocessRelativeLinks,
|
|
376
|
+
toTelegramMarkdown,
|
|
377
|
+
fitAnswerText,
|
|
378
|
+
parseToolArguments,
|
|
379
|
+
compactToolValue,
|
|
380
|
+
toolArg,
|
|
381
|
+
wrapVisualLines,
|
|
382
|
+
htmlToPlain,
|
|
383
|
+
isTelegramParseError,
|
|
384
|
+
isAllThread,
|
|
385
|
+
normalizeCommand,
|
|
386
|
+
routeMessage,
|
|
387
|
+
clipPreview,
|
|
388
|
+
splitAnswerText,
|
|
389
|
+
wrapThinkingLines,
|
|
390
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Public host entry point for @soffits/dsh-telegram-bot. */
|
|
2
|
+
export { apply } from './app.js'
|
|
3
|
+
export { Config } from './config.js'
|
|
4
|
+
export { name } from './constants.js'
|
|
5
|
+
export { classifyError } from './errors.js'
|
|
6
|
+
export {
|
|
7
|
+
isAllThread,
|
|
8
|
+
isTelegramParseError,
|
|
9
|
+
normalizeCommand,
|
|
10
|
+
routeMessage,
|
|
11
|
+
splitAnswerText,
|
|
12
|
+
} from './formatting.js'
|
|
13
|
+
|
|
14
|
+
/** Pure helpers exposed for the local regression suite. */
|
|
15
|
+
import { classifyError as classify } from './errors.js'
|
|
16
|
+
import { isAllThread as isAll, isTelegramParseError as isParse, normalizeCommand as normalize, routeMessage as route, splitAnswerText as split } from './formatting.js'
|
|
17
|
+
export const __testing = {
|
|
18
|
+
classifyError: classify,
|
|
19
|
+
isAllThread: isAll,
|
|
20
|
+
isTelegramParseError: isParse,
|
|
21
|
+
normalizeCommand: normalize,
|
|
22
|
+
routeMessage: route,
|
|
23
|
+
splitAnswerText: split,
|
|
24
|
+
}
|