@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/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, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
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 session key: one agent per platform+chat+topic. */
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 || '').split(':')
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
- return { platform, chatId, threadId }
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 messages. */
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.1.0",
4
- "description": "Messenger gateway for DeepSeek Harness: Telegram transport (0.1.0 scope). Discord planned.",
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`