@goodandready/dsh-messenger-gateway 0.3.19 → 0.3.21
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 +40 -2
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/adapters/discord.js +65 -11
- package/lib/adapters/slack.js +72 -12
- package/lib/adapters/telegram.js +708 -690
- package/lib/alerts.js +10 -10
- package/lib/artifacts.js +117 -117
- package/lib/ask.js +151 -151
- package/lib/client.js +104 -104
- package/lib/commands.js +73 -47
- package/lib/config.js +104 -98
- package/lib/content-guard.js +14 -14
- package/lib/documents.js +9 -9
- package/lib/file-manager.js +9 -9
- package/lib/gateway.js +1737 -1406
- package/lib/index.js +500 -488
- package/lib/locales/en.js +114 -0
- package/lib/locales/index.js +21 -0
- package/lib/locales/zh.js +114 -0
- package/lib/models.js +1 -1
- package/lib/personas.js +67 -14
- package/lib/photos.js +2 -2
- package/lib/scheduler.js +159 -142
- package/lib/stream.js +4 -2
- package/lib/telegram-errors.js +0 -0
- package/lib/text.js +39 -39
- package/package.json +1 -1
package/lib/alerts.js
CHANGED
|
@@ -8,26 +8,26 @@ export function formatAlertMessage(type, payload = {}) {
|
|
|
8
8
|
const { userId, username, code } = payload
|
|
9
9
|
const userStr = username ? `@${username} (id: <code>${userId}</code>)` : `id: <code>${userId}</code>`
|
|
10
10
|
return [
|
|
11
|
-
`🔐 <b>[
|
|
11
|
+
`🔐 <b>[Pairing Request]</b> <i>(${timestamp})</i>`,
|
|
12
12
|
'',
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
`User: ${userStr}`,
|
|
14
|
+
`Pairing Code: <code>${code}</code>`,
|
|
15
15
|
'',
|
|
16
|
-
|
|
16
|
+
`To approve, send to bot:`,
|
|
17
17
|
`<code>/pair ${code}</code>`,
|
|
18
18
|
].join('\n')
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
if (type === 'error') {
|
|
22
22
|
const { message, code, sessionId, chatId, threadId } = payload
|
|
23
|
-
const location = chatId ?
|
|
24
|
-
const sess = sessionId ?
|
|
23
|
+
const location = chatId ? `Chat: <code>${chatId}</code>${threadId ? ` / thread <code>${threadId}</code>` : ''}` : ''
|
|
24
|
+
const sess = sessionId ? `Session: <code>${sessionId}</code>` : ''
|
|
25
25
|
const meta = [location, sess].filter(Boolean).join('\n')
|
|
26
26
|
|
|
27
27
|
return [
|
|
28
|
-
`🚨 <b>[
|
|
28
|
+
`🚨 <b>[Gateway Error]</b> <i>(${timestamp})</i>`,
|
|
29
29
|
meta ? `\n${meta}` : '',
|
|
30
|
-
|
|
30
|
+
`Error code: <b>${escapeHtml(String(code || 'error'))}</b>`,
|
|
31
31
|
`<code>${escapeHtml(String(message || 'unknown error'))}</code>`,
|
|
32
32
|
].filter(Boolean).join('\n')
|
|
33
33
|
}
|
|
@@ -35,12 +35,12 @@ export function formatAlertMessage(type, payload = {}) {
|
|
|
35
35
|
if (type === 'status') {
|
|
36
36
|
const { title, details } = payload
|
|
37
37
|
return [
|
|
38
|
-
`⚡ <b>[
|
|
38
|
+
`⚡ <b>[Gateway: ${escapeHtml(title || 'Status')}]</b> <i>(${timestamp})</i>`,
|
|
39
39
|
details ? `\n${escapeHtml(details)}` : '',
|
|
40
40
|
].filter(Boolean).join('\n')
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
return `🔔 <b>[
|
|
43
|
+
return `🔔 <b>[Alert: ${type}]</b> <i>(${timestamp})</i>\n${escapeHtml(JSON.stringify(payload))}`
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function resolveAlertTarget(gateway) {
|
package/lib/artifacts.js
CHANGED
|
@@ -1,118 +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,
|
|
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: 'document',
|
|
106
|
-
dataBase64: base64,
|
|
107
|
-
bytes: Buffer.from(svgContent, 'utf-8'),
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
text = text.replace(diag.fullMatch, `\n📊 <b>[
|
|
111
|
-
diagramIndex++
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
text = formatMarkdownTables(text)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return { text, files, diagramsCount: files.length }
|
|
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, `Diagram ${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: 'document',
|
|
106
|
+
dataBase64: base64,
|
|
107
|
+
bytes: Buffer.from(svgContent, 'utf-8'),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
text = text.replace(diag.fullMatch, `\n📊 <b>[Diagram ${diagramIndex}: see attachment]</b>\n`)
|
|
111
|
+
diagramIndex++
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
text = formatMarkdownTables(text)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { text, files, diagramsCount: files.length }
|
|
118
118
|
}
|
package/lib/ask.js
CHANGED
|
@@ -1,152 +1,152 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { normalizeThreadId } from './topics.js'
|
|
3
|
-
|
|
4
|
-
export const TELEGRAM_CALLBACK_DATA_MAX = 64
|
|
5
|
-
export const DEFAULT_PAGE_SIZE = 6
|
|
6
|
-
|
|
7
|
-
export function makeAskToken() {
|
|
8
|
-
return randomUUID().replace(/-/g, '').slice(0, 12)
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function buildCallbackData(token, buttonId) {
|
|
12
|
-
const data = `${token}:${buttonId}`
|
|
13
|
-
if (data.length > TELEGRAM_CALLBACK_DATA_MAX) {
|
|
14
|
-
const err = new Error(`callback_data too long (${data.length} > ${TELEGRAM_CALLBACK_DATA_MAX})`)
|
|
15
|
-
err.status = 400
|
|
16
|
-
throw err
|
|
17
|
-
}
|
|
18
|
-
return data
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function parseCallbackData(data) {
|
|
22
|
-
const raw = String(data || '')
|
|
23
|
-
const idx = raw.indexOf(':')
|
|
24
|
-
if (idx <= 0) return { token: undefined, buttonId: raw }
|
|
25
|
-
return { token: raw.slice(0, idx), buttonId: raw.slice(idx + 1) }
|
|
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
|
-
|
|
43
|
-
export function buildInlineKeyboard(token, buttons) {
|
|
44
|
-
const callbackKeys = []
|
|
45
|
-
const rows = (buttons || []).map((row) => row.map((btn) => {
|
|
46
|
-
const callback_data = buildCallbackData(token, btn.id)
|
|
47
|
-
callbackKeys.push(callback_data)
|
|
48
|
-
return { text: btn.text, callback_data }
|
|
49
|
-
}))
|
|
50
|
-
return {
|
|
51
|
-
replyMarkup: rows.length ? { inline_keyboard: rows } : undefined,
|
|
52
|
-
callbackKeys,
|
|
53
|
-
}
|
|
54
|
-
}
|
|
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: '⬅️
|
|
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: '
|
|
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
|
-
|
|
129
|
-
export function indexCallbacks(callbackIndex, keys, token) {
|
|
130
|
-
for (const key of keys || []) callbackIndex.set(key, token)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function releaseCallbacks(callbackIndex, keys) {
|
|
134
|
-
for (const key of keys || []) callbackIndex.delete(key)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function targetMatchesAsk(pending, cb) {
|
|
138
|
-
const target = pending?.target
|
|
139
|
-
if (!target) return false
|
|
140
|
-
if (cb?.platform && target.platform && cb.platform !== target.platform) return false
|
|
141
|
-
if (cb?.chatId !== undefined && String(target.chatId) !== String(cb.chatId)) return false
|
|
142
|
-
if (normalizeThreadId(target.threadId) !== normalizeThreadId(cb?.threadId)) return false
|
|
143
|
-
return true
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function rejectPendingAsk(pending, err) {
|
|
147
|
-
if (!pending) return
|
|
148
|
-
clearTimeout(pending.timer)
|
|
149
|
-
pending.reject(err)
|
|
150
|
-
}
|
|
151
|
-
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { normalizeThreadId } from './topics.js'
|
|
3
|
+
|
|
4
|
+
export const TELEGRAM_CALLBACK_DATA_MAX = 64
|
|
5
|
+
export const DEFAULT_PAGE_SIZE = 6
|
|
6
|
+
|
|
7
|
+
export function makeAskToken() {
|
|
8
|
+
return randomUUID().replace(/-/g, '').slice(0, 12)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildCallbackData(token, buttonId) {
|
|
12
|
+
const data = `${token}:${buttonId}`
|
|
13
|
+
if (data.length > TELEGRAM_CALLBACK_DATA_MAX) {
|
|
14
|
+
const err = new Error(`callback_data too long (${data.length} > ${TELEGRAM_CALLBACK_DATA_MAX})`)
|
|
15
|
+
err.status = 400
|
|
16
|
+
throw err
|
|
17
|
+
}
|
|
18
|
+
return data
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseCallbackData(data) {
|
|
22
|
+
const raw = String(data || '')
|
|
23
|
+
const idx = raw.indexOf(':')
|
|
24
|
+
if (idx <= 0) return { token: undefined, buttonId: raw }
|
|
25
|
+
return { token: raw.slice(0, idx), buttonId: raw.slice(idx + 1) }
|
|
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
|
+
|
|
43
|
+
export function buildInlineKeyboard(token, buttons) {
|
|
44
|
+
const callbackKeys = []
|
|
45
|
+
const rows = (buttons || []).map((row) => row.map((btn) => {
|
|
46
|
+
const callback_data = buildCallbackData(token, btn.id)
|
|
47
|
+
callbackKeys.push(callback_data)
|
|
48
|
+
return { text: btn.text, callback_data }
|
|
49
|
+
}))
|
|
50
|
+
return {
|
|
51
|
+
replyMarkup: rows.length ? { inline_keyboard: rows } : undefined,
|
|
52
|
+
callbackKeys,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
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: '⬅️ Back', 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: 'Next ➡️', callback_data: nextData })
|
|
106
|
+
}
|
|
107
|
+
rows.push(navRow)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Action buttons (Done / Cancel)
|
|
111
|
+
const doneText = opts.doneText || '✅ Done'
|
|
112
|
+
const cancelText = opts.cancelText || '❌ Cancel'
|
|
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
|
+
|
|
129
|
+
export function indexCallbacks(callbackIndex, keys, token) {
|
|
130
|
+
for (const key of keys || []) callbackIndex.set(key, token)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function releaseCallbacks(callbackIndex, keys) {
|
|
134
|
+
for (const key of keys || []) callbackIndex.delete(key)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function targetMatchesAsk(pending, cb) {
|
|
138
|
+
const target = pending?.target
|
|
139
|
+
if (!target) return false
|
|
140
|
+
if (cb?.platform && target.platform && cb.platform !== target.platform) return false
|
|
141
|
+
if (cb?.chatId !== undefined && String(target.chatId) !== String(cb.chatId)) return false
|
|
142
|
+
if (normalizeThreadId(target.threadId) !== normalizeThreadId(cb?.threadId)) return false
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function rejectPendingAsk(pending, err) {
|
|
147
|
+
if (!pending) return
|
|
148
|
+
clearTimeout(pending.timer)
|
|
149
|
+
pending.reject(err)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
152
|
export const REMOVE_KEYBOARD = { inline_keyboard: [] }
|