@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/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>[Запрос сопряжения]</b> <i>(${timestamp})</i>`,
11
+ `🔐 <b>[Pairing Request]</b> <i>(${timestamp})</i>`,
12
12
  '',
13
- `Пользователь: ${userStr}`,
14
- `Код доступа: <code>${code}</code>`,
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 ? `Чат: <code>${chatId}</code>${threadId ? ` / топик <code>${threadId}</code>` : ''}` : ''
24
- const sess = sessionId ? `Сессия: <code>${sessionId}</code>` : ''
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>[Ошибка шлюза]</b> <i>(${timestamp})</i>`,
28
+ `🚨 <b>[Gateway Error]</b> <i>(${timestamp})</i>`,
29
29
  meta ? `\n${meta}` : '',
30
- `Ошибка: <b>${escapeHtml(String(code || 'error'))}</b>`,
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>[Шлюз: ${escapeHtml(title || 'Статус')}]</b> <i>(${timestamp})</i>`,
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>[Алерт: ${type}]</b> <i>(${timestamp})</i>\n${escapeHtml(JSON.stringify(payload))}`
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, '&amp;')
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: 'document',
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 }
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, '&amp;')
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, `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: '⬅️ Назад', 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
-
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: [] }