@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/README.md +68 -19
- package/lib/adapters/index.js +12 -2
- package/lib/adapters/telegram.js +324 -47
- package/lib/client.js +471 -46
- package/lib/commands.js +30 -0
- package/lib/config.js +75 -0
- package/lib/documents.js +19 -5
- package/lib/gateway.js +476 -55
- package/lib/groups.js +72 -0
- package/lib/homes.js +58 -0
- package/lib/index.js +261 -39
- package/lib/messenger-api.js +5 -5
- package/lib/pairing.js +110 -0
- package/lib/stream.js +64 -0
- package/lib/telegram-format.js +39 -0
- package/lib/text.js +18 -1
- package/lib/topics.js +23 -4
- package/lib/tts.js +82 -2
- package/lib/voice-prefs.js +44 -0
- package/package.json +15 -9
- package/docs/architecture/2026-08-23-messenger-gateway-design.md +0 -30
- package/docs/deployment/0.1.0-install.md +0 -28
- package/docs/testing/0.1.0-smoke.md +0 -12
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 {
|
|
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.
|
|
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
|
-
|
|
102
|
-
if (!
|
|
103
|
-
|
|
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,
|
|
154
|
-
const key =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
const
|
|
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')
|
|
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) {
|
|
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?.
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
281
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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) {
|
|
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),
|