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