@goodandready/dsh-messenger-gateway 0.2.0 → 0.3.1
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/adapters/index.js +3 -0
- package/lib/adapters/telegram.js +53 -7
- package/lib/client.js +9 -0
- package/lib/commands.js +3 -0
- package/lib/config.js +3 -0
- package/lib/gateway.js +64 -3
- package/lib/index.js +2 -0
- package/lib/telegram-errors.js +41 -0
- package/lib/telegram-format.js +102 -0
- package/lib/voice-prefs.js +3 -1
- package/package.json +8 -8
package/lib/adapters/index.js
CHANGED
|
@@ -15,6 +15,9 @@ export default function createAdapters(deps) {
|
|
|
15
15
|
groupsEnabled: tg.groupsEnabled,
|
|
16
16
|
groupRequireMention: tg.groupRequireMention,
|
|
17
17
|
reactionsEnabled: tg.reactionsEnabled,
|
|
18
|
+
statusIndicator: tg.statusIndicator,
|
|
19
|
+
statusOnline: tg.statusOnline,
|
|
20
|
+
statusOffline: tg.statusOffline,
|
|
18
21
|
transport: tg.transport,
|
|
19
22
|
webhookUrl: tg.webhookUrl,
|
|
20
23
|
webhookSecret: tg.webhookSecret,
|
package/lib/adapters/telegram.js
CHANGED
|
@@ -11,6 +11,7 @@ import { normalizeThreadId, telegramThreadParams } from '../topics.js'
|
|
|
11
11
|
import {
|
|
12
12
|
shouldProcessTelegramMessage, stripBotCommandSuffix,
|
|
13
13
|
} from '../groups.js'
|
|
14
|
+
import { isResendSafeNetworkError, isPollingConflict } from '../telegram-errors.js'
|
|
14
15
|
|
|
15
16
|
const API = 'https://api.telegram.org'
|
|
16
17
|
const TELEGRAM_MAX = 4096
|
|
@@ -34,6 +35,12 @@ export class TelegramAdapter {
|
|
|
34
35
|
this.groupRequireMention = opts.groupRequireMention !== false
|
|
35
36
|
this.reactionsEnabled = opts.reactionsEnabled !== false
|
|
36
37
|
this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
|
|
38
|
+
this.statusIndicator = opts.statusIndicator === true
|
|
39
|
+
this.statusOnline = String(opts.statusOnline || 'Online')
|
|
40
|
+
this.statusOffline = String(opts.statusOffline || 'Offline')
|
|
41
|
+
this.sendRetryMax = 2
|
|
42
|
+
this.sendRetryBaseMs = 400
|
|
43
|
+
this.pollingConflict = false
|
|
37
44
|
this.webhookUrl = String(opts.webhookUrl || '').trim()
|
|
38
45
|
this.webhookSecret = String(opts.webhookSecret || '').trim()
|
|
39
46
|
this.offset = 0
|
|
@@ -63,6 +70,24 @@ export class TelegramAdapter {
|
|
|
63
70
|
return json.result
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
// Retry a send only on resend-safe network errors (request never reached Telegram).
|
|
74
|
+
// Permanent errors (4xx/5xx) and ambiguous timeouts are not retried to avoid duplicates.
|
|
75
|
+
async sendWithRetry(method, params, { multipart = false } = {}) {
|
|
76
|
+
const fn = () => (multipart ? this.callMultipart(method, params) : this.call(method, params))
|
|
77
|
+
let lastErr
|
|
78
|
+
for (let attempt = 0; attempt <= this.sendRetryMax; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
return await fn()
|
|
81
|
+
} catch (err) {
|
|
82
|
+
lastErr = err
|
|
83
|
+
if (!isResendSafeNetworkError(err) || attempt >= this.sendRetryMax) throw err
|
|
84
|
+
this.logger?.warn?.(`telegram ${method} resend-safe network error (attempt ${attempt + 1}/${this.sendRetryMax}), retrying: ${err.message}`)
|
|
85
|
+
await new Promise((r) => setTimeout(r, this.sendRetryBaseMs * (attempt + 1)))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastErr
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
|
|
67
92
|
|
|
68
93
|
async downloadFile(filePath) {
|
|
@@ -76,6 +101,17 @@ export class TelegramAdapter {
|
|
|
76
101
|
await this.call('setMyCommands', { commands: this.commands })
|
|
77
102
|
}
|
|
78
103
|
|
|
104
|
+
// Bots have no presence dot; the short description is the closest surface.
|
|
105
|
+
// Opt-in only — it mutates the bot's global profile visible to all users.
|
|
106
|
+
async setStatusIndicator(text) {
|
|
107
|
+
if (!this.statusIndicator) return
|
|
108
|
+
try {
|
|
109
|
+
await this.call('setMyShortDescription', { short_description: String(text || '').slice(0, 120) })
|
|
110
|
+
} catch (err) {
|
|
111
|
+
this.logger?.warn?.(`telegram setMyShortDescription: ${err.message}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
79
115
|
async start() {
|
|
80
116
|
if (!this.token) throw new Error('telegram bot token is empty')
|
|
81
117
|
this.stopped = false
|
|
@@ -87,6 +123,7 @@ export class TelegramAdapter {
|
|
|
87
123
|
} catch (err) {
|
|
88
124
|
this.logger?.warn?.(`dsh-messenger-gateway: telegram getMe: ${err.message}`)
|
|
89
125
|
}
|
|
126
|
+
if (this.statusIndicator) await this.setStatusIndicator(this.statusOnline)
|
|
90
127
|
try {
|
|
91
128
|
await this.registerCommands()
|
|
92
129
|
this.logger?.info?.(`dsh-messenger-gateway: telegram commands registered (${this.commands.length})`)
|
|
@@ -115,6 +152,7 @@ export class TelegramAdapter {
|
|
|
115
152
|
stop() {
|
|
116
153
|
this.stopped = true
|
|
117
154
|
if (this.pollTimer) clearTimeout(this.pollTimer)
|
|
155
|
+
if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch(() => {})
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
schedulePoll() {
|
|
@@ -131,12 +169,20 @@ export class TelegramAdapter {
|
|
|
131
169
|
offset: this.offset,
|
|
132
170
|
allowed_updates: ['message', 'callback_query'],
|
|
133
171
|
})
|
|
172
|
+
this.pollingConflict = false
|
|
134
173
|
for (const update of updates || []) {
|
|
135
174
|
this.offset = Math.max(this.offset, update.update_id + 1)
|
|
136
175
|
await this.dispatchUpdate(update)
|
|
137
176
|
}
|
|
138
177
|
} catch (e) {
|
|
139
|
-
if (!this.stopped)
|
|
178
|
+
if (!this.stopped) {
|
|
179
|
+
if (isPollingConflict(e)) {
|
|
180
|
+
this.pollingConflict = true
|
|
181
|
+
this.logger?.error?.(`poll: TELEGRAM CONFLICT — another bot instance is polling the same token. Stop the duplicate instance. (${e.message})`)
|
|
182
|
+
} else {
|
|
183
|
+
this.logger?.warn?.(`poll: ${e.message}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
140
186
|
}
|
|
141
187
|
this.schedulePoll()
|
|
142
188
|
}
|
|
@@ -422,12 +468,12 @@ export class TelegramAdapter {
|
|
|
422
468
|
if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
|
|
423
469
|
const blob = new Blob([file.bytes])
|
|
424
470
|
const name = safeName(file.name || 'file')
|
|
425
|
-
if (file.kind === 'photo') { form.append('photo', blob, name); return this.
|
|
426
|
-
if (file.kind === 'voice') { form.append('voice', blob, name); return this.
|
|
427
|
-
if (file.kind === 'audio') { form.append('audio', blob, name); return this.
|
|
428
|
-
if (file.kind === 'video') { form.append('video', blob, name); return this.
|
|
471
|
+
if (file.kind === 'photo') { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
|
|
472
|
+
if (file.kind === 'voice') { form.append('voice', blob, name); return this.sendWithRetry('sendVoice', form, { multipart: true }) }
|
|
473
|
+
if (file.kind === 'audio') { form.append('audio', blob, name); return this.sendWithRetry('sendAudio', form, { multipart: true }) }
|
|
474
|
+
if (file.kind === 'video') { form.append('video', blob, name); return this.sendWithRetry('sendVideo', form, { multipart: true }) }
|
|
429
475
|
form.append('document', blob, name)
|
|
430
|
-
return this.
|
|
476
|
+
return this.sendWithRetry('sendDocument', form, { multipart: true })
|
|
431
477
|
}
|
|
432
478
|
|
|
433
479
|
formatOutgoingText(text, payload = {}) {
|
|
@@ -451,7 +497,7 @@ export class TelegramAdapter {
|
|
|
451
497
|
}
|
|
452
498
|
if (parseMode) params.parse_mode = parseMode
|
|
453
499
|
try {
|
|
454
|
-
await this.
|
|
500
|
+
await this.sendWithRetry('sendMessage', params)
|
|
455
501
|
} catch (err) {
|
|
456
502
|
if (!parseMode) throw err
|
|
457
503
|
this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
|
package/lib/client.js
CHANGED
|
@@ -98,6 +98,8 @@ const css =
|
|
|
98
98
|
groupMention: 'Groups require @mention / reply / command',
|
|
99
99
|
reactionsEnable: 'React while processing (👀)',
|
|
100
100
|
progressEnable: 'Progress message (thinking → delete → final)',
|
|
101
|
+
statusIndicator: 'Status indicator (Online/Offline)',
|
|
102
|
+
statusIndicatorHint: 'Sets the bot short description; bots have no presence dot. Opt-in, visible to all users.',
|
|
101
103
|
transport: 'Transport',
|
|
102
104
|
transportPoll: 'Long poll (getUpdates)',
|
|
103
105
|
transportWebhook: 'Webhook',
|
|
@@ -167,6 +169,8 @@ const css =
|
|
|
167
169
|
groupMention: 'В группах только @mention / reply / команда',
|
|
168
170
|
reactionsEnable: 'Реакция 👀 пока думает',
|
|
169
171
|
progressEnable: 'Прогресс: думаю → удалить → финальный ответ',
|
|
172
|
+
statusIndicator: 'Индикатор статуса (Online/Offline)',
|
|
173
|
+
statusIndicatorHint: 'Меняет short description бота (у ботов нет точки присутствия). Опцionalно, видно всем.',
|
|
170
174
|
transport: 'Транспорт',
|
|
171
175
|
transportPoll: 'Long poll (getUpdates)',
|
|
172
176
|
transportWebhook: 'Webhook',
|
|
@@ -410,6 +414,11 @@ const css =
|
|
|
410
414
|
React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.progressEnabled !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, progressEnabled: e.target.checked } }) }),
|
|
411
415
|
t('progressEnable'),
|
|
412
416
|
),
|
|
417
|
+
React.createElement('label', { className: 'msgw-check' },
|
|
418
|
+
React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.statusIndicator === true, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, statusIndicator: e.target.checked } }) }),
|
|
419
|
+
t('statusIndicator'),
|
|
420
|
+
),
|
|
421
|
+
React.createElement('div', { className: 'msgw-hint' }, t('statusIndicatorHint')),
|
|
413
422
|
React.createElement(Field, { label: t('voiceMode') },
|
|
414
423
|
React.createElement('select', { className: 'msgw-select', value: cfg.telegram?.voiceMode || 'mirror', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, voiceMode: e.target.value } }) },
|
|
415
424
|
React.createElement('option', { value: 'mirror' }, t('voiceModeMirror')),
|
package/lib/commands.js
CHANGED
|
@@ -10,6 +10,9 @@ export const DEFAULT_TELEGRAM_COMMANDS = [
|
|
|
10
10
|
{ command: 'model', description: 'Показать или сменить модель' },
|
|
11
11
|
{ command: 'status', description: 'Статус шлюза' },
|
|
12
12
|
{ command: 'voice', description: 'Голосовые ответы: /voice on|off|status' },
|
|
13
|
+
{ command: 'tts', description: 'Озвучка в этом чате: /tts on|off|status' },
|
|
14
|
+
{ command: 'mute', description: 'Не присылать уведомления в этот чат' },
|
|
15
|
+
{ command: 'unmute', description: 'Вернуть уведомления в этот чат' },
|
|
13
16
|
]
|
|
14
17
|
|
|
15
18
|
export function normalizeTelegramCommands(commands) {
|
package/lib/config.js
CHANGED
|
@@ -31,6 +31,9 @@ export const PluginConfig = z.object({
|
|
|
31
31
|
groupsEnabled: z.boolean().default(true).description('Process group/supergroup messages'),
|
|
32
32
|
groupRequireMention: z.boolean().default(true).description('In groups, only respond to @mention, reply-to-bot, or /commands'),
|
|
33
33
|
reactionsEnabled: z.boolean().default(true).description('React with 👀 while processing a turn'),
|
|
34
|
+
statusIndicator: z.boolean().default(false).description('Opt-in: set bot short description to Online/Offline (bots have no presence)'),
|
|
35
|
+
statusOnline: z.string().default('Online'),
|
|
36
|
+
statusOffline: z.string().default('Offline'),
|
|
34
37
|
transport: z.union([z.const('poll'), z.const('webhook')]).default('poll'),
|
|
35
38
|
webhookUrl: z.string().default(''),
|
|
36
39
|
webhookSecret: z.string().role('secret').default(''),
|
package/lib/gateway.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
|
|
25
25
|
createEditScheduler, startTypingHeartbeat,
|
|
26
26
|
} from './stream.js'
|
|
27
|
+
import { isTopicGoneError } from './telegram-errors.js'
|
|
27
28
|
|
|
28
29
|
function whenIdleWithTimeout(agent, timeoutMs, signal) {
|
|
29
30
|
const idle = agent.whenIdle()
|
|
@@ -61,8 +62,14 @@ export class Gateway {
|
|
|
61
62
|
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
62
63
|
this.pairing = createPairingStore(join(home, 'messenger-gateway', 'pairing.json'))
|
|
63
64
|
this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'))
|
|
65
|
+
this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'))
|
|
66
|
+
this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'))
|
|
67
|
+
this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
isMuted(chatId) { return this.muted.get(chatId) === true }
|
|
71
|
+
setMuted(chatId, on) { return this.muted.set(chatId, on) }
|
|
72
|
+
|
|
66
73
|
baseUrl() {
|
|
67
74
|
const raw = String(this.config.internalBaseURL || '').trim()
|
|
68
75
|
return raw || 'http://127.0.0.1:3080'
|
|
@@ -129,6 +136,7 @@ export class Gateway {
|
|
|
129
136
|
await adapter.start()
|
|
130
137
|
this.adapterList.push(adapter)
|
|
131
138
|
this.adapters.set(adapter.name, adapter)
|
|
139
|
+
if (adapter.name === 'telegram') this.tgAdapter = adapter
|
|
132
140
|
this.ctx.logger?.info?.(`dsh-messenger-gateway: ${adapter.name} started`)
|
|
133
141
|
} catch (err) {
|
|
134
142
|
this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
|
|
@@ -170,7 +178,17 @@ export class Gateway {
|
|
|
170
178
|
}
|
|
171
179
|
const adapter = this.getAdapter(resolved.platform)
|
|
172
180
|
if (!adapter?.sendTo) throw new Error(`adapter ${resolved.platform} unavailable`)
|
|
173
|
-
|
|
181
|
+
try {
|
|
182
|
+
await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
|
|
183
|
+
this.stats.sent++
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.stats.errors++
|
|
186
|
+
if (isTopicGoneError(err)) {
|
|
187
|
+
this.logger?.warn?.(`messengerSend: topic gone for ${resolved.platform}:${resolved.chatId}:${resolved.threadId} — the chat/topic was deleted; skipping delivery`)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
throw err
|
|
191
|
+
}
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
async messengerAsk(target, payload, timeoutMs = 300_000) {
|
|
@@ -179,7 +197,17 @@ export class Gateway {
|
|
|
179
197
|
const token = makeAskToken()
|
|
180
198
|
const { replyMarkup, callbackKeys } = buildInlineKeyboard(token, payload.buttons || [])
|
|
181
199
|
indexCallbacks(this.callbackIndex, callbackKeys, token)
|
|
182
|
-
|
|
200
|
+
try {
|
|
201
|
+
await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// Never leave stale callback keys pointing at an unresolvable ask.
|
|
204
|
+
releaseCallbacks(this.callbackIndex, callbackKeys)
|
|
205
|
+
if (isTopicGoneError(err)) {
|
|
206
|
+
this.logger?.warn?.(`messengerAsk: topic gone for ${target.platform}:${target.chatId}:${target.threadId} — ask aborted (chat/topic deleted)`)
|
|
207
|
+
throw new Error('messenger.ask: chat or topic was deleted')
|
|
208
|
+
}
|
|
209
|
+
throw err
|
|
210
|
+
}
|
|
183
211
|
return new Promise((resolve, reject) => {
|
|
184
212
|
const timer = setTimeout(() => {
|
|
185
213
|
this.pendingAsks.delete(token)
|
|
@@ -343,7 +371,12 @@ export class Gateway {
|
|
|
343
371
|
}
|
|
344
372
|
return reply('Активной сессии нет.')
|
|
345
373
|
}
|
|
346
|
-
if (cmd === '/whoami')
|
|
374
|
+
if (cmd === '/whoami') {
|
|
375
|
+
const lines = [`Ваш id: ${userId}`]
|
|
376
|
+
if (chatId) lines.push(`chatId: ${chatId}`)
|
|
377
|
+
if (threadId) lines.push(`threadId: ${threadId}`)
|
|
378
|
+
return reply(lines.join('\n'))
|
|
379
|
+
}
|
|
347
380
|
if (cmd === '/stop') {
|
|
348
381
|
const chat = this.chats.get(key)
|
|
349
382
|
if (chat?.turnActive || chat?.abort) {
|
|
@@ -367,6 +400,10 @@ export class Gateway {
|
|
|
367
400
|
? `home: chat ${home.chatId}${home.threadId ? ` topic ${home.threadId}` : ''}`
|
|
368
401
|
: 'home: не задан'
|
|
369
402
|
const pending = this.pairing.listPending().length
|
|
403
|
+
const up = Math.max(0, Math.round((Date.now() - this.stats.startedAt) / 1000))
|
|
404
|
+
const hh = String(Math.floor(up / 3600)).padStart(2, '0')
|
|
405
|
+
const mm = String(Math.floor((up % 3600) / 60)).padStart(2, '0')
|
|
406
|
+
const ss = String(up % 60).padStart(2, '0')
|
|
370
407
|
return reply([
|
|
371
408
|
'Messenger gateway',
|
|
372
409
|
`адаптеры: ${[...this.adapters.keys()].join(', ') || '(нет)'}`,
|
|
@@ -376,6 +413,10 @@ export class Gateway {
|
|
|
376
413
|
`pairing pending: ${pending}`,
|
|
377
414
|
`transport: ${this.tg().transport || 'poll'}`,
|
|
378
415
|
`sessionScope: ${this.config.agent?.sessionScope || 'user'}`,
|
|
416
|
+
`доставлено: ${this.stats.sent}`,
|
|
417
|
+
`ошибок: ${this.stats.errors}`,
|
|
418
|
+
`polling conflict: ${this.tgAdapter?.pollingConflict ? 'да' : 'нет'}`,
|
|
419
|
+
`uptime: ${hh}:${mm}:${ss}`,
|
|
379
420
|
].join('\n'))
|
|
380
421
|
}
|
|
381
422
|
if (cmd === '/model') {
|
|
@@ -447,6 +488,24 @@ export class Gateway {
|
|
|
447
488
|
const prefLine = pref === null ? 'не задан (/voice on|off)' : (pref ? 'on' : 'off')
|
|
448
489
|
return reply(`voiceMode=${mode}\nваш /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
|
|
449
490
|
}
|
|
491
|
+
if (cmd === '/tts') {
|
|
492
|
+
const sub = String(parts[1] || 'status').toLowerCase()
|
|
493
|
+
if (sub === 'on' || sub === 'off') {
|
|
494
|
+
this.chatTts.set(chatId, sub === 'on')
|
|
495
|
+
return reply(sub === 'on' ? 'Озвучка в этом чате: on' : 'Озвучка в этом чате: off')
|
|
496
|
+
}
|
|
497
|
+
const cur = this.chatTts.get(chatId)
|
|
498
|
+
const line = cur === null ? 'не задан (/tts on|off)' : (cur ? 'on' : 'off')
|
|
499
|
+
return reply(`Озвучка в этом чате: ${line}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
|
|
500
|
+
}
|
|
501
|
+
if (cmd === '/mute') {
|
|
502
|
+
this.setMuted(chatId, true)
|
|
503
|
+
return reply('Уведомления в этот чат: выключены (/unmute)')
|
|
504
|
+
}
|
|
505
|
+
if (cmd === '/unmute') {
|
|
506
|
+
this.setMuted(chatId, false)
|
|
507
|
+
return reply('Уведомления в этот чат: включены')
|
|
508
|
+
}
|
|
450
509
|
return reply(`Неизвестная команда ${cmd}. /help`)
|
|
451
510
|
}
|
|
452
511
|
|
|
@@ -655,11 +714,13 @@ export class Gateway {
|
|
|
655
714
|
await reply({ text: chunks[i] || undefined, files: i === 0 ? files : [] })
|
|
656
715
|
}
|
|
657
716
|
}
|
|
717
|
+
const chatTtsPref = this.chatTts.get(chat.target?.chatId)
|
|
658
718
|
const speak = shouldSpeakReply({
|
|
659
719
|
globalTts: Boolean(this.config.tts?.enabled),
|
|
660
720
|
voiceMode: this.tg().voiceMode || 'mirror',
|
|
661
721
|
inboundWasVoice: Boolean(inboundWasVoice),
|
|
662
722
|
userPref: this.voicePrefs.get(userId),
|
|
723
|
+
chatPref: chatTtsPref,
|
|
663
724
|
})
|
|
664
725
|
if (speak && !signal.aborted) {
|
|
665
726
|
const ttsText = prepareTtsText(answer, this.config.tts?.maxChars)
|
package/lib/index.js
CHANGED
|
@@ -244,6 +244,8 @@ export function apply(ctx, config) {
|
|
|
244
244
|
lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
|
|
245
245
|
}
|
|
246
246
|
const homeName = cfg.home || 'default'
|
|
247
|
+
const home = gw.messenger.home?.() || null
|
|
248
|
+
if (home && gw.isMuted?.(home.chatId)) return
|
|
247
249
|
gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
|
|
248
250
|
.catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
|
|
249
251
|
}), 'dsh-messenger-gateway: notify bridge')
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Network error classification for Telegram sends (mirrors Hermes adapter policy).
|
|
2
|
+
//
|
|
3
|
+
// The key distinction: some network failures mean the request NEVER left the
|
|
4
|
+
// process (connect/pool timeout, ECONNRESET before send) → resending is safe.
|
|
5
|
+
// Others (a generic timeout after the request may have reached Telegram) could
|
|
6
|
+
// duplicate a message if we resend → do NOT retry.
|
|
7
|
+
|
|
8
|
+
function rootCause(err) {
|
|
9
|
+
let e = err
|
|
10
|
+
let depth = 0
|
|
11
|
+
while (e && e.cause && e.cause !== e && depth < 10) {
|
|
12
|
+
e = e.cause
|
|
13
|
+
depth++
|
|
14
|
+
}
|
|
15
|
+
return e || err
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// True when a resend cannot duplicate a message: the request did not reach Telegram.
|
|
19
|
+
export function isResendSafeNetworkError(err) {
|
|
20
|
+
const c = rootCause(err)
|
|
21
|
+
const msg = String(c?.message || err?.message || '').toLowerCase()
|
|
22
|
+
if (/not sent to telegram|connect timeout|und_err_connect|econnreset|enotfound|econnrefused|ECONNRESET|ENOTFOUND|ECONNREFUSED/i.test(msg)) {
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
// undici PoolTimeout message: "Request was *not* sent to Telegram."
|
|
26
|
+
if (/pool timeout|request was \*?not\*? sent/i.test(msg)) return true
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// True for a 409 from getUpdates: a second bot instance polls the same token.
|
|
31
|
+
export function isPollingConflict(err) {
|
|
32
|
+
const msg = String(err?.message || '').toLowerCase()
|
|
33
|
+
return /terminated by other getupdates request|another bot instance is running|getupdates.*conflict|conflict.*getupdates/i.test(msg)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True when the target chat/topic no longer exists (deleted/closed/upgraded).
|
|
37
|
+
// Such sends should not be retried and any pending ask binding must be pruned.
|
|
38
|
+
export function isTopicGoneError(err) {
|
|
39
|
+
const msg = String(err?.message || '').toLowerCase()
|
|
40
|
+
return /thread not found|message thread not found|topic[_ ]?(not found|closed|deleted)|chat (not found|was (upgraded|deleted))|group chat was (upgraded|deleted)/i.test(msg)
|
|
41
|
+
}
|
package/lib/telegram-format.js
CHANGED
|
@@ -7,6 +7,105 @@ export function escapeHtml(text) {
|
|
|
7
7
|
|
|
8
8
|
const PH = '\uE000'
|
|
9
9
|
|
|
10
|
+
// Render inline markdown (bold/italic/code/links) inside already-HTML-escaped text.
|
|
11
|
+
function renderInline(t) {
|
|
12
|
+
let s = escapeHtml(t)
|
|
13
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
|
14
|
+
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
15
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${escapeHtml(url)}">${label}</a>`)
|
|
16
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<i>$2</i>')
|
|
17
|
+
return s
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function splitRow(line) {
|
|
21
|
+
let s = line.trim()
|
|
22
|
+
if (s.startsWith('|')) s = s.slice(1)
|
|
23
|
+
if (s.endsWith('|')) s = s.slice(0, -1)
|
|
24
|
+
return s.split('|').map((c) => c.trim())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTableDelimiter(line) {
|
|
28
|
+
const s = line.trim()
|
|
29
|
+
if (!s.startsWith('|') || !s.endsWith('|')) return false
|
|
30
|
+
const cells = s.split('|').slice(1, -1)
|
|
31
|
+
return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c.trim()))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// GFM pipe tables → bold heading + bullet rows (Telegram HTML has no table syntax).
|
|
35
|
+
function convertTables(text, stash) {
|
|
36
|
+
const lines = text.split('\n')
|
|
37
|
+
const out = []
|
|
38
|
+
let i = 0
|
|
39
|
+
while (i < lines.length) {
|
|
40
|
+
const line = lines[i]
|
|
41
|
+
if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) {
|
|
42
|
+
const headers = splitRow(line)
|
|
43
|
+
const rows = []
|
|
44
|
+
let j = i + 2
|
|
45
|
+
while (j < lines.length && lines[j].includes('|') && !isTableDelimiter(lines[j])) {
|
|
46
|
+
rows.push(splitRow(lines[j]))
|
|
47
|
+
j++
|
|
48
|
+
}
|
|
49
|
+
let blocks
|
|
50
|
+
if (headers.length === 2) {
|
|
51
|
+
// key/value table → bullet list, no separate heading row
|
|
52
|
+
blocks = rows.map((row) => `• <b>${renderInline(row[0] || '')}</b>: ${renderInline(row[1] || '')}`)
|
|
53
|
+
} else {
|
|
54
|
+
blocks = rows.map((row) => {
|
|
55
|
+
const heading = renderInline(row[0] || '')
|
|
56
|
+
const bullets = headers
|
|
57
|
+
.slice(1)
|
|
58
|
+
.map((h, idx) => {
|
|
59
|
+
const val = (row[idx + 1] || '').trim()
|
|
60
|
+
if (!val) return null
|
|
61
|
+
return `• ${renderInline(h)}: ${renderInline(val)}`
|
|
62
|
+
})
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
return bullets.length ? `<b>${heading}</b>\n${bullets.join('\n')}` : `<b>${heading}</b>`
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
out.push(stash(blocks.join('\n\n')))
|
|
68
|
+
i = j
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
out.push(line)
|
|
72
|
+
i++
|
|
73
|
+
}
|
|
74
|
+
return out.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// GFM task lists → checkbox glyphs.
|
|
78
|
+
function convertTaskLists(text, stash) {
|
|
79
|
+
const lines = text.split('\n')
|
|
80
|
+
const out = []
|
|
81
|
+
let buf = []
|
|
82
|
+
const flush = () => {
|
|
83
|
+
if (buf.length) {
|
|
84
|
+
out.push(stash(buf.join('\n')))
|
|
85
|
+
buf = []
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const m = line.match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.*)$/)
|
|
90
|
+
if (m) {
|
|
91
|
+
buf.push(`${m[1]}${m[2].toLowerCase() === 'x' ? '☑' : '☐'} ${renderInline(m[3])}`)
|
|
92
|
+
} else {
|
|
93
|
+
flush()
|
|
94
|
+
out.push(line)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
flush()
|
|
98
|
+
return out.join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// <details><summary>..</summary>..</details> → bold summary + body (no native collapse in HTML).
|
|
102
|
+
function convertDetails(text, stash) {
|
|
103
|
+
return text.replace(
|
|
104
|
+
/<details\b[^>]*>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi,
|
|
105
|
+
(_, summary, body) => stash(`<b>${renderInline(summary.trim())}</b>\n${renderInline(body.trim())}`),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
10
109
|
/** Convert common Markdown from LLM replies to Telegram HTML parse_mode. */
|
|
11
110
|
export function markdownToTelegramHtml(markdown) {
|
|
12
111
|
let text = String(markdown ?? '')
|
|
@@ -20,6 +119,9 @@ export function markdownToTelegramHtml(markdown) {
|
|
|
20
119
|
|
|
21
120
|
text = text.replace(/```([\s\S]*?)```/g, (_, code) => stash(`<pre><code>${escapeHtml(code.replace(/^\n/, '').replace(/\n$/, ''))}</code></pre>`))
|
|
22
121
|
text = text.replace(/`([^`\n]+)`/g, (_, code) => stash(`<code>${escapeHtml(code)}</code>`))
|
|
122
|
+
text = convertTables(text, stash)
|
|
123
|
+
text = convertDetails(text, stash)
|
|
124
|
+
text = convertTaskLists(text, stash)
|
|
23
125
|
text = escapeHtml(text)
|
|
24
126
|
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${escapeHtml(url)}">${label}</a>`)
|
|
25
127
|
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
package/lib/voice-prefs.js
CHANGED
|
@@ -33,7 +33,9 @@ export function createVoicePrefs(filePath) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Decide whether to speak the reply. */
|
|
36
|
-
export function shouldSpeakReply({ globalTts, voiceMode, inboundWasVoice, userPref }) {
|
|
36
|
+
export function shouldSpeakReply({ globalTts, voiceMode, inboundWasVoice, userPref, chatPref }) {
|
|
37
|
+
if (chatPref === false) return false
|
|
38
|
+
if (chatPref === true) return true
|
|
37
39
|
if (globalTts) return true
|
|
38
40
|
if (userPref === true) return true
|
|
39
41
|
if (userPref === false) return false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -52,14 +52,14 @@
|
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
55
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
56
|
-
"@deepseek-ai/dsh-agent-loop": "^0.1.
|
|
57
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
58
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
59
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
60
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
55
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-agent-loop": "^0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
61
61
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
62
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
62
|
+
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public"
|