@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/README.md +68 -19
- package/lib/adapters/index.js +15 -2
- package/lib/adapters/telegram.js +375 -52
- package/lib/client.js +480 -46
- package/lib/commands.js +30 -0
- package/lib/config.js +78 -0
- package/lib/documents.js +19 -5
- package/lib/gateway.js +496 -56
- 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-errors.js +41 -0
- package/lib/telegram-format.js +141 -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,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 {
|
|
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.
|
|
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
|
-
|
|
102
|
-
if (!
|
|
103
|
-
|
|
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
|
-
|
|
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,
|
|
154
|
-
const key =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
const
|
|
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')
|
|
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) {
|
|
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?.
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
281
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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) {
|
|
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),
|