@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.
@@ -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,
@@ -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) this.logger?.warn?.(`poll: ${e.message}`)
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.callMultipart('sendPhoto', form) }
426
- if (file.kind === 'voice') { form.append('voice', blob, name); return this.callMultipart('sendVoice', form) }
427
- if (file.kind === 'audio') { form.append('audio', blob, name); return this.callMultipart('sendAudio', form) }
428
- if (file.kind === 'video') { form.append('video', blob, name); return this.callMultipart('sendVideo', form) }
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.callMultipart('sendDocument', form)
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.call('sendMessage', params)
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
- await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
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
- await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
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') return reply(`Ваш id: ${userId}`)
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
+ }
@@ -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>')
@@ -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.2.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.0-rc.6",
56
- "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6",
57
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
58
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
59
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
60
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
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.0-rc.6"
62
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
63
63
  },
64
64
  "publishConfig": {
65
65
  "access": "public"