@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/stream.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Helpers for Telegram streaming edits + progress. */
|
|
2
|
+
|
|
3
|
+
export function extractTextDelta(chunk) {
|
|
4
|
+
if (!chunk || typeof chunk !== 'object') return ''
|
|
5
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
|
|
6
|
+
return ''
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractToolName(eventData) {
|
|
10
|
+
const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
|
|
11
|
+
return name ? String(name) : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatProgressLine(toolName) {
|
|
15
|
+
if (!toolName) return '⏳ Думаю…'
|
|
16
|
+
return `🔧 ${toolName}…`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
|
|
20
|
+
const body = String(streamText || '').trimEnd()
|
|
21
|
+
const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
|
|
22
|
+
const combined = head + (body || '…')
|
|
23
|
+
if (combined.length <= maxLen) return combined
|
|
24
|
+
return combined.slice(0, maxLen - 1) + '…'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createEditScheduler(editFn, intervalMs = 1200) {
|
|
28
|
+
let pending = null
|
|
29
|
+
let timer = null
|
|
30
|
+
let lastSent = ''
|
|
31
|
+
let inFlight = Promise.resolve()
|
|
32
|
+
const flush = () => {
|
|
33
|
+
timer = null
|
|
34
|
+
if (pending === null || pending === lastSent) return
|
|
35
|
+
const text = pending
|
|
36
|
+
inFlight = inFlight.then(async () => {
|
|
37
|
+
try {
|
|
38
|
+
await editFn(text)
|
|
39
|
+
lastSent = text
|
|
40
|
+
} catch {}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
push(text) {
|
|
45
|
+
pending = text
|
|
46
|
+
if (timer) return
|
|
47
|
+
timer = setTimeout(flush, Math.max(200, intervalMs))
|
|
48
|
+
timer.unref?.()
|
|
49
|
+
},
|
|
50
|
+
async flush() {
|
|
51
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
52
|
+
flush()
|
|
53
|
+
await inFlight
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
|
|
59
|
+
if (typeof typingFn !== 'function') return () => {}
|
|
60
|
+
typingFn().catch?.(() => {})
|
|
61
|
+
const timer = setInterval(() => { typingFn().catch?.(() => {}) }, intervalMs)
|
|
62
|
+
timer.unref?.()
|
|
63
|
+
return () => clearInterval(timer)
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export function escapeHtml(text) {
|
|
2
|
+
return String(text ?? '')
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const PH = '\uE000'
|
|
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
|
+
|
|
109
|
+
/** Convert common Markdown from LLM replies to Telegram HTML parse_mode. */
|
|
110
|
+
export function markdownToTelegramHtml(markdown) {
|
|
111
|
+
let text = String(markdown ?? '')
|
|
112
|
+
const slots = []
|
|
113
|
+
|
|
114
|
+
const stash = (html) => {
|
|
115
|
+
const id = slots.length
|
|
116
|
+
slots.push(html)
|
|
117
|
+
return `${PH}${id}${PH}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
text = text.replace(/```([\s\S]*?)```/g, (_, code) => stash(`<pre><code>${escapeHtml(code.replace(/^\n/, '').replace(/\n$/, ''))}</code></pre>`))
|
|
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)
|
|
125
|
+
text = escapeHtml(text)
|
|
126
|
+
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${escapeHtml(url)}">${label}</a>`)
|
|
127
|
+
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
|
128
|
+
text = text.replace(/__([^_\n]+)__/g, '<b>$1</b>')
|
|
129
|
+
text = text.replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
|
|
130
|
+
text = text.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<i>$2</i>')
|
|
131
|
+
text = text.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, '$1<i>$2</i>')
|
|
132
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
|
|
133
|
+
for (let i = 0; i < slots.length; i++) text = text.replace(`${PH}${i}${PH}`, slots[i])
|
|
134
|
+
return text
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function prepareTelegramText(text, format = 'html') {
|
|
138
|
+
const raw = String(text ?? '')
|
|
139
|
+
if (!raw || format === 'plain') return { text: raw, parseMode: undefined }
|
|
140
|
+
return { text: markdownToTelegramHtml(raw), parseMode: 'HTML' }
|
|
141
|
+
}
|
package/lib/text.js
CHANGED
|
@@ -19,4 +19,21 @@ export function assistantText(message) {
|
|
|
19
19
|
.filter((block) => block.type === 'text')
|
|
20
20
|
.map((block) => block.text)
|
|
21
21
|
.join('')
|
|
22
|
-
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/** Drop English reasoning preamble before the user-facing reply (Telegram). */
|
|
26
|
+
export function stripReasoningPreamble(text) {
|
|
27
|
+
const raw = String(text ?? '').trim()
|
|
28
|
+
if (!raw) return raw
|
|
29
|
+
const paragraphs = raw.split(/\n\n+/)
|
|
30
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
31
|
+
const p = paragraphs[i]
|
|
32
|
+
const cyrillic = (p.match(/[\u0400-\u04FF]/g) || []).length
|
|
33
|
+
const latin = (p.match(/[A-Za-z]/g) || []).length
|
|
34
|
+
if (cyrillic >= 12 && cyrillic > latin) return paragraphs.slice(i).join('\n\n').trim()
|
|
35
|
+
}
|
|
36
|
+
return raw
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const MESSENGER_RELAY_INSTRUCTION = 'Канал: Telegram. Отвечай только пользователю по-русски. Не выводи ход рассуждений, планирование и служебный текст на английском.'
|
package/lib/topics.js
CHANGED
|
@@ -6,22 +6,41 @@ export function normalizeThreadId(threadId) {
|
|
|
6
6
|
return Math.trunc(n)
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** Stable
|
|
9
|
+
/** Stable base key: platform+chat+topic. */
|
|
10
10
|
export function chatKey(platform, chatId, threadId = 0) {
|
|
11
11
|
return `${platform}:${chatId}:${normalizeThreadId(threadId)}`
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Session map key.
|
|
16
|
+
* scope=user in groups → one agent per user in that chat/topic.
|
|
17
|
+
* Private chats ignore user suffix (already 1:1).
|
|
18
|
+
*/
|
|
19
|
+
export function sessionKey({ platform, chatId, threadId = 0, userId, chatType, scope = "chat" }) {
|
|
20
|
+
const base = chatKey(platform, chatId, threadId)
|
|
21
|
+
const isGroup = chatType === "group" || chatType === "supergroup"
|
|
22
|
+
if (scope === "user" && isGroup && userId != null && Number.isFinite(Number(userId))) {
|
|
23
|
+
return `${base}:u:${Number(userId)}`
|
|
24
|
+
}
|
|
25
|
+
return base
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
export function parseChatKey(key) {
|
|
15
|
-
const parts = String(key ||
|
|
29
|
+
const parts = String(key || "").split(":")
|
|
16
30
|
if (parts.length < 2) return null
|
|
17
31
|
const platform = parts[0]
|
|
18
32
|
const chatId = parts[1]
|
|
19
33
|
const threadId = parts.length >= 3 ? normalizeThreadId(parts[2]) : 0
|
|
20
|
-
|
|
34
|
+
const out = { platform, chatId, threadId }
|
|
35
|
+
if (parts.length >= 5 && parts[3] === "u") {
|
|
36
|
+
const userId = Number(parts[4])
|
|
37
|
+
if (Number.isFinite(userId)) out.userId = userId
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
21
40
|
}
|
|
22
41
|
|
|
23
42
|
/** Extra Telegram API fields for forum topic replies. */
|
|
24
43
|
export function telegramThreadParams(threadId) {
|
|
25
44
|
const tid = normalizeThreadId(threadId)
|
|
26
45
|
return tid > 0 ? { message_thread_id: tid } : {}
|
|
27
|
-
}
|
|
46
|
+
}
|
package/lib/tts.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
/** Prepare agent text for dsh-tts and map audio to Telegram voice
|
|
1
|
+
/** Prepare agent text for dsh-tts and map audio to Telegram voice notes. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
2
7
|
|
|
3
8
|
export const DEFAULT_TTS_MAX_CHARS = 4000
|
|
4
9
|
|
|
@@ -29,6 +34,11 @@ export function voiceFileNameForMime(mime) {
|
|
|
29
34
|
return 'reply.dat'
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
export function isOggOpusMime(mime) {
|
|
38
|
+
const m = String(mime || '').toLowerCase()
|
|
39
|
+
return m.includes('ogg') || m.includes('opus')
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export function voiceReplyFile(spoken) {
|
|
33
43
|
const mime = spoken?.mime || 'audio/mpeg'
|
|
34
44
|
return {
|
|
@@ -37,4 +47,74 @@ export function voiceReplyFile(spoken) {
|
|
|
37
47
|
kind: 'voice',
|
|
38
48
|
name: voiceFileNameForMime(mime),
|
|
39
49
|
}
|
|
40
|
-
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runFfmpeg(args) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
55
|
+
let err = ''
|
|
56
|
+
child.stderr.on('data', (chunk) => { err += String(chunk) })
|
|
57
|
+
child.on('error', reject)
|
|
58
|
+
child.on('close', (code) => {
|
|
59
|
+
if (code === 0) resolve()
|
|
60
|
+
else reject(new Error(`ffmpeg exit ${code}: ${err.slice(-400)}`))
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Convert arbitrary TTS audio bytes to OGG/Opus for Telegram sendVoice. */
|
|
66
|
+
export async function convertToOggOpus(audioBytes, inputMime = 'audio/mpeg') {
|
|
67
|
+
const dir = await mkdtemp(join(tmpdir(), 'msgw-tts-'))
|
|
68
|
+
const ext = voiceFileNameForMime(inputMime).split('.').pop() || 'mp3'
|
|
69
|
+
const input = join(dir, `in.${ext}`)
|
|
70
|
+
const output = join(dir, 'out.ogg')
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(input, audioBytes)
|
|
73
|
+
await runFfmpeg([
|
|
74
|
+
'-y', '-hide_banner', '-loglevel', 'error',
|
|
75
|
+
'-i', input,
|
|
76
|
+
'-c:a', 'libopus',
|
|
77
|
+
'-b:a', '32k',
|
|
78
|
+
'-vbr', 'on',
|
|
79
|
+
'-application', 'voip',
|
|
80
|
+
output,
|
|
81
|
+
])
|
|
82
|
+
return await readFile(output)
|
|
83
|
+
} finally {
|
|
84
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a Telegram voice-note file from dsh-tts output.
|
|
90
|
+
* Prefer OGG/Opus; if conversion fails and binary missing, fall back to audio (not voice-note).
|
|
91
|
+
*/
|
|
92
|
+
export async function toTelegramVoiceFile(spoken, { logger } = {}) {
|
|
93
|
+
if (!spoken?.audio?.length) return null
|
|
94
|
+
if (isOggOpusMime(spoken.mime)) {
|
|
95
|
+
return {
|
|
96
|
+
bytes: spoken.audio,
|
|
97
|
+
mime: 'audio/ogg',
|
|
98
|
+
kind: 'voice',
|
|
99
|
+
name: 'reply.ogg',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const ogg = await convertToOggOpus(spoken.audio, spoken.mime)
|
|
104
|
+
return {
|
|
105
|
+
bytes: ogg,
|
|
106
|
+
mime: 'audio/ogg',
|
|
107
|
+
kind: 'voice',
|
|
108
|
+
name: 'reply.ogg',
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
logger?.warn?.(`tts opus convert skipped: ${err.message}`)
|
|
112
|
+
// Telegram sendVoice rejects mp3; send as audio file instead of failing silently.
|
|
113
|
+
return {
|
|
114
|
+
bytes: spoken.audio,
|
|
115
|
+
mime: spoken.mime || 'audio/mpeg',
|
|
116
|
+
kind: 'audio',
|
|
117
|
+
name: voiceFileNameForMime(spoken.mime),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
|
|
4
|
+
export function createVoicePrefs(filePath) {
|
|
5
|
+
/** @type {Record<string, boolean>} */
|
|
6
|
+
let state = {}
|
|
7
|
+
if (filePath && existsSync(filePath)) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = JSON.parse(readFileSync(filePath, "utf8"))
|
|
10
|
+
if (raw && typeof raw === "object") state = raw
|
|
11
|
+
} catch {}
|
|
12
|
+
}
|
|
13
|
+
const persist = () => {
|
|
14
|
+
if (!filePath) return
|
|
15
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
16
|
+
writeFileSync(filePath, JSON.stringify(state, null, 2))
|
|
17
|
+
}
|
|
18
|
+
const key = (userId) => String(Number(userId) || userId || "")
|
|
19
|
+
return {
|
|
20
|
+
get(userId) {
|
|
21
|
+
const k = key(userId)
|
|
22
|
+
if (!k || !(k in state)) return null
|
|
23
|
+
return Boolean(state[k])
|
|
24
|
+
},
|
|
25
|
+
set(userId, enabled) {
|
|
26
|
+
const k = key(userId)
|
|
27
|
+
if (!k) return false
|
|
28
|
+
state[k] = Boolean(enabled)
|
|
29
|
+
persist()
|
|
30
|
+
return true
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Decide whether to speak the reply. */
|
|
36
|
+
export function shouldSpeakReply({ globalTts, voiceMode, inboundWasVoice, userPref }) {
|
|
37
|
+
if (globalTts) return true
|
|
38
|
+
if (userPref === true) return true
|
|
39
|
+
if (userPref === false) return false
|
|
40
|
+
if (voiceMode === "always") return true
|
|
41
|
+
if (voiceMode === "off") return false
|
|
42
|
+
// mirror: speak if inbound was voice
|
|
43
|
+
return Boolean(inboundWasVoice)
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -15,24 +15,26 @@
|
|
|
15
15
|
"lib/",
|
|
16
16
|
"cordis.patch.yml",
|
|
17
17
|
"README.md",
|
|
18
|
-
"LICENSE"
|
|
19
|
-
"docs/"
|
|
18
|
+
"LICENSE"
|
|
20
19
|
],
|
|
21
20
|
"keywords": [
|
|
22
21
|
"dsh",
|
|
23
22
|
"dsh-plugin",
|
|
24
23
|
"telegram",
|
|
25
|
-
"discord",
|
|
26
24
|
"messenger",
|
|
27
|
-
"deepseek-harness"
|
|
25
|
+
"deepseek-harness",
|
|
26
|
+
"bot"
|
|
28
27
|
],
|
|
29
28
|
"repository": {
|
|
30
29
|
"type": "git",
|
|
31
30
|
"url": "git+https://github.com/GooDAnDReaDY/dsh-messenger-gateway.git"
|
|
32
31
|
},
|
|
33
|
-
"homepage": "https://github.com/GooDAnDReaDY/dsh-messenger-gateway",
|
|
32
|
+
"homepage": "https://github.com/GooDAnDReaDY/dsh-messenger-gateway#readme",
|
|
34
33
|
"scripts": {
|
|
35
|
-
"test": "node --test test/*.test.mjs"
|
|
34
|
+
"test": "node --test test/*.test.mjs",
|
|
35
|
+
"test:load": "node scripts/verify-dsh-load.mjs",
|
|
36
|
+
"test:all": "npm test && npm run test:load",
|
|
37
|
+
"smoke:http": "node scripts/smoke-http.mjs"
|
|
36
38
|
},
|
|
37
39
|
"dsh": {
|
|
38
40
|
"bundle": {
|
|
@@ -56,9 +58,13 @@
|
|
|
56
58
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
57
59
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
58
60
|
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
59
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
61
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
62
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
60
63
|
},
|
|
61
64
|
"publishConfig": {
|
|
62
65
|
"access": "public"
|
|
66
|
+
},
|
|
67
|
+
"bugs": {
|
|
68
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-messenger-gateway/issues"
|
|
63
69
|
}
|
|
64
70
|
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# dsh-messenger-gateway — versioning
|
|
2
|
-
|
|
3
|
-
## Правило версий
|
|
4
|
-
|
|
5
|
-
| Версия | Когда |
|
|
6
|
-
|--------|--------|
|
|
7
|
-
| **0.0.1, 0.0.2, 0.0.3…** | Каждая отдельная фича (один bump = одна фича) |
|
|
8
|
-
| **0.1.0** | Финальный релиз согласованного scope (замена hub на prod) |
|
|
9
|
-
| **1.0.0** | Когда-нибудь потом, не планируем сейчас |
|
|
10
|
-
|
|
11
|
-
## Журнал 0.0.x
|
|
12
|
-
|
|
13
|
-
| Ver | Фича | Статус |
|
|
14
|
-
|-----|------|--------|
|
|
15
|
-
| 0.0.1 | Скелет: Telegram text, Settings, команды | done |
|
|
16
|
-
| 0.0.2 | Черновик: media, outbound, messenger HTTP, topics, TTS toggle, /stop | done (unit tests) |
|
|
17
|
-
| 0.0.3 | Voice inbound via dsh-voice (signal, errors, unit test) | done (E2E smoke — с ботом) |
|
|
18
|
-
| 0.0.4 | Photo inbound + vision-bridge (attachInboundPhoto, maxImageBytes, photoOnlyHint) | done |
|
|
19
|
-
| 0.0.5 | Documents inbound (formatInboundDocument, documentOnlyHint) | done |
|
|
20
|
-
| 0.0.6 | Outbound tool attachments (nested images, URL fetch, stripImageUrls) | done |
|
|
21
|
-
| 0.0.7 | messenger API (HTTP + ctx.provide messenger, validation, schema) | done |
|
|
22
|
-
| 0.0.8 | Inline keyboards ask (lib/ask.js, cleanup, chat guard) | done |
|
|
23
|
-
| 0.0.9 | Forum topics (threadId sessions + outbound message_thread_id) | done |
|
|
24
|
-
| 0.0.10 | TTS replies (prepareTtsText, AbortSignal, maxChars UI) | done |
|
|
25
|
-
| 0.1.0 | Final Telegram scope + prod swap vs hub-media | done |
|
|
26
|
-
| … | Discord | after 0.1.0 |
|
|
27
|
-
|
|
28
|
-
## Scope 0.1.0 (финал этой линии)
|
|
29
|
-
|
|
30
|
-
Всё из roadmap п.4 для Telegram, без Discord (Discord — после 0.1.0 или отдельной веткой 0.0.x).
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# 0.1.0 install and prod swap
|
|
2
|
-
|
|
3
|
-
## Prerequisites
|
|
4
|
-
|
|
5
|
-
Profile bundles must include:
|
|
6
|
-
|
|
7
|
-
- `dsh-voice`
|
|
8
|
-
- `dsh-tts` (optional)
|
|
9
|
-
- `dsh-vision-bridge`
|
|
10
|
-
|
|
11
|
-
## Staging smoke
|
|
12
|
-
|
|
13
|
-
1. Backup profile `package.json`.
|
|
14
|
-
2. `dsh plugin --profile <staging-profile> add file:/opt/plugins-dev/dsh-messenger-gateway`
|
|
15
|
-
or `add @goodandready/dsh-messenger-gateway@0.1.0` after npm publish.
|
|
16
|
-
3. Restart Harness; verify client.js 200 and `/dsh-messenger-gateway/status`.
|
|
17
|
-
4. Run `docs/testing/0.1.0-smoke.md`.
|
|
18
|
-
|
|
19
|
-
## RELEASE → npm
|
|
20
|
-
|
|
21
|
-
Copy to RELEASE/dhsplugins, `npm test`, `npm publish`.
|
|
22
|
-
|
|
23
|
-
## Prod swap (user approval only)
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
dsh plugin --profile web remove dsh-im-hub-media
|
|
27
|
-
dsh plugin --profile web add @goodandready/dsh-messenger-gateway@0.1.0
|
|
28
|
-
```
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# 0.1.0 manual smoke
|
|
2
|
-
|
|
3
|
-
- [ ] `/start`, text reply
|
|
4
|
-
- [ ] Voice → dsh-voice transcript
|
|
5
|
-
- [ ] Photo → vision-bridge
|
|
6
|
-
- [ ] Document metadata in prompt
|
|
7
|
-
- [ ] Outbound tool image
|
|
8
|
-
- [ ] messenger HTTP send/ask/progress
|
|
9
|
-
- [ ] Inline keyboard ask
|
|
10
|
-
- [ ] Forum topic session
|
|
11
|
-
- [ ] TTS voice reply (or log warn if down)
|
|
12
|
-
- [ ] `/new` `/stop` `/whoami`
|