@goodandready/dsh-messenger-gateway 0.1.0 → 0.2.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/gateway.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto'
3
- import { homedir } from 'node:os'
4
3
  import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
5
5
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
6
6
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
7
7
  import { SessionId } from '@deepseek-ai/dsh-session'
@@ -10,21 +10,47 @@ import { transcribeVoice, speakText } from './integrations.js'
10
10
  import { attachInboundPhoto, photoOnlyHint } from './photos.js'
11
11
  import { formatInboundDocument, documentOnlyHint } from './documents.js'
12
12
  import { collectAssistantParts, buildOutboundFiles, stripImageUrls } from './outbound.js'
13
- import { assistantText, splitText } from './text.js'
14
- import { chatKey } from './topics.js'
15
- import { prepareTtsText, voiceReplyFile } from './tts.js'
13
+ import { assistantText, splitText, stripReasoningPreamble, MESSENGER_RELAY_INSTRUCTION } from './text.js'
14
+ import { chatKey, sessionKey } from './topics.js'
15
+ import { listHomes, resolveNamedHome, upsertHome, normalizeHomeName } from './homes.js'
16
+ import { createVoicePrefs, shouldSpeakReply } from './voice-prefs.js'
17
+ import { prepareTtsText, toTelegramVoiceFile } from './tts.js'
16
18
  import {
17
19
  makeAskToken, buildInlineKeyboard, indexCallbacks, releaseCallbacks,
18
20
  parseCallbackData, targetMatchesAsk, rejectPendingAsk, REMOVE_KEYBOARD,
19
21
  } from './ask.js'
22
+ import { createPairingStore } from './pairing.js'
23
+ import {
24
+ extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
25
+ createEditScheduler, startTypingHeartbeat,
26
+ } from './stream.js'
27
+
28
+ function whenIdleWithTimeout(agent, timeoutMs, signal) {
29
+ const idle = agent.whenIdle()
30
+ if (!timeoutMs || timeoutMs <= 0) return idle
31
+ return Promise.race([
32
+ idle,
33
+ new Promise((_, reject) => {
34
+ const timer = setTimeout(() => reject(new Error(`turn timeout (${timeoutMs}ms)`)), timeoutMs)
35
+ timer.unref?.()
36
+ signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
37
+ }),
38
+ ])
39
+ }
40
+
41
+ function releaseChatTurn(chat) {
42
+ chat.busy = Promise.resolve()
43
+ }
20
44
 
21
45
  const PLUGIN = 'dsh-messenger-gateway'
22
46
 
23
47
  export class Gateway {
24
- constructor(ctx, config) {
48
+ constructor(ctx, config, hooks = {}) {
25
49
  this.ctx = ctx
26
50
  this.config = config
51
+ this.hooks = hooks
27
52
  this.chats = new Map()
53
+ this.sessionToChat = new Map()
28
54
  this.pending = new Map()
29
55
  this.pendingAsks = new Map()
30
56
  this.adapters = new Map()
@@ -32,6 +58,9 @@ export class Gateway {
32
58
  this.disposeListener = undefined
33
59
  this.idleTimer = undefined
34
60
  this.callbackIndex = new Map()
61
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
62
+ this.pairing = createPairingStore(join(home, 'messenger-gateway', 'pairing.json'))
63
+ this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'))
35
64
  }
36
65
 
37
66
  baseUrl() {
@@ -39,6 +68,28 @@ export class Gateway {
39
68
  return raw || 'http://127.0.0.1:3080'
40
69
  }
41
70
 
71
+ tg() { return this.config.telegram || {} }
72
+
73
+ effectiveAllowedIds() {
74
+ const fromConfig = (this.tg().allowedUserIds || []).map(Number).filter(Number.isFinite)
75
+ const fromPairing = this.pairing.listApproved()
76
+ return [...new Set([...fromConfig, ...fromPairing])]
77
+ }
78
+
79
+ isUserAllowed(userId) {
80
+ const ids = this.effectiveAllowedIds()
81
+ return ids.length === 0 || ids.includes(Number(userId))
82
+ }
83
+
84
+ resolveHomeTarget(platform = 'telegram', name) {
85
+ const tg = this.tg()
86
+ const home = resolveNamedHome(tg, name)
87
+ if (!home) return null
88
+ const out = { platform, chatId: home.chatId, homeName: home.name }
89
+ if (home.threadId > 0) out.threadId = home.threadId
90
+ return out
91
+ }
92
+
42
93
  async start() {
43
94
  this.disposeListener = this.ctx.on('session/event', (session, event) => {
44
95
  const collector = this.pending.get(session.id)
@@ -46,9 +97,21 @@ export class Gateway {
46
97
  if (event.type === 'assistant/message') {
47
98
  const msg = event.data.message
48
99
  const text = assistantText(msg)
49
- if (text) collector.parts.push(text)
100
+ if (text) collector.lastText = text
50
101
  const extra = collectAssistantParts(msg)
51
102
  for (const img of extra.images) collector.images.push(img)
103
+ } else if (event.type === 'assistant/chunk') {
104
+ const delta = extractTextDelta(event.data?.chunk)
105
+ if (delta) {
106
+ collector.streamText = (collector.streamText || '') + delta
107
+ collector.onStream?.(collector.streamText, collector.toolName)
108
+ }
109
+ } else if (event.type === 'tool/call') {
110
+ collector.toolName = extractToolName(event.data) || collector.toolName
111
+ collector.onStream?.(collector.streamText || '', collector.toolName)
112
+ } else if (event.type === 'tool/result') {
113
+ collector.toolName = ''
114
+ collector.onStream?.(collector.streamText || '', '')
52
115
  } else if (event.type === 'turn/end') {
53
116
  collector.reason = event.data.reason
54
117
  }
@@ -57,6 +120,8 @@ export class Gateway {
57
120
  config: this.config,
58
121
  onMessage: (input) => this.handleMessage(input),
59
122
  onCallback: (cb) => this.handleCallback(cb),
123
+ onUnauthorized: (input) => this.handleUnauthorized(input),
124
+ isUserAllowed: (id) => this.isUserAllowed(id),
60
125
  logger: this.ctx.logger,
61
126
  })
62
127
  for (const adapter of adapters) {
@@ -84,6 +149,7 @@ export class Gateway {
84
149
  this.adapters.clear()
85
150
  for (const chat of this.chats.values()) chat.dispose().catch(() => {})
86
151
  this.chats.clear()
152
+ this.sessionToChat.clear()
87
153
  for (const pending of this.pendingAsks.values()) {
88
154
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
89
155
  rejectPendingAsk(pending, new Error('gateway stopped'))
@@ -93,14 +159,18 @@ export class Gateway {
93
159
  this.callbackIndex.clear()
94
160
  }
95
161
 
96
- getAdapter(platform) {
97
- return this.adapters.get(platform)
98
- }
162
+ getAdapter(platform) { return this.adapters.get(platform) }
99
163
 
100
164
  async messengerSend(target, payload) {
101
- const adapter = this.getAdapter(target.platform)
102
- if (!adapter?.sendTo) throw new Error(`adapter ${target.platform} unavailable`)
103
- await adapter.sendTo(target.chatId, payload, { threadId: target.threadId })
165
+ let resolved = target
166
+ if (!resolved?.chatId && resolved?.platform) {
167
+ const home = this.resolveHomeTarget(resolved.platform, resolved.home || resolved.name)
168
+ if (!home) throw new Error('target.chatId required (or set telegram home channel)')
169
+ resolved = { ...home, ...resolved, chatId: home.chatId, threadId: resolved.threadId ?? home.threadId }
170
+ }
171
+ const adapter = this.getAdapter(resolved.platform)
172
+ if (!adapter?.sendTo) throw new Error(`adapter ${resolved.platform} unavailable`)
173
+ await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
104
174
  }
105
175
 
106
176
  async messengerAsk(target, payload, timeoutMs = 300_000) {
@@ -112,7 +182,6 @@ export class Gateway {
112
182
  await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
113
183
  return new Promise((resolve, reject) => {
114
184
  const timer = setTimeout(() => {
115
- const pending = this.pendingAsks.get(token)
116
185
  this.pendingAsks.delete(token)
117
186
  releaseCallbacks(this.callbackIndex, callbackKeys)
118
187
  reject(new Error('messenger.ask timed out'))
@@ -126,6 +195,21 @@ export class Gateway {
126
195
  await this.messengerSend(target, { text: payload.text })
127
196
  }
128
197
 
198
+ async handleUnauthorized(input) {
199
+ const { reply, userId, username } = input
200
+ if (!this.tg().pairingEnabled) {
201
+ await reply('Доступ закрыт. Попросите владельца добавить ваш id в allowlist.')
202
+ return
203
+ }
204
+ try {
205
+ const { code } = this.pairing.requestCode(userId, { username })
206
+ await reply(`Нет доступа.\nВаш id: ${userId}\nКод сопряжения: ${code}\n\nВладелец должен отправить боту:\n/pair ${code}`)
207
+ } catch (err) {
208
+ if (err.code === 'RATE_LIMIT') await reply('Код уже выдан. Подождите или попросите владельца /pair.')
209
+ else await reply(`Не удалось выдать код: ${err.message}`)
210
+ }
211
+ }
212
+
129
213
  async handleCallback(cb) {
130
214
  const indexed = this.callbackIndex.get(cb.data)
131
215
  const { token, buttonId } = parseCallbackData(cb.data)
@@ -140,87 +224,312 @@ export class Gateway {
140
224
  this.pendingAsks.delete(askToken)
141
225
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
142
226
  await cb.answer('OK')
143
- try {
144
- await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD)
145
- } catch {}
227
+ try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
146
228
  pending.resolve({ buttonId, data: cb.data })
147
229
  return
148
230
  }
149
231
  await cb.answer()
150
232
  }
151
233
 
234
+ sessionKeyFor(input) {
235
+ const scope = this.config.agent?.sessionScope || 'user'
236
+ return sessionKey({
237
+ platform: input.platform,
238
+ chatId: input.chatId,
239
+ threadId: input.threadId || 0,
240
+ userId: input.userId,
241
+ chatType: input.chatType,
242
+ scope,
243
+ })
244
+ }
245
+
246
+ isChatBusy(chat) {
247
+ return Boolean(chat?.turnActive)
248
+ }
249
+
152
250
  async handleMessage(input) {
153
- const { platform, chatId, userId, threadId = 0, text, attachments = [], replyText } = input
154
- const key = chatKey(platform, chatId, threadId)
251
+ const { platform, chatId, threadId = 0, text, reply } = input
252
+ const key = this.sessionKeyFor(input)
155
253
  const body = String(text || '').trim()
254
+ let attachments = [...(input.attachments || [])]
156
255
  const hasMedia = attachments.length > 0
157
256
  if (!body && !hasMedia) return
158
257
  if (body.startsWith('/')) { await this.handleCommand(key, body, input); return }
159
- const chat = await this.getOrCreateChat(key)
160
- if (chat.abort) chat.abort.abort()
161
- chat.abort = new AbortController()
162
- const run = chat.busy.then(() => this.runTurn(chat, input, chat.abort.signal))
163
- chat.busy = run.catch(() => {})
164
- await run
258
+ try {
259
+ const chat = await this.getOrCreateChat(key, input)
260
+ const photoOnlyMode = this.config.agent?.photoOnlyMode ?? 'prompt'
261
+ const incomingPhotoOnly = hasMedia && attachments.every((a) => a.kind === 'photo' || a.kind === 'sticker') && !body
262
+ if (incomingPhotoOnly && photoOnlyMode === 'prompt') {
263
+ chat.pendingMedia = [...(chat.pendingMedia || []), ...attachments]
264
+ const n = chat.pendingMedia.length
265
+ const msg = n === 1
266
+ ? 'Медиа получено. Напишите вопрос — например: «что на фото?»'
267
+ : `Получено ${n} вложений. Напишите вопрос.`
268
+ return reply(msg)
269
+ }
270
+ if (chat.pendingMedia?.length) {
271
+ attachments = [...chat.pendingMedia, ...attachments]
272
+ chat.pendingMedia = []
273
+ }
274
+ const inboundWasVoice = attachments.some((a) => a.kind === 'voice' || a.kind === 'audio')
275
+ const turnInput = { ...input, text: body, attachments, inboundWasVoice }
276
+
277
+ // Hermes-like steer: while a turn is running, inject followup instead of abort+restart
278
+ if (this.isChatBusy(chat)) {
279
+ const steerText = body || (hasMedia ? '(дополнение: медиа)' : '')
280
+ const content = await this.buildUserContent({
281
+ ...turnInput,
282
+ text: steerText,
283
+ steer: true,
284
+ }, undefined)
285
+ chat.agent.followup(createUserMessage({
286
+ content,
287
+ source: { kind: 'plugin', plugin: PLUGIN, form: 'steer' },
288
+ }))
289
+ chat.lastUsed = Date.now()
290
+ try { await reply('↪️ Добавлено к текущему ответу') } catch {}
291
+ return
292
+ }
293
+
294
+ // Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
295
+ chat.turnActive = true
296
+ chat.abort = new AbortController()
297
+ const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
298
+ chat.busy = run.catch(() => {})
299
+ // Do NOT await: Telegram poll is sequential; awaiting blocked steer and /stop until the turn finished.
300
+ run.catch((err) => {
301
+ const msg = err instanceof Error ? err.message : String(err)
302
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: background turn: ${msg}`)
303
+ chat.turnActive = false
304
+ chat.abort = undefined
305
+ })
306
+ } catch (err) {
307
+ const msg = err instanceof Error ? err.message : String(err)
308
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: message: ${msg}`)
309
+ try { await reply(`Ошибка: ${msg}`) } catch {}
310
+ }
165
311
  }
166
312
 
167
313
  async handleCommand(key, text, input) {
168
- const cmd = text.split(/\s+/)[0].toLowerCase()
169
- const { reply, userId } = input
314
+
315
+ const parts = text.split(/\s+/)
316
+ const cmd = parts[0].toLowerCase().split('@')[0]
317
+ const { reply, userId, chatId, threadId = 0, platform } = input
170
318
  if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.')
171
- if (cmd === '/help') return reply(['Команды:', '/help — справка', '/new — новая сессия', '/whoami — ваш id', '/stop — прервать текущий ответ'].join('\n'))
319
+ if (cmd === '/help') {
320
+ return reply([
321
+ 'Команды:',
322
+ '/help — справка',
323
+ '/new — новая сессия',
324
+ '/whoami — ваш id',
325
+ '/stop — прервать текущий ответ',
326
+ '/status — статус шлюза',
327
+ '/model — текущая модель; /model provider model — сменить',
328
+ '/pair CODE — одобрить пользователя',
329
+ '/sethome [name] — этот чат = home (имя опционально)',
330
+ '/home — список home',
331
+ '/voice on|off|status — голосовые ответы',
332
+ ].join('\n'))
333
+ }
172
334
  if (cmd === '/new') {
173
335
  const chat = this.chats.get(key)
174
- if (chat) { if (chat.abort) chat.abort.abort(); this.chats.delete(key); await chat.dispose(); return reply('Сессия сброшена.') }
336
+ if (chat) {
337
+ if (chat.abort) chat.abort.abort()
338
+ chat.pendingMedia = []
339
+ this.sessionToChat.delete(String(chat.agent.session.id))
340
+ this.chats.delete(key)
341
+ await chat.dispose()
342
+ return reply('Сессия сброшена.')
343
+ }
175
344
  return reply('Активной сессии нет.')
176
345
  }
177
346
  if (cmd === '/whoami') return reply(`Ваш id: ${userId}`)
178
347
  if (cmd === '/stop') {
179
348
  const chat = this.chats.get(key)
180
- if (chat?.abort) { chat.abort.abort(); return reply('Прерывание отправлено.') }
349
+ if (chat?.turnActive || chat?.abort) {
350
+ try { chat.abort?.abort() } catch {}
351
+ releaseChatTurn(chat)
352
+ chat.turnActive = false
353
+ return reply('Прерывание отправлено.')
354
+ }
181
355
  return reply('Нечего прерывать.')
182
356
  }
357
+ if (cmd === '/status') {
358
+ let modelLine = 'модель: (не задана)'
359
+ try {
360
+ const sel = this.resolveAgentModel()
361
+ modelLine = `модель: ${sel.provider}/${sel.model}`
362
+ } catch (e) {
363
+ modelLine = `модель: ${e.message}`
364
+ }
365
+ const home = this.resolveHomeTarget(platform || 'telegram')
366
+ const homeLine = home
367
+ ? `home: chat ${home.chatId}${home.threadId ? ` topic ${home.threadId}` : ''}`
368
+ : 'home: не задан'
369
+ const pending = this.pairing.listPending().length
370
+ return reply([
371
+ 'Messenger gateway',
372
+ `адаптеры: ${[...this.adapters.keys()].join(', ') || '(нет)'}`,
373
+ `активных чатов: ${this.chats.size}`,
374
+ modelLine,
375
+ homeLine,
376
+ `pairing pending: ${pending}`,
377
+ `transport: ${this.tg().transport || 'poll'}`,
378
+ `sessionScope: ${this.config.agent?.sessionScope || 'user'}`,
379
+ ].join('\n'))
380
+ }
381
+ if (cmd === '/model') {
382
+ if (parts.length >= 3) {
383
+ if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
384
+ const provider = parts[1]
385
+ const model = parts.slice(2).join(' ')
386
+ try {
387
+ const adm = this.ctx.get('agentDefaultModel')
388
+ if (adm?.saveSelection) {
389
+ await adm.saveSelection({ provider, model })
390
+ }
391
+ this.config.agent = { ...this.config.agent, provider, model }
392
+ try {
393
+ await this.hooks?.persistAgentModel?.({ provider, model })
394
+ } catch (e) {
395
+ this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
396
+ }
397
+ return reply(`Модель сохранена: ${provider}/${model}`)
398
+ } catch (e) {
399
+ return reply(`Не удалось сменить модель: ${e.message}`)
400
+ }
401
+ }
402
+ try {
403
+ const sel = this.resolveAgentModel()
404
+ return reply(`Текущая модель: ${sel.provider}/${sel.model}\nСмена: /model <provider> <model>`)
405
+ } catch (e) {
406
+ return reply(e.message)
407
+ }
408
+ }
409
+ if (cmd === '/pair') {
410
+ if (!this.isUserAllowed(userId)) return reply('Только пользователи из allowlist могут одобрять /pair.')
411
+ const code = parts[1]
412
+ if (!code) return reply('Использование: /pair CODE')
413
+ const res = this.pairing.approveCode(code, userId)
414
+ if (!res.ok) return reply(`Не удалось: ${res.error}`)
415
+ const merged = this.effectiveAllowedIds()
416
+ for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
417
+ try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
418
+ this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
419
+ }
420
+ return reply(`Одобрен user id ${res.userId}${res.username ? ` (@${res.username})` : ''}.`)
421
+ }
422
+ if (cmd === '/sethome') {
423
+ if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
424
+ const name = normalizeHomeName(parts[1] || 'default') || 'default'
425
+ try {
426
+ const nextTg = upsertHome(this.tg(), { name, chatId, threadId })
427
+ await this.hooks?.persistHomes?.(nextTg)
428
+ this.config.telegram = nextTg
429
+ return reply(`Home «${name}»: chat ${chatId}${threadId ? ` topic ${threadId}` : ''}`)
430
+ } catch (e) {
431
+ return reply(`Не удалось сохранить home: ${e.message}`)
432
+ }
433
+ }
434
+ if (cmd === '/home') {
435
+ const homes = listHomes(this.tg())
436
+ if (!homes.length) return reply('Home не задан. /sethome или /sethome name')
437
+ return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
438
+ }
439
+ if (cmd === '/voice') {
440
+ const sub = String(parts[1] || 'status').toLowerCase()
441
+ if (sub === 'on' || sub === 'off') {
442
+ this.voicePrefs.set(userId, sub === 'on')
443
+ return reply(sub === 'on' ? 'Голосовые ответы: on (для вас)' : 'Голосовые ответы: off (для вас)')
444
+ }
445
+ const pref = this.voicePrefs.get(userId)
446
+ const mode = this.tg().voiceMode || 'mirror'
447
+ const prefLine = pref === null ? 'не задан (/voice on|off)' : (pref ? 'on' : 'off')
448
+ return reply(`voiceMode=${mode}\nваш /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
449
+ }
183
450
  return reply(`Неизвестная команда ${cmd}. /help`)
184
451
  }
185
452
 
186
- async getOrCreateChat(key) {
453
+ async getOrCreateChat(key, input) {
187
454
  let chat = this.chats.get(key)
188
- if (!chat) { chat = await this.createChat(key); this.chats.set(key, chat) }
455
+ if (!chat) { chat = await this.createChat(key, input); this.chats.set(key, chat) }
189
456
  chat.lastUsed = Date.now()
457
+ chat.target = { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 }
190
458
  return chat
191
459
  }
192
460
 
193
- async createChat(key) {
461
+ resolveAgentModel() {
462
+ const agentCfg = this.config.agent || {}
463
+ let provider = String(agentCfg.provider || '').trim()
464
+ let model = String(agentCfg.model || '').trim()
465
+ if (provider && model) return { provider, model }
194
466
  const selection = this.ctx.get('agentDefaultModel')?.currentSelection?.()
195
- if (!selection) throw new Error('Выберите модель в Settings → Models')
467
+ if (!selection?.provider || !selection?.model) {
468
+ throw new Error('Выберите модель в Settings → Models (или укажите agent.provider/model в профиле)')
469
+ }
470
+ return { provider: provider || selection.provider, model: model || selection.model }
471
+ }
472
+
473
+ async createChat(key, input) {
474
+ const { provider, model } = this.resolveAgentModel()
196
475
  const agentCfg = this.config.agent || {}
197
- const provider = agentCfg.provider || selection.provider
198
- const model = agentCfg.model || selection.model
199
476
  const cwd = agentCfg.cwd || process.cwd()
477
+ const self = this
200
478
  const handle = await this.ctx.agents.create({
201
479
  sessionId: SessionId(`msgw-${randomUUID()}`),
202
480
  meta: { cwd },
203
481
  agentOptions: { provider, model },
204
- setup: (agentCtx) => installModelSelection(agentCtx, { current: { provider, model }, assembled: undefined }),
482
+ setup: (agentCtx) => {
483
+ installModelSelection(agentCtx, { current: { provider, model }, assembled: undefined })
484
+ if (self.tg().approvalsEnabled !== false) {
485
+ agentCtx.on('approval/request', (req, next) => self.answerApproval(key, req, next))
486
+ }
487
+ },
205
488
  })
206
489
  await handle.agent.whenIdle()
207
- return { key, agent: handle.agent, dispose: handle.dispose, busy: Promise.resolve(), lastUsed: Date.now(), abort: undefined }
490
+ const chat = {
491
+ key, agent: handle.agent, dispose: handle.dispose, busy: Promise.resolve(),
492
+ lastUsed: Date.now(), abort: undefined, pendingMedia: [], turnActive: false,
493
+ target: input ? { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 } : undefined,
494
+ }
495
+ this.sessionToChat.set(String(handle.agent.session.id), key)
496
+ return chat
497
+ }
498
+
499
+ async answerApproval(chatKeyValue, req, next) {
500
+ try {
501
+ const chat = this.chats.get(chatKeyValue)
502
+ if (!chat?.target) return next()
503
+ const tool = req.toolName || 'tool'
504
+ const reason = req.reason ? `\n${req.reason}` : ''
505
+ const result = await this.messengerAsk(chat.target, {
506
+ text: `⚠️ Нужно подтверждение\nИнструмент: ${tool}${reason}`,
507
+ buttons: [[{ id: 'allow', text: '✅ Разрешить' }, { id: 'deny', text: '❌ Отклонить' }]],
508
+ }, 300_000)
509
+ if (result?.buttonId === 'allow') return 'allowed-once'
510
+ if (result?.buttonId === 'deny') return 'rejected'
511
+ return next()
512
+ } catch {
513
+ return next()
514
+ }
208
515
  }
209
516
 
210
517
  async buildUserContent(input, signal) {
211
- const { text, attachments = [], replyText } = input
518
+ const { text, attachments = [], replyText, steer } = input
212
519
  const parts = []
213
- if (this.config.agent?.instructionPrefix) parts.push(String(this.config.agent.instructionPrefix))
520
+ parts.push(String(this.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
521
+ if (steer) parts.push('[Steer / дополнение к текущему ходу — учти вместе с предыдущим запросом, не начинай ответ заново с нуля]')
214
522
  if (replyText?.trim()) parts.push(`[Ответ на сообщение: ${replyText.trim()}]`)
215
523
  const blocks = []
216
524
  for (const att of attachments) {
217
- if (att.kind === 'photo') {
525
+ if (att.kind === 'photo' || (att.kind === 'sticker' && att.mime?.startsWith('image/'))) {
218
526
  try {
219
527
  const { ref } = await attachInboundPhoto(this.ctx, att, {
220
528
  signal,
221
529
  maxBytes: Number(this.config.media?.maxImageBytes) || 20 * 1024 * 1024,
222
530
  })
223
531
  blocks.push({ type: 'image', attachment: ref })
532
+ if (att.kind === 'sticker' && att.emoji) parts.push(`[Стикер ${att.emoji}]`)
224
533
  } catch (err) {
225
534
  if (signal?.aborted) throw err
226
535
  const msg = err instanceof Error ? err.message : String(err)
@@ -237,7 +546,7 @@ export class Gateway {
237
546
  this.ctx.logger?.warn?.(`voice: ${msg}`)
238
547
  parts.push(`[Голосовое сообщение (dsh-voice недоступен: ${msg})]`)
239
548
  }
240
- } else if (att.kind === 'document' || att.kind === 'video') {
549
+ } else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
241
550
  parts.push(formatInboundDocument(att))
242
551
  } else {
243
552
  parts.push(`[Файл: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
@@ -245,6 +554,8 @@ export class Gateway {
245
554
  }
246
555
  const photoHint = photoOnlyHint(attachments, text)
247
556
  if (photoHint) parts.push(photoHint)
557
+ const docHint = documentOnlyHint(attachments, text)
558
+ if (docHint) parts.push(docHint)
248
559
  if (text?.trim()) parts.push(text.trim())
249
560
  const textBlock = parts.filter(Boolean).join('\n\n')
250
561
  if (textBlock) blocks.unshift({ type: 'text', text: textBlock })
@@ -253,39 +564,110 @@ export class Gateway {
253
564
  }
254
565
 
255
566
  async runTurn(chat, input, signal) {
256
- const { reply, typing } = input
567
+ const { reply, typing, startStream, startProgress, react, inboundWasVoice, userId } = input
568
+ chat.turnActive = true
257
569
  const sessionId = chat.agent.session.id
258
- const collector = { parts: [], images: [], reason: undefined }
570
+ const tg = this.tg()
571
+ const streaming = tg.streaming === true && typeof startStream === 'function'
572
+ const progressEnabled = tg.progressEnabled !== false
573
+ const collector = { parts: [], lastText: '', streamText: '', toolName: '', images: [], reason: undefined, onStream: undefined }
259
574
  this.pending.set(sessionId, collector)
575
+ let stopTyping = () => {}
576
+ let stream = null
577
+ let scheduler = null
578
+ let progress = null
260
579
  try {
261
580
  if (signal.aborted) return
262
- if (typing) await typing()
581
+ if (typeof react === 'function' && tg.reactionsEnabled !== false) {
582
+ react('👀').catch?.(() => {})
583
+ }
584
+ if (progressEnabled) stopTyping = startTypingHeartbeat(typing, 4000)
585
+ if (streaming) {
586
+ try {
587
+ stream = await startStream()
588
+ scheduler = createEditScheduler((text) => stream.edit(text), Number(tg.streamEditIntervalMs) || 1200)
589
+ collector.onStream = (text, toolName) => {
590
+ if (!progressEnabled && !text) return
591
+ scheduler.push(buildStreamPreview(text, progressEnabled ? toolName : ''))
592
+ }
593
+ if (progressEnabled) scheduler.push(buildStreamPreview('', ''))
594
+ } catch (e) {
595
+ this.ctx.logger?.warn?.(`stream start: ${e.message}`)
596
+ stream = null
597
+ }
598
+ } else if (progressEnabled && typeof startProgress === 'function') {
599
+ try {
600
+ progress = await startProgress()
601
+ const editProgress = createEditScheduler((text) => progress.edit(text), 800)
602
+ collector.onStream = (_text, toolName) => {
603
+ editProgress.push(formatProgressLine(toolName))
604
+ }
605
+ } catch (e) {
606
+ this.ctx.logger?.warn?.(`progress start: ${e.message}`)
607
+ progress = null
608
+ if (typing) await typing()
609
+ }
610
+ } else if (typing) {
611
+ await typing()
612
+ }
613
+
263
614
  const content = await this.buildUserContent(input, signal)
264
615
  chat.agent.followup(createUserMessage({
265
616
  content,
266
617
  source: { kind: 'plugin', plugin: PLUGIN, form: 'relay' },
267
618
  }))
268
- await chat.agent.whenIdle()
269
- if (signal.aborted) return
619
+ const turnTimeoutMs = Number(this.config.agent?.turnTimeoutMs) || 600_000
620
+ await whenIdleWithTimeout(chat.agent, turnTimeoutMs, signal)
621
+ if (signal.aborted) {
622
+ if (progress) try { await progress.remove() } catch {}
623
+ if (typeof react === 'function') react('').catch?.(() => {})
624
+ if (stream) try { await stream.finalize('Прервано.') } catch {}
625
+ else return reply('Прервано.')
626
+ return
627
+ }
270
628
  await this.ctx.sessions.flush(chat.agent.session)
271
- const answer = stripImageUrls(collector.parts.join('\n\n'))
629
+ if (progress) try { await progress.remove() } catch {}
630
+ progress = null
631
+ if (typeof react === 'function') react('').catch?.(() => {})
632
+ const answer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
272
633
  if (collector.reason?.kind === 'error') {
273
634
  const err = collector.reason.error
274
- return reply(`Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`)
635
+ const msg = `Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`
636
+ if (stream) { await scheduler?.flush(); await stream.finalize(msg) }
637
+ else await reply(msg)
638
+ return
275
639
  }
276
640
  const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
277
- if (!answer && !files.length) return reply('(нет ответа)')
641
+ if (!answer && !files.length) {
642
+ if (stream) { await scheduler?.flush(); await stream.finalize('(нет ответа)') }
643
+ else await reply('(нет ответа)')
644
+ return
645
+ }
278
646
  const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
279
647
  const chunks = answer ? splitText(answer, maxLen) : ['']
280
- for (let i = 0; i < chunks.length; i++) {
281
- await reply({ text: chunks[i] || undefined, files: i === 0 ? files : [] })
648
+ if (stream) {
649
+ await scheduler?.flush()
650
+ await stream.finalize(chunks[0] || '(нет ответа)')
651
+ for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
652
+ if (files.length) await reply({ files })
653
+ } else {
654
+ for (let i = 0; i < chunks.length; i++) {
655
+ await reply({ text: chunks[i] || undefined, files: i === 0 ? files : [] })
656
+ }
282
657
  }
283
- if (this.config.tts?.enabled && !signal.aborted) {
658
+ const speak = shouldSpeakReply({
659
+ globalTts: Boolean(this.config.tts?.enabled),
660
+ voiceMode: this.tg().voiceMode || 'mirror',
661
+ inboundWasVoice: Boolean(inboundWasVoice),
662
+ userPref: this.voicePrefs.get(userId),
663
+ })
664
+ if (speak && !signal.aborted) {
284
665
  const ttsText = prepareTtsText(answer, this.config.tts?.maxChars)
285
666
  if (ttsText) {
286
667
  try {
287
668
  const spoken = await speakText(this.baseUrl(), ttsText, signal)
288
- if (!signal.aborted) await reply({ files: [voiceReplyFile(spoken)] })
669
+ const voiceFile = await toTelegramVoiceFile(spoken, { logger: this.ctx.logger })
670
+ if (!signal.aborted && voiceFile) await reply({ files: [voiceFile] })
289
671
  } catch (e) {
290
672
  if (!signal?.aborted) this.ctx.logger?.warn?.(`tts: ${e.message}`)
291
673
  }
@@ -293,9 +675,17 @@ export class Gateway {
293
675
  }
294
676
  } catch (err) {
295
677
  if (!signal?.aborted) {
296
- try { await reply(`Сбой: ${err.message}`) } catch {}
678
+ try {
679
+ if (stream) await stream.finalize(`Сбой: ${err.message}`)
680
+ else await reply(`Сбой: ${err.message}`)
681
+ } catch {}
297
682
  }
298
683
  } finally {
684
+ stopTyping()
685
+ if (progress) try { await progress.remove() } catch {}
686
+ if (typeof react === 'function') react('').catch?.(() => {})
687
+ chat.turnActive = false
688
+ chat.abort = undefined
299
689
  this.pending.delete(sessionId)
300
690
  }
301
691
  }
@@ -305,14 +695,45 @@ export class Gateway {
305
695
  if (timeout <= 0) return
306
696
  const now = Date.now()
307
697
  for (const [key, chat] of this.chats) {
308
- if (now - chat.lastUsed > timeout) { this.chats.delete(key); chat.dispose().catch(() => {}) }
698
+ if (now - chat.lastUsed > timeout) {
699
+ this.sessionToChat.delete(String(chat.agent.session.id))
700
+ this.chats.delete(key)
701
+ chat.dispose().catch(() => {})
702
+ }
703
+ }
704
+ }
705
+
706
+ async approvePairingCode(code, actorUserId = 0) {
707
+ const res = this.pairing.approveCode(code, actorUserId)
708
+ if (!res.ok) return res
709
+ const merged = this.effectiveAllowedIds()
710
+ for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
711
+ try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
712
+ this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
309
713
  }
714
+ return { ...res, allowedUserIds: merged }
715
+ }
716
+
717
+ rejectPairingCode(code) {
718
+ return this.pairing.rejectCode(code)
719
+ }
720
+
721
+ async messengerAskFromAgent(agent, payload, timeoutMs) {
722
+ const sessionId = String(agent?.session?.id || '')
723
+ const key = this.sessionToChat.get(sessionId)
724
+ const chat = key ? this.chats.get(key) : null
725
+ if (!chat?.target) throw new Error('messenger_ask: no telegram chat for this agent session')
726
+ return this.messengerAsk(chat.target, payload, timeoutMs)
310
727
  }
311
728
 
312
729
  get messenger() {
313
730
  return {
314
731
  adapters: () => [...this.adapters.keys()],
315
732
  activeChats: () => this.chats.size,
733
+ home: (name) => this.resolveHomeTarget('telegram', name),
734
+ homes: () => listHomes(this.tg()),
735
+ pairingPending: () => this.pairing.listPending(),
736
+ pairingApproved: () => this.pairing.listApproved(),
316
737
  send: (target, payload) => this.messengerSend(target, payload),
317
738
  ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
318
739
  progress: (target, payload) => this.messengerProgress(target, payload),