@goodandready/dsh-messenger-gateway 0.3.21 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ask.js CHANGED
@@ -1,152 +1,203 @@
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
- export const REMOVE_KEYBOARD = { inline_keyboard: [] }
1
+ import { randomUUID } from 'node:crypto'
2
+ import { normalizeThreadId } from './topics.js'
3
+ import { isTopicGoneError } from './telegram-errors.js'
4
+
5
+ export const TELEGRAM_CALLBACK_DATA_MAX = 64
6
+ export const DEFAULT_PAGE_SIZE = 6
7
+
8
+ export function makeAskToken() {
9
+ return randomUUID().replace(/-/g, '').slice(0, 12)
10
+ }
11
+
12
+ export function buildCallbackData(token, buttonId) {
13
+ const data = `${token}:${buttonId}`
14
+ if (data.length > TELEGRAM_CALLBACK_DATA_MAX) {
15
+ const err = new Error(`callback_data too long (${data.length} > ${TELEGRAM_CALLBACK_DATA_MAX})`)
16
+ err.status = 400
17
+ throw err
18
+ }
19
+ return data
20
+ }
21
+
22
+ export function parseCallbackData(data) {
23
+ const raw = String(data || '')
24
+ const idx = raw.indexOf(':')
25
+ if (idx <= 0) return { token: undefined, buttonId: raw }
26
+ return { token: raw.slice(0, idx), buttonId: raw.slice(idx + 1) }
27
+ }
28
+
29
+ export function parseAskCallback(data) {
30
+ const raw = String(data || '')
31
+ const parts = raw.split(':')
32
+ if (parts.length < 2) return { token: undefined, kind: 'unknown', id: raw }
33
+ const token = parts[0]
34
+ const rest = parts.slice(1).join(':')
35
+
36
+ if (rest === 'done') return { token, kind: 'done', id: 'done' }
37
+ if (rest === 'cancel') return { token, kind: 'cancel', id: 'cancel' }
38
+ if (rest.startsWith('t:')) return { token, kind: 'toggle', id: rest.slice(2) }
39
+ if (rest.startsWith('p:')) return { token, kind: 'page', page: Number(rest.slice(2)) || 0 }
40
+
41
+ return { token, kind: 'select', id: rest }
42
+ }
43
+
44
+ export function buildInlineKeyboard(token, buttons) {
45
+ const callbackKeys = []
46
+ const rows = (buttons || []).map((row) => row.map((btn) => {
47
+ const callback_data = buildCallbackData(token, btn.id)
48
+ callbackKeys.push(callback_data)
49
+ return { text: btn.text, callback_data }
50
+ }))
51
+ return {
52
+ replyMarkup: rows.length ? { inline_keyboard: rows } : undefined,
53
+ callbackKeys,
54
+ }
55
+ }
56
+
57
+ export function normalizeAskOptions(options = []) {
58
+ if (Array.isArray(options)) {
59
+ const flat = options.flat ? options.flat() : [].concat(...options)
60
+ return flat.map((opt, i) => {
61
+ if (typeof opt === 'string') return { id: String(i + 1), text: opt, selected: false }
62
+ const id = String(opt?.id ?? opt?.buttonId ?? i + 1).trim()
63
+ const text = String(opt?.text ?? opt?.label ?? id).trim()
64
+ return { id, text, selected: Boolean(opt?.selected) }
65
+ })
66
+ }
67
+ return []
68
+ }
69
+
70
+ export function buildMultiSelectKeyboard(token, options = [], selectedIds = new Set(), page = 0, pageSize = DEFAULT_PAGE_SIZE, opts = {}) {
71
+ const normalized = normalizeAskOptions(options)
72
+ const total = normalized.length
73
+ const limit = Math.max(1, pageSize)
74
+ const maxPages = Math.ceil(total / limit) || 1
75
+ const curPage = Math.max(0, Math.min(page, maxPages - 1))
76
+ const start = curPage * limit
77
+ const end = Math.min(start + limit, total)
78
+ const slice = normalized.slice(start, end)
79
+
80
+ const callbackKeys = []
81
+ const rows = []
82
+
83
+ for (const opt of slice) {
84
+ const isChecked = selectedIds.has(opt.id)
85
+ const icon = isChecked ? '☑️' : '⬜️'
86
+ const text = `${icon} ${opt.text}`
87
+ const callback_data = buildCallbackData(token, `t:${opt.id}`)
88
+ callbackKeys.push(callback_data)
89
+ rows.push([{ text, callback_data }])
90
+ }
91
+
92
+ // Pagination row if more than one page
93
+ if (maxPages > 1) {
94
+ const navRow = []
95
+ if (curPage > 0) {
96
+ const prevData = buildCallbackData(token, `p:${curPage - 1}`)
97
+ callbackKeys.push(prevData)
98
+ navRow.push({ text: '⬅️ Back', callback_data: prevData })
99
+ }
100
+ const indicatorData = buildCallbackData(token, `p:${curPage}`)
101
+ callbackKeys.push(indicatorData)
102
+ navRow.push({ text: `${curPage + 1}/${maxPages}`, callback_data: indicatorData })
103
+ if (curPage < maxPages - 1) {
104
+ const nextData = buildCallbackData(token, `p:${curPage + 1}`)
105
+ callbackKeys.push(nextData)
106
+ navRow.push({ text: 'Next ➡️', callback_data: nextData })
107
+ }
108
+ rows.push(navRow)
109
+ }
110
+
111
+ // Action buttons (Done / Cancel)
112
+ const doneText = opts.doneText || ' Done'
113
+ const cancelText = opts.cancelText || '❌ Cancel'
114
+ const doneData = buildCallbackData(token, 'done')
115
+ const cancelData = buildCallbackData(token, 'cancel')
116
+ callbackKeys.push(doneData, cancelData)
117
+ rows.push([
118
+ { text: doneText, callback_data: doneData },
119
+ { text: cancelText, callback_data: cancelData },
120
+ ])
121
+
122
+ return {
123
+ replyMarkup: { inline_keyboard: rows },
124
+ callbackKeys,
125
+ page: curPage,
126
+ maxPages,
127
+ }
128
+ }
129
+
130
+ export function indexCallbacks(callbackIndex, keys, token) {
131
+ for (const key of keys || []) callbackIndex.set(key, token)
132
+ }
133
+
134
+ export function releaseCallbacks(callbackIndex, keys) {
135
+ for (const key of keys || []) callbackIndex.delete(key)
136
+ }
137
+
138
+ export function targetMatchesAsk(pending, cb) {
139
+ const target = pending?.target
140
+ if (!target) return false
141
+ if (cb?.platform && target.platform && cb.platform !== target.platform) return false
142
+ if (cb?.chatId !== undefined && String(target.chatId) !== String(cb.chatId)) return false
143
+ if (normalizeThreadId(target.threadId) !== normalizeThreadId(cb?.threadId)) return false
144
+ return true
145
+ }
146
+
147
+ export function rejectPendingAsk(pending, err) {
148
+ if (!pending) return
149
+ clearTimeout(pending.timer)
150
+ pending.reject(err)
151
+ }
152
+
153
+ export const REMOVE_KEYBOARD = { inline_keyboard: [] }
154
+
155
+ export async function executeMessengerAsk(gw, target, payload, timeoutMs = 300_000) {
156
+ const adapter = gw.getAdapter(target.platform)
157
+ if (!adapter) throw new Error(`adapter ${target.platform} unavailable`)
158
+ const token = makeAskToken()
159
+ const isMulti = payload.mode === 'multi' || (Array.isArray(payload.options) && payload.options.length > 0)
160
+ let replyMarkup
161
+ let callbackKeys
162
+ let selectedSet
163
+ let page = 0
164
+ const pageSize = Number(payload.pageSize) || 6
165
+
166
+ if (isMulti) {
167
+ selectedSet = new Set(Array.isArray(payload.selected) ? payload.selected : [])
168
+ const kb = buildMultiSelectKeyboard(token, payload.options || payload.buttons, selectedSet, page, pageSize, payload)
169
+ replyMarkup = kb.replyMarkup
170
+ callbackKeys = kb.callbackKeys
171
+ } else {
172
+ const kb = buildInlineKeyboard(token, payload.buttons || [])
173
+ replyMarkup = kb.replyMarkup
174
+ callbackKeys = kb.callbackKeys
175
+ }
176
+
177
+ indexCallbacks(gw.callbackIndex, callbackKeys, token)
178
+ try {
179
+ await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
180
+ } catch (err) {
181
+ // Never leave stale callback keys pointing at an unresolvable ask.
182
+ releaseCallbacks(gw.callbackIndex, callbackKeys)
183
+ if (isTopicGoneError(err)) {
184
+ gw.logger?.warn?.(`messengerAsk: topic gone for ${target.platform}:${target.chatId}:${target.threadId} — ask aborted (chat/topic deleted)`)
185
+ throw new Error('messenger.ask: chat or topic was deleted')
186
+ }
187
+ throw err
188
+ }
189
+ return new Promise((resolve, reject) => {
190
+ const timer = setTimeout(() => {
191
+ gw.pendingAsks.delete(token)
192
+ releaseCallbacks(gw.callbackIndex, callbackKeys)
193
+ reject(new Error('messenger.ask timed out'))
194
+ }, timeoutMs)
195
+ timer.unref?.()
196
+ gw.pendingAsks.set(token, {
197
+ resolve, reject, timer, target, callbackKeys, isMulti,
198
+ options: payload.options || payload.buttons,
199
+ selected: selectedSet,
200
+ page, pageSize, payload,
201
+ })
202
+ })
203
+ }