@goodandready/dsh-messenger-gateway 0.2.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.
@@ -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/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()
@@ -170,7 +171,15 @@ export class Gateway {
170
171
  }
171
172
  const adapter = this.getAdapter(resolved.platform)
172
173
  if (!adapter?.sendTo) throw new Error(`adapter ${resolved.platform} unavailable`)
173
- await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
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
+ }
174
183
  }
175
184
 
176
185
  async messengerAsk(target, payload, timeoutMs = 300_000) {
@@ -179,7 +188,17 @@ export class Gateway {
179
188
  const token = makeAskToken()
180
189
  const { replyMarkup, callbackKeys } = buildInlineKeyboard(token, payload.buttons || [])
181
190
  indexCallbacks(this.callbackIndex, callbackKeys, token)
182
- await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
191
+ try {
192
+ await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
193
+ } catch (err) {
194
+ // Never leave stale callback keys pointing at an unresolvable ask.
195
+ releaseCallbacks(this.callbackIndex, callbackKeys)
196
+ if (isTopicGoneError(err)) {
197
+ this.logger?.warn?.(`messengerAsk: topic gone for ${target.platform}:${target.chatId}:${target.threadId} — ask aborted (chat/topic deleted)`)
198
+ throw new Error('messenger.ask: chat or topic was deleted')
199
+ }
200
+ throw err
201
+ }
183
202
  return new Promise((resolve, reject) => {
184
203
  const timer = setTimeout(() => {
185
204
  this.pendingAsks.delete(token)
@@ -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>')
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.0",
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",