@goodandready/dsh-messenger-gateway 0.3.10 → 0.3.11

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.
@@ -69,8 +69,12 @@ export class TelegramAdapter {
69
69
  }
70
70
 
71
71
  async call(method, params = {}) {
72
+ const timeoutMs = (this.timeoutSeconds * 1000) + 15000
72
73
  const res = await fetch(`${API}/bot${this.token}/${method}`, {
73
- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params),
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify(params),
77
+ signal: AbortSignal.timeout(timeoutMs),
74
78
  })
75
79
  const json = await res.json().catch(() => ({}))
76
80
  if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
@@ -78,7 +82,12 @@ export class TelegramAdapter {
78
82
  }
79
83
 
80
84
  async callMultipart(method, form) {
81
- const res = await fetch(`${API}/bot${this.token}/${method}`, { method: 'POST', body: form })
85
+ const timeoutMs = (this.timeoutSeconds * 1000) + 30000
86
+ const res = await fetch(`${API}/bot${this.token}/${method}`, {
87
+ method: 'POST',
88
+ body: form,
89
+ signal: AbortSignal.timeout(timeoutMs),
90
+ })
82
91
  const json = await res.json().catch(() => ({}))
83
92
  if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
84
93
  return json.result
@@ -105,7 +114,10 @@ export class TelegramAdapter {
105
114
  async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
106
115
 
107
116
  async downloadFile(filePath) {
108
- const res = await fetch(`${API}/file/bot${this.token}/${filePath}`)
117
+ const timeoutMs = (this.timeoutSeconds * 1000) + 30000
118
+ const res = await fetch(`${API}/file/bot${this.token}/${filePath}`, {
119
+ signal: AbortSignal.timeout(timeoutMs),
120
+ })
109
121
  if (!res.ok) throw new Error(`telegram download HTTP ${res.status}`)
110
122
  return new Uint8Array(await res.arrayBuffer())
111
123
  }
@@ -203,6 +215,15 @@ export class TelegramAdapter {
203
215
 
204
216
  async dispatchUpdate(update) {
205
217
  if (update.callback_query) {
218
+ const fromId = update.callback_query.from?.id
219
+ if (fromId && !this.allowed(fromId)) {
220
+ await this.call('answerCallbackQuery', {
221
+ callback_query_id: update.callback_query.id,
222
+ text: 'Нет доступа',
223
+ show_alert: true,
224
+ }).catch(() => {})
225
+ return
226
+ }
206
227
  try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
207
228
  this.logger?.warn?.(`callback: ${e.message}`)
208
229
  }
@@ -489,7 +510,8 @@ export class TelegramAdapter {
489
510
  if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
490
511
  const blob = new Blob([file.bytes])
491
512
  const name = safeName(file.name || 'file')
492
- if (file.kind === 'photo') { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
513
+ const isSvg = file.mime === 'image/svg+xml' || (file.name && file.name.toLowerCase().endsWith('.svg'))
514
+ if (file.kind === 'photo' && !isSvg) { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
493
515
  if (file.kind === 'voice') { form.append('voice', blob, name); return this.sendWithRetry('sendVoice', form, { multipart: true }) }
494
516
  if (file.kind === 'audio') { form.append('audio', blob, name); return this.sendWithRetry('sendAudio', form, { multipart: true }) }
495
517
  if (file.kind === 'video') { form.append('video', blob, name); return this.sendWithRetry('sendVideo', form, { multipart: true }) }
package/lib/artifacts.js CHANGED
@@ -102,7 +102,7 @@ export function processDiagramsAndTables(rawText, options = {}) {
102
102
  files.push({
103
103
  name: `diagram-${diagramIndex}.svg`,
104
104
  mime: 'image/svg+xml',
105
- kind: 'photo',
105
+ kind: 'document',
106
106
  dataBase64: base64,
107
107
  bytes: Buffer.from(svgContent, 'utf-8'),
108
108
  })
@@ -1,5 +1,5 @@
1
1
  import { readdir, stat, readFile } from 'node:fs/promises'
2
- import { resolve, normalize, relative, join, extname } from 'node:path'
2
+ import { resolve, normalize, relative, join, extname, isAbsolute } from 'node:path'
3
3
 
4
4
  export function formatFileSize(bytes) {
5
5
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
@@ -14,8 +14,9 @@ export function resolveSafePath(baseCwd, userPath = '.') {
14
14
  const cleanUser = String(userPath || '').trim() || '.'
15
15
  const target = resolve(base, cleanUser)
16
16
 
17
+ const isDrivePath = /^[a-zA-Z]:/.test(cleanUser)
17
18
  const rel = relative(base, target)
18
- if (rel.startsWith('..') || (rel && resolve(base, rel) !== target)) {
19
+ if (isAbsolute(cleanUser) || isDrivePath || rel.startsWith('..') || isAbsolute(rel) || (rel && resolve(base, rel) !== target)) {
19
20
  const err = new Error('Access denied: path traversal out of workspace')
20
21
  err.code = 'ERR_PATH_TRAVERSAL'
21
22
  throw err
package/lib/gateway.js CHANGED
@@ -296,6 +296,10 @@ export class Gateway {
296
296
  }
297
297
 
298
298
  async handleCallback(cb) {
299
+ if (cb.userId && !this.isUserAllowed(cb.userId)) {
300
+ try { await cb.answer('Нет доступа.') } catch {}
301
+ return
302
+ }
299
303
  const indexed = this.callbackIndex.get(cb.data)
300
304
  const { token, buttonId } = parseCallbackData(cb.data)
301
305
  const askToken = indexed || token
@@ -368,7 +372,7 @@ export class Gateway {
368
372
  const sub = parts[1]
369
373
  // Step 2: Selected provider -> show its models (10 per page)
370
374
  if (sub === 'p') {
371
- const providerId = parts[2]
375
+ const providerId = parts.slice(2).join(':')
372
376
  const current = this.resolveAgentModel()
373
377
  const catalog = await listModelCatalog(this.ctx, current)
374
378
  const models = catalog.modelsByProvider.get(providerId) || []
@@ -389,8 +393,8 @@ export class Gateway {
389
393
  }
390
394
  // Pagination for models
391
395
  if (sub === 'pg') {
392
- const providerId = parts[2]
393
- const page = parseInt(parts[3], 10) || 0
396
+ const page = parseInt(parts[parts.length - 1], 10) || 0
397
+ const providerId = parts.slice(2, -1).join(':')
394
398
  const current = this.resolveAgentModel()
395
399
  const catalog = await listModelCatalog(this.ctx, current)
396
400
  const models = catalog.modelsByProvider.get(providerId) || []
@@ -709,16 +713,6 @@ export class Gateway {
709
713
  }
710
714
  return reply({ text: `📄 Файл: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
711
715
  }
712
- if (cmd === '/keyboard') {
713
- const sub = String(parts[1] || 'status').toLowerCase()
714
- const tgAdapter = this.getAdapter('telegram')
715
- if (sub === 'on' || sub === 'off') {
716
- if (tgAdapter) tgAdapter.quickActions = sub === 'on'
717
- return reply(sub === 'on' ? 'Быстрая клавиатура: включена.' : 'Быстрая клавиатура: выключена.', sub === 'off' ? { replyMarkup: { remove_keyboard: true } } : undefined)
718
- }
719
- const cur = tgAdapter?.quickActions !== false
720
- return reply(`Быстрая клавиатура: ${cur ? 'on' : 'off'}\nСмена: /keyboard on|off`)
721
- }
722
716
  if (cmd === '/new') {
723
717
  const chat = this.chats.get(key)
724
718
  if (chat) {
@@ -1328,8 +1322,10 @@ export class Gateway {
1328
1322
  if (timeout <= 0) return
1329
1323
  const now = Date.now()
1330
1324
  for (const [key, chat] of this.chats) {
1331
- if (now - chat.lastUsed > timeout) {
1332
- this.sessionToChat.delete(String(chat.agent.session.id))
1325
+ if (!chat.turnActive && now - chat.lastUsed > timeout) {
1326
+ if (chat.agent?.session?.id) {
1327
+ this.sessionToChat.delete(String(chat.agent.session.id))
1328
+ }
1333
1329
  this.chats.delete(key)
1334
1330
  chat.dispose().catch(() => {})
1335
1331
  }
package/lib/models.js CHANGED
@@ -39,16 +39,29 @@ export async function listModelCatalog(ctx, fallback = {}) {
39
39
 
40
40
  // In-memory registry for model picker callback tokens to avoid 64-byte Telegram limit
41
41
  const modelIndexStore = new Map()
42
+ const modelToKeyMap = new Map()
42
43
  let modelIndexCounter = 0
43
44
 
44
45
  export function storeModelSelection(provider, model) {
46
+ const compositeKey = `${provider}::${model}`
47
+ const existingKey = modelToKeyMap.get(compositeKey)
48
+ if (existingKey && modelIndexStore.has(existingKey)) {
49
+ modelIndexStore.get(existingKey).time = Date.now()
50
+ return existingKey
51
+ }
52
+
45
53
  const key = String(++modelIndexCounter)
46
54
  modelIndexStore.set(key, { provider, model, time: Date.now() })
55
+ modelToKeyMap.set(compositeKey, key)
56
+
47
57
  // Cleanup entries older than 1 hour
48
58
  if (modelIndexStore.size > 200) {
49
59
  const cutoff = Date.now() - 3600000
50
60
  for (const [k, v] of modelIndexStore.entries()) {
51
- if (v.time < cutoff) modelIndexStore.delete(k)
61
+ if (v.time < cutoff) {
62
+ modelIndexStore.delete(k)
63
+ modelToKeyMap.delete(`${v.provider}::${v.model}`)
64
+ }
52
65
  }
53
66
  }
54
67
  return key
package/lib/scheduler.js CHANGED
@@ -46,8 +46,16 @@ export function createScheduler(filePath, onDue) {
46
46
  loaded = true
47
47
  }
48
48
 
49
+ const RETENTION_MS = 7 * 86400 * 1000
50
+
49
51
  async function save() {
50
52
  try {
53
+ const now = Date.now()
54
+ tasks = tasks.filter((t) => {
55
+ if (t.status === 'pending') return true
56
+ const finishTime = t.firedAt || t.createdAt || 0
57
+ return (now - finishTime) < RETENTION_MS
58
+ })
51
59
  await mkdir(dirname(filePath), { recursive: true })
52
60
  await writeFile(filePath, JSON.stringify(tasks, null, 2), 'utf8')
53
61
  } catch {}
@@ -12,7 +12,7 @@ function renderInline(t) {
12
12
  let s = escapeHtml(t)
13
13
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
14
14
  s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>')
15
- s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${escapeHtml(url)}">${label}</a>`)
15
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${url.replace(/"/g, '%22')}">${label}</a>`)
16
16
  s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<i>$2</i>')
17
17
  return s
18
18
  }
@@ -123,7 +123,7 @@ export function markdownToTelegramHtml(markdown) {
123
123
  text = convertDetails(text, stash)
124
124
  text = convertTaskLists(text, stash)
125
125
  text = escapeHtml(text)
126
- text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${escapeHtml(url)}">${label}</a>`)
126
+ text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${url.replace(/"/g, '%22')}">${label}</a>`)
127
127
  text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
128
128
  text = text.replace(/__([^_\n]+)__/g, '<b>$1</b>')
129
129
  text = text.replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
5
5
  "license": "MIT",
6
6
  "type": "module",