@goodandready/dsh-messenger-gateway 0.3.2 → 0.3.8

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.
@@ -0,0 +1,118 @@
1
+ export function extractMermaidDiagrams(text) {
2
+ if (!text || typeof text !== 'string') return []
3
+ const regex = /```mermaid\s*\n([\s\S]*?)```/gi
4
+ const diagrams = []
5
+ let match
6
+ while ((match = regex.exec(text)) !== null) {
7
+ diagrams.push({
8
+ fullMatch: match[0],
9
+ code: match[1].trim(),
10
+ index: match.index,
11
+ })
12
+ }
13
+ return diagrams
14
+ }
15
+
16
+ export function escapeSvgXml(str = '') {
17
+ return String(str)
18
+ .replace(/&/g, '&')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&apos;')
23
+ }
24
+
25
+ export function generateDiagramSvg(code, title = 'Mermaid Diagram') {
26
+ const lines = code.split('\n').slice(0, 30)
27
+ const lineSpans = lines.map((line, i) => {
28
+ const escaped = escapeSvgXml(line)
29
+ return `<tspan x="24" dy="${i === 0 ? '0' : '1.4em'}">${escaped}</tspan>`
30
+ }).join('')
31
+
32
+ const height = Math.max(160, Math.min(800, lines.length * 24 + 100))
33
+ const safeTitle = escapeSvgXml(title)
34
+
35
+ return `<?xml version="1.0" encoding="UTF-8"?>
36
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 ${height}" width="800" height="${height}">
37
+ <defs>
38
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
39
+ <stop offset="0%" stop-color="#1e1e2e"/>
40
+ <stop offset="100%" stop-color="#181825"/>
41
+ </linearGradient>
42
+ </defs>
43
+ <rect width="100%" height="100%" rx="12" fill="url(#bg)" stroke="#313244" stroke-width="2"/>
44
+ <circle cx="28" cy="28" r="6" fill="#f38ba8" />
45
+ <circle cx="48" cy="28" r="6" fill="#f9e2af" />
46
+ <circle cx="68" cy="28" r="6" fill="#a6e3a1" />
47
+ <text x="96" y="32" fill="#cdd6f4" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace" font-size="14" font-weight="600">${safeTitle}</text>
48
+ <line x1="16" y1="48" x2="784" y2="48" stroke="#313244" stroke-width="1"/>
49
+ <text x="24" y="76" fill="#a6adc8" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" xml:space="preserve">
50
+ ${lineSpans}
51
+ </text>
52
+ </svg>`
53
+ }
54
+
55
+ export function formatMarkdownTables(text) {
56
+ if (!text || typeof text !== 'string') return text
57
+ const tableRegex = /((?:^|\n)\|[^\n]+\|\n\|[\s\-:|]+\|\n(?:\|[^\n]+\|\n?)+)/g
58
+
59
+ return text.replace(tableRegex, (match) => {
60
+ const rawLines = match.trim().split('\n')
61
+ if (rawLines.length < 3) return match
62
+
63
+ const rows = rawLines.map((line) => {
64
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim())
65
+ return cells
66
+ })
67
+
68
+ const colCount = Math.max(...rows.map((r) => r.length))
69
+ const colWidths = Array(colCount).fill(0)
70
+
71
+ rows.forEach((row, rowIdx) => {
72
+ if (rowIdx === 1) return
73
+ row.forEach((cell, colIdx) => {
74
+ colWidths[colIdx] = Math.max(colWidths[colIdx] || 0, cell.length)
75
+ })
76
+ })
77
+
78
+ const formattedRows = rows.map((row, rowIdx) => {
79
+ if (rowIdx === 1) {
80
+ return colWidths.map((w) => '-'.repeat(Math.max(w, 3))).join(' | ')
81
+ }
82
+ return row.map((cell, colIdx) => cell.padEnd(colWidths[colIdx] || 3)).join(' | ')
83
+ })
84
+
85
+ const alignedTable = formattedRows.join('\n')
86
+ return `\n\`\`\`\n${alignedTable}\n\`\`\`\n`
87
+ })
88
+ }
89
+
90
+ export function processDiagramsAndTables(rawText, options = {}) {
91
+ const { artifactPreviews = true } = options
92
+ let text = String(rawText || '')
93
+ const files = []
94
+
95
+ if (artifactPreviews) {
96
+ const diagrams = extractMermaidDiagrams(text)
97
+ if (diagrams.length > 0) {
98
+ let diagramIndex = 1
99
+ for (const diag of diagrams) {
100
+ const svgContent = generateDiagramSvg(diag.code, `Диаграмма ${diagramIndex}`)
101
+ const base64 = Buffer.from(svgContent, 'utf-8').toString('base64')
102
+ files.push({
103
+ name: `diagram-${diagramIndex}.svg`,
104
+ mime: 'image/svg+xml',
105
+ kind: 'photo',
106
+ dataBase64: base64,
107
+ bytes: Buffer.from(svgContent, 'utf-8'),
108
+ })
109
+
110
+ text = text.replace(diag.fullMatch, `\n📊 <b>[Диаграмма ${diagramIndex}: см. вложение]</b>\n`)
111
+ diagramIndex++
112
+ }
113
+ }
114
+ text = formatMarkdownTables(text)
115
+ }
116
+
117
+ return { text, files, diagramsCount: files.length }
118
+ }
package/lib/ask.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
2
2
  import { normalizeThreadId } from './topics.js'
3
3
 
4
4
  export const TELEGRAM_CALLBACK_DATA_MAX = 64
5
+ export const DEFAULT_PAGE_SIZE = 6
5
6
 
6
7
  export function makeAskToken() {
7
8
  return randomUUID().replace(/-/g, '').slice(0, 12)
@@ -24,6 +25,21 @@ export function parseCallbackData(data) {
24
25
  return { token: raw.slice(0, idx), buttonId: raw.slice(idx + 1) }
25
26
  }
26
27
 
28
+ export function parseAskCallback(data) {
29
+ const raw = String(data || '')
30
+ const parts = raw.split(':')
31
+ if (parts.length < 2) return { token: undefined, kind: 'unknown', id: raw }
32
+ const token = parts[0]
33
+ const rest = parts.slice(1).join(':')
34
+
35
+ if (rest === 'done') return { token, kind: 'done', id: 'done' }
36
+ if (rest === 'cancel') return { token, kind: 'cancel', id: 'cancel' }
37
+ if (rest.startsWith('t:')) return { token, kind: 'toggle', id: rest.slice(2) }
38
+ if (rest.startsWith('p:')) return { token, kind: 'page', page: Number(rest.slice(2)) || 0 }
39
+
40
+ return { token, kind: 'select', id: rest }
41
+ }
42
+
27
43
  export function buildInlineKeyboard(token, buttons) {
28
44
  const callbackKeys = []
29
45
  const rows = (buttons || []).map((row) => row.map((btn) => {
@@ -37,6 +53,79 @@ export function buildInlineKeyboard(token, buttons) {
37
53
  }
38
54
  }
39
55
 
56
+ export function normalizeAskOptions(options = []) {
57
+ if (Array.isArray(options)) {
58
+ const flat = options.flat ? options.flat() : [].concat(...options)
59
+ return flat.map((opt, i) => {
60
+ if (typeof opt === 'string') return { id: String(i + 1), text: opt, selected: false }
61
+ const id = String(opt?.id ?? opt?.buttonId ?? i + 1).trim()
62
+ const text = String(opt?.text ?? opt?.label ?? id).trim()
63
+ return { id, text, selected: Boolean(opt?.selected) }
64
+ })
65
+ }
66
+ return []
67
+ }
68
+
69
+ export function buildMultiSelectKeyboard(token, options = [], selectedIds = new Set(), page = 0, pageSize = DEFAULT_PAGE_SIZE, opts = {}) {
70
+ const normalized = normalizeAskOptions(options)
71
+ const total = normalized.length
72
+ const limit = Math.max(1, pageSize)
73
+ const maxPages = Math.ceil(total / limit) || 1
74
+ const curPage = Math.max(0, Math.min(page, maxPages - 1))
75
+ const start = curPage * limit
76
+ const end = Math.min(start + limit, total)
77
+ const slice = normalized.slice(start, end)
78
+
79
+ const callbackKeys = []
80
+ const rows = []
81
+
82
+ for (const opt of slice) {
83
+ const isChecked = selectedIds.has(opt.id)
84
+ const icon = isChecked ? '☑️' : '⬜️'
85
+ const text = `${icon} ${opt.text}`
86
+ const callback_data = buildCallbackData(token, `t:${opt.id}`)
87
+ callbackKeys.push(callback_data)
88
+ rows.push([{ text, callback_data }])
89
+ }
90
+
91
+ // Pagination row if more than one page
92
+ if (maxPages > 1) {
93
+ const navRow = []
94
+ if (curPage > 0) {
95
+ const prevData = buildCallbackData(token, `p:${curPage - 1}`)
96
+ callbackKeys.push(prevData)
97
+ navRow.push({ text: '⬅️ Назад', callback_data: prevData })
98
+ }
99
+ const indicatorData = buildCallbackData(token, `p:${curPage}`)
100
+ callbackKeys.push(indicatorData)
101
+ navRow.push({ text: `${curPage + 1}/${maxPages}`, callback_data: indicatorData })
102
+ if (curPage < maxPages - 1) {
103
+ const nextData = buildCallbackData(token, `p:${curPage + 1}`)
104
+ callbackKeys.push(nextData)
105
+ navRow.push({ text: 'Вперед ➡️', callback_data: nextData })
106
+ }
107
+ rows.push(navRow)
108
+ }
109
+
110
+ // Action buttons (Done / Cancel)
111
+ const doneText = opts.doneText || '✅ Готово'
112
+ const cancelText = opts.cancelText || '❌ Отмена'
113
+ const doneData = buildCallbackData(token, 'done')
114
+ const cancelData = buildCallbackData(token, 'cancel')
115
+ callbackKeys.push(doneData, cancelData)
116
+ rows.push([
117
+ { text: doneText, callback_data: doneData },
118
+ { text: cancelText, callback_data: cancelData },
119
+ ])
120
+
121
+ return {
122
+ replyMarkup: { inline_keyboard: rows },
123
+ callbackKeys,
124
+ page: curPage,
125
+ maxPages,
126
+ }
127
+ }
128
+
40
129
  export function indexCallbacks(callbackIndex, keys, token) {
41
130
  for (const key of keys || []) callbackIndex.set(key, token)
42
131
  }
package/lib/client.js CHANGED
@@ -117,6 +117,8 @@ const css =
117
117
  notifyBridge: 'Notify → Telegram home',
118
118
  notifyBridgeHint: 'task_done/error from web sessions (not msgw-*)',
119
119
  notifyHome: 'Notify home name',
120
+ quickActions: 'Quick actions keyboard (/new, /stop, /voice, /status)',
121
+ artifactPreviews: 'Render diagram and table previews',
120
122
  }
121
123
  const ru = {
122
124
  title: 'Messenger gateway',
@@ -188,6 +190,8 @@ const css =
188
190
  notifyBridge: 'Notify → Telegram home',
189
191
  notifyBridgeHint: 'task_done/error из web-сессий (не msgw-*)',
190
192
  notifyHome: 'Имя home для notify',
193
+ quickActions: 'Быстрая клавиатура (/new, /stop, /voice, /status)',
194
+ artifactPreviews: 'Превью артефактов (диаграммы и таблицы)',
191
195
  }
192
196
 
193
197
  function useActiveLocale(ctx) {
@@ -419,6 +423,14 @@ const css =
419
423
  t('statusIndicator'),
420
424
  ),
421
425
  React.createElement('div', { className: 'msgw-hint' }, t('statusIndicatorHint')),
426
+ React.createElement('label', { className: 'msgw-check' },
427
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.quickActions !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, quickActions: e.target.checked } }) }),
428
+ t('quickActions'),
429
+ ),
430
+ React.createElement('label', { className: 'msgw-check' },
431
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.artifactPreviews !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, artifactPreviews: e.target.checked } }) }),
432
+ t('artifactPreviews'),
433
+ ),
422
434
  React.createElement(Field, { label: t('voiceMode') },
423
435
  React.createElement('select', { className: 'msgw-select', value: cfg.telegram?.voiceMode || 'mirror', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, voiceMode: e.target.value } }) },
424
436
  React.createElement('option', { value: 'mirror' }, t('voiceModeMirror')),
package/lib/commands.js CHANGED
@@ -9,6 +9,18 @@ export const DEFAULT_TELEGRAM_COMMANDS = [
9
9
  { command: 'home', description: 'Список home-каналов' },
10
10
  { command: 'model', description: 'Показать или сменить модель' },
11
11
  { command: 'status', description: 'Статус шлюза' },
12
+ { command: 'setalert', description: 'Назначить этот чат каналом алертов' },
13
+ { command: 'alert', description: 'Статус канала алертов: /alert [test]' },
14
+ { command: 'keyboard', description: 'Клавиатура быстрых действий: /keyboard on|off' },
15
+ { command: 'role', description: 'Роль и персона агента: /role [name]' },
16
+ { command: 'skills', description: 'Список активных инструментов и навыков' },
17
+ { command: 'tools', description: 'Список активных инструментов' },
18
+ { command: 'fork', description: 'Форк текущей сессии в новую ветку' },
19
+ { command: 'export', description: 'Экспорт истории диалога в Markdown' },
20
+ { command: 'rewind', description: 'Откат сообщений: /rewind [N]' },
21
+ { command: 'files', description: 'Файловый менеджер рабочей папки: /files [dir]' },
22
+ { command: 'get', description: 'Скачать файл в Telegram: /get <path>' },
23
+ { command: 'remind', description: 'Напоминание: /remind <время> <текст>' },
12
24
  { command: 'voice', description: 'Голосовые ответы: /voice on|off|status' },
13
25
  { command: 'tts', description: 'Озвучка в этом чате: /tts on|off|status' },
14
26
  { command: 'mute', description: 'Не присылать уведомления в этот чат' },
package/lib/config.js CHANGED
@@ -40,17 +40,36 @@ export const PluginConfig = z.object({
40
40
  webhookPath: z.string().default('/dsh-messenger-gateway/telegram/webhook'),
41
41
  voiceMode: z.union([z.const('mirror'), z.const('always'), z.const('off')]).default('mirror')
42
42
  .description('mirror: TTS when inbound was voice; always/off override (per-user /voice wins)'),
43
+ quickActions: z.boolean().default(true).description('Show persistent quick actions keyboard in Telegram (/new, /stop, /voice, /status)'),
44
+ artifactPreviews: z.boolean().default(true).description('Render diagrams and formatted tables as previews'),
43
45
  notifyBridge: z.object({
44
46
  enabled: z.boolean().default(false),
45
47
  events: z.array(z.string()).default(['task_done', 'error']),
46
48
  home: z.string().default('default'),
47
49
  excludeSessionPrefixes: z.array(z.string()).default(['msgw-']),
48
50
  }).default({ enabled: false }),
51
+ alerts: z.object({
52
+ enabled: z.boolean().default(false),
53
+ chatId: z.union([z.number(), z.string()]).default(''),
54
+ threadId: z.number().default(0),
55
+ home: z.string().default(''),
56
+ events: z.array(z.string()).default(['error', 'pairing']),
57
+ }).default({ enabled: false }),
49
58
  }),
50
59
  discord: z.object({
51
60
  enabled: z.boolean().default(false),
52
61
  botToken: z.string().role('secret').default(''),
53
- }),
62
+ webhookUrl: z.string().default(''),
63
+ }).default({ enabled: false }),
64
+ slack: z.object({
65
+ enabled: z.boolean().default(false),
66
+ botToken: z.string().role('secret').default(''),
67
+ webhookUrl: z.string().default(''),
68
+ }).default({ enabled: false }),
69
+ webhooks: z.object({
70
+ enabled: z.boolean().default(true),
71
+ secret: z.string().role('secret').default(''),
72
+ }).default({ enabled: true }),
54
73
  media: z.object({
55
74
  cacheDir: z.string().default(''),
56
75
  maxDocBytes: z.number().default(20 * 1024 * 1024),
package/lib/documents.js CHANGED
@@ -1,12 +1,22 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { extname } from 'node:path'
3
+ import { inflateRawSync } from 'node:zlib'
4
+
1
5
  /** Format a cached inbound document/video path for the agent (fs tools). */
2
- export function formatInboundDocument(att) {
6
+ export function formatInboundDocument(att, parsed = null) {
3
7
  const kind = att?.kind || 'document'
4
8
  const labels = { video: 'Видео', sticker: 'Стикер', animation: 'GIF', document: 'Документ' }
5
9
  const label = labels[kind] || 'Файл'
6
10
  const name = att?.name ? ` ${att.name}` : ''
7
11
  const mime = att?.mime ? ` (${att.mime})` : ''
8
12
  const emoji = att?.emoji ? ` emoji=${att.emoji}` : ''
9
- return `[${label}${name}${mime}${emoji}]\nПуть: ${att.path}`
13
+ let base = `[${label}${name}${mime}${emoji}]\nПуть: ${att.path}`
14
+
15
+ if (parsed?.text) {
16
+ const truncNote = parsed.truncated ? ' (содержимое усечено)' : ''
17
+ base += `\n\n[Распознанный текст из файла${truncNote}]:\n\`\`\`\n${parsed.text}\n\`\`\``
18
+ }
19
+ return base
10
20
  }
11
21
 
12
22
  export function documentOnlyHint(attachments, userText) {
@@ -23,8 +33,140 @@ export function documentOnlyHint(attachments, userText) {
23
33
  }
24
34
 
25
35
  export const TEXT_INJECT_EXTS = new Set([
26
- '.md', '.txt', '.csv', '.log', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg',
36
+ '.md', '.txt', '.csv', '.tsv', '.log', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg',
27
37
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.sh', '.bash', '.zsh', '.ps1',
28
38
  '.go', '.rs', '.java', '.kt', '.c', '.h', '.cpp', '.hpp', '.cs', '.rb', '.php',
29
39
  '.html', '.css', '.scss', '.sql', '.r', '.swift', '.vue', '.svelte',
30
40
  ])
41
+
42
+ export function extractPdfText(buffer) {
43
+ const content = buffer.toString('binary')
44
+ const textBlocks = []
45
+ // Matches text inside BT ... ET blocks
46
+ const btRegex = /BT[\s\S]*?ET/g
47
+ let btMatch
48
+ while ((btMatch = btRegex.exec(content)) !== null) {
49
+ const block = btMatch[0]
50
+ // Matches (text) Tj
51
+ const tjRegex = /\((.*?)\)\s*Tj/g
52
+ let tjMatch
53
+ while ((tjMatch = tjRegex.exec(block)) !== null) {
54
+ textBlocks.push(tjMatch[1])
55
+ }
56
+ // Matches [(t1) 10 (t2)] TJ
57
+ const arrayRegex = /\[(.*?)\]\s*TJ/g
58
+ let arrMatch
59
+ while ((arrMatch = arrayRegex.exec(block)) !== null) {
60
+ const inner = arrMatch[1]
61
+ const strRegex = /\((.*?)\)/g
62
+ let strMatch
63
+ while ((strMatch = strRegex.exec(inner)) !== null) {
64
+ textBlocks.push(strMatch[1])
65
+ }
66
+ }
67
+ }
68
+
69
+ // Also check plain text streams if no BT/ET blocks were matched
70
+ if (!textBlocks.length) {
71
+ const streamRegex = /stream[\r\n]+([\s\S]*?)[\r\n]+endstream/g
72
+ let sMatch
73
+ while ((sMatch = streamRegex.exec(content)) !== null) {
74
+ const stream = sMatch[1]
75
+ const strRegex = /\(([\w\s.,;:!?-]{4,})\)/g
76
+ let strMatch
77
+ while ((strMatch = strRegex.exec(stream)) !== null) {
78
+ textBlocks.push(strMatch[1])
79
+ }
80
+ }
81
+ }
82
+
83
+ return textBlocks.join(' ').replace(/\\([()\\])/g, '$1').trim()
84
+ }
85
+
86
+ export function extractDocxText(buffer) {
87
+ // Locate word/document.xml inside ZIP local file headers
88
+ let pos = 0
89
+ const needle = Buffer.from('word/document.xml', 'utf8')
90
+ while (pos < buffer.length - 30) {
91
+ // Local file header signature: 0x04034b50
92
+ if (buffer[pos] === 0x50 && buffer[pos + 1] === 0x4b && buffer[pos + 2] === 0x03 && buffer[pos + 3] === 0x04) {
93
+ const compMethod = buffer.readUInt16LE(pos + 8)
94
+ const compSize = buffer.readUInt32LE(pos + 18)
95
+ const nameLen = buffer.readUInt16LE(pos + 26)
96
+ const extraLen = buffer.readUInt16LE(pos + 28)
97
+ const nameStart = pos + 30
98
+ const nameBuf = buffer.slice(nameStart, nameStart + nameLen)
99
+
100
+ if (nameBuf.equals(needle)) {
101
+ const dataStart = nameStart + nameLen + extraLen
102
+ const compData = buffer.slice(dataStart, dataStart + compSize)
103
+ let xmlStr = ''
104
+ try {
105
+ if (compMethod === 8) {
106
+ xmlStr = inflateRawSync(compData).toString('utf8')
107
+ } else {
108
+ xmlStr = compData.toString('utf8')
109
+ }
110
+ } catch {
111
+ return ''
112
+ }
113
+ // Extract all text inside <w:t>...</w:t> tags
114
+ const textParts = []
115
+ const tRegex = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g
116
+ let tMatch
117
+ while ((tMatch = tRegex.exec(xmlStr)) !== null) {
118
+ textParts.push(tMatch[1])
119
+ }
120
+ return textParts.join(' ').trim()
121
+ }
122
+ pos = nameStart + nameLen + extraLen + compSize
123
+ } else {
124
+ pos++
125
+ }
126
+ }
127
+ return ''
128
+ }
129
+
130
+ export async function parseDocument(filePath, opts = {}) {
131
+ const { maxBytes = 64 * 1024 } = opts
132
+ const ext = extname(filePath).toLowerCase()
133
+
134
+ try {
135
+ const rawBuffer = await readFile(filePath)
136
+ let text = ''
137
+ let parsedType = 'text'
138
+
139
+ if (ext === '.pdf') {
140
+ parsedType = 'pdf'
141
+ text = extractPdfText(rawBuffer)
142
+ } else if (ext === '.docx') {
143
+ parsedType = 'docx'
144
+ text = extractDocxText(rawBuffer)
145
+ } else if (TEXT_INJECT_EXTS.has(ext)) {
146
+ parsedType = ext.slice(1)
147
+ text = rawBuffer.toString('utf8')
148
+ } else {
149
+ return { parsed: false, type: 'unsupported' }
150
+ }
151
+
152
+ text = text.trim()
153
+ if (!text) return { parsed: false, type: parsedType }
154
+
155
+ const originalLength = text.length
156
+ let truncated = false
157
+ if (text.length > maxBytes) {
158
+ text = text.slice(0, maxBytes)
159
+ truncated = true
160
+ }
161
+
162
+ return {
163
+ parsed: true,
164
+ type: parsedType,
165
+ text,
166
+ truncated,
167
+ originalLength,
168
+ }
169
+ } catch (err) {
170
+ return { parsed: false, error: err.message }
171
+ }
172
+ }
@@ -0,0 +1,162 @@
1
+ import { readdir, stat, readFile } from 'node:fs/promises'
2
+ import { resolve, normalize, relative, join, extname } from 'node:path'
3
+
4
+ export function formatFileSize(bytes) {
5
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
6
+ const units = ['B', 'KB', 'MB', 'GB']
7
+ const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
8
+ const val = (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)
9
+ return `${val} ${units[i]}`
10
+ }
11
+
12
+ export function resolveSafePath(baseCwd, userPath = '.') {
13
+ const base = resolve(normalize(baseCwd || process.cwd()))
14
+ const cleanUser = String(userPath || '').trim() || '.'
15
+ const target = resolve(base, cleanUser)
16
+
17
+ const rel = relative(base, target)
18
+ if (rel.startsWith('..') || (rel && resolve(base, rel) !== target)) {
19
+ const err = new Error('Access denied: path traversal out of workspace')
20
+ err.code = 'ERR_PATH_TRAVERSAL'
21
+ throw err
22
+ }
23
+ return { base, target, relativePath: rel || '.' }
24
+ }
25
+
26
+ export async function listFiles(baseCwd, subPath = '.', opts = {}) {
27
+ const { maxEntries = 50, showHidden = false } = opts
28
+ const { base, target, relativePath } = resolveSafePath(baseCwd, subPath)
29
+
30
+ let st
31
+ try {
32
+ st = await stat(target)
33
+ } catch (err) {
34
+ return { ok: false, error: `Каталог не найден: ${err.message}` }
35
+ }
36
+
37
+ if (!st.isDirectory()) {
38
+ return {
39
+ ok: true,
40
+ isDirectory: false,
41
+ currentPath: relativePath,
42
+ entries: [{ name: relativePath, size: st.size, isDirectory: false }],
43
+ formattedText: `📄 <b>${relativePath}</b> (${formatFileSize(st.size)})\nДля скачивания: <code>/get ${relativePath}</code>`,
44
+ }
45
+ }
46
+
47
+ const rawEntries = await readdir(target, { withFileTypes: true })
48
+ const entries = []
49
+
50
+ for (const entry of rawEntries) {
51
+ if (!showHidden && (entry.name.startsWith('.') || entry.name === 'node_modules')) {
52
+ continue
53
+ }
54
+ const full = join(target, entry.name)
55
+ try {
56
+ const entryStat = await stat(full)
57
+ entries.push({
58
+ name: entry.name,
59
+ isDirectory: entry.isDirectory(),
60
+ size: entryStat.size,
61
+ mtime: entryStat.mtimeMs,
62
+ })
63
+ } catch {
64
+ entries.push({
65
+ name: entry.name,
66
+ isDirectory: entry.isDirectory(),
67
+ size: 0,
68
+ mtime: 0,
69
+ })
70
+ }
71
+ }
72
+
73
+ // Sort: directories first, then alphabetical
74
+ entries.sort((a, b) => {
75
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
76
+ return a.name.localeCompare(b.name)
77
+ })
78
+
79
+ const total = entries.length
80
+ const sliced = entries.slice(0, maxEntries)
81
+
82
+ const lines = [
83
+ `📂 <b>Файлы: <code>${relativePath === '.' ? '/' : `/${relativePath}`}</code></b> (${total} элементов):`,
84
+ '',
85
+ ]
86
+
87
+ if (relativePath !== '.') {
88
+ lines.push('📁 <code>..</code> (на уровень выше: <code>/files ..</code>)')
89
+ }
90
+
91
+ for (const e of sliced) {
92
+ if (e.isDirectory) {
93
+ lines.push(`📁 <code>${e.name}/</code>`)
94
+ } else {
95
+ lines.push(`📄 <code>${e.name}</code> (${formatFileSize(e.size)})`)
96
+ }
97
+ }
98
+
99
+ if (total > maxEntries) {
100
+ lines.push(`\n<i>... и ещё ${total - maxEntries} файлов</i>`)
101
+ }
102
+
103
+ lines.push('\nСкачать файл: <code>/get <файл></code>\nПереход по папкам: <code>/files <папка></code>')
104
+
105
+ return {
106
+ ok: true,
107
+ isDirectory: true,
108
+ currentPath: relativePath,
109
+ total,
110
+ entries: sliced,
111
+ formattedText: lines.join('\n'),
112
+ }
113
+ }
114
+
115
+ export async function getFileForDownload(baseCwd, relativePath, maxBytes = 50 * 1024 * 1024) {
116
+ const { target, relativePath: safeRel } = resolveSafePath(baseCwd, relativePath)
117
+ let st
118
+ try {
119
+ st = await stat(target)
120
+ } catch (err) {
121
+ return { ok: false, error: `Файл не найден: ${safeRel}` }
122
+ }
123
+
124
+ if (st.isDirectory()) {
125
+ return { ok: false, error: `«${safeRel}» является каталогом, а не файлом. Список: /files ${safeRel}` }
126
+ }
127
+
128
+ if (st.size > maxBytes) {
129
+ return {
130
+ ok: false,
131
+ error: `Файл слишком большой (${formatFileSize(st.size)} > ${formatFileSize(maxBytes)}).`,
132
+ }
133
+ }
134
+
135
+ const bytes = await readFile(target)
136
+ const ext = extname(safeRel).toLowerCase()
137
+
138
+ const mimeMap = {
139
+ '.md': 'text/markdown',
140
+ '.txt': 'text/plain',
141
+ '.json': 'application/json',
142
+ '.pdf': 'application/pdf',
143
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
144
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
145
+ '.csv': 'text/csv',
146
+ '.png': 'image/png',
147
+ '.jpg': 'image/jpeg',
148
+ '.svg': 'image/svg+xml',
149
+ '.html': 'text/html',
150
+ }
151
+
152
+ const mime = mimeMap[ext] || 'application/octet-stream'
153
+
154
+ return {
155
+ ok: true,
156
+ name: safeRel.split(/[\\/]/).pop(),
157
+ relativePath: safeRel,
158
+ bytes,
159
+ size: st.size,
160
+ mime,
161
+ }
162
+ }