@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.
- package/README.md +29 -1
- package/README.ru.md +125 -0
- package/README.zh.md +76 -0
- package/lib/adapters/discord.js +129 -4
- package/lib/adapters/index.js +17 -1
- package/lib/adapters/slack.js +91 -0
- package/lib/adapters/telegram.js +27 -2
- package/lib/alerts.js +64 -0
- package/lib/artifacts.js +118 -0
- package/lib/ask.js +89 -0
- package/lib/client.js +12 -0
- package/lib/commands.js +12 -0
- package/lib/config.js +20 -1
- package/lib/documents.js +145 -3
- package/lib/file-manager.js +162 -0
- package/lib/gateway.js +391 -13
- package/lib/index.js +65 -8
- package/lib/messenger-api.js +19 -1
- package/lib/personas.js +98 -0
- package/lib/scheduler.js +135 -0
- package/lib/session-ops.js +95 -0
- package/package.json +1 -1
package/lib/artifacts.js
ADDED
|
@@ -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, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, ''')
|
|
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
|
-
|
|
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
|
+
}
|