@goodandready/dsh-messenger-gateway 0.1.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.
@@ -0,0 +1,225 @@
1
+ import { normalizeThreadId } from './topics.js'
2
+
3
+ const DEFAULT_ASK_TIMEOUT_MS = 300_000
4
+
5
+ export const messengerApiSchema = {
6
+ version: 1,
7
+ service: 'messenger',
8
+ http: {
9
+ send: {
10
+ method: 'POST',
11
+ path: '/dsh-messenger-gateway/messenger/send',
12
+ body: {
13
+ target: { platform: 'telegram', chatId: 'number|string', threadId: 'optional number' },
14
+ text: 'optional string',
15
+ files: 'optional [{ dataBase64, mime, kind, name }]',
16
+ replyMarkup: 'optional Telegram inline_keyboard object',
17
+ },
18
+ },
19
+ ask: {
20
+ method: 'POST',
21
+ path: '/dsh-messenger-gateway/messenger/ask',
22
+ body: {
23
+ target: { platform: 'telegram', chatId: 'number|string' },
24
+ text: 'string (required)',
25
+ buttons: '[[{ id, text }]] (required, at least one button)',
26
+ timeoutMs: 'optional number (default 300000)',
27
+ },
28
+ },
29
+ progress: {
30
+ method: 'POST',
31
+ path: '/dsh-messenger-gateway/messenger/progress',
32
+ body: {
33
+ target: { platform: 'telegram', chatId: 'number|string' },
34
+ text: 'string (required)',
35
+ },
36
+ },
37
+ },
38
+ }
39
+
40
+ export function parseMessengerBody(raw) {
41
+ try {
42
+ return JSON.parse(String(raw || '{}') || '{}')
43
+ } catch {
44
+ const err = new Error('invalid json')
45
+ err.status = 400
46
+ throw err
47
+ }
48
+ }
49
+
50
+ export function validateTarget(target) {
51
+ if (!target || typeof target !== 'object') {
52
+ const err = new Error('target is required')
53
+ err.status = 400
54
+ throw err
55
+ }
56
+ const platform = String(target.platform || '').trim()
57
+ if (!platform) {
58
+ const err = new Error('target.platform is required')
59
+ err.status = 400
60
+ throw err
61
+ }
62
+ const chatId = target.chatId
63
+ if (chatId === undefined || chatId === null || String(chatId).trim() === '') {
64
+ const err = new Error('target.chatId is required')
65
+ err.status = 400
66
+ throw err
67
+ }
68
+ const out = { platform, chatId }
69
+ const threadId = normalizeThreadId(target.threadId)
70
+ if (threadId > 0) out.threadId = threadId
71
+ return out
72
+ }
73
+
74
+ export function normalizeButtons(buttons) {
75
+ if (buttons === undefined || buttons === null) return []
76
+ if (!Array.isArray(buttons)) {
77
+ const err = new Error('buttons must be a 2d array')
78
+ err.status = 400
79
+ throw err
80
+ }
81
+ return buttons.map((row, ri) => {
82
+ if (!Array.isArray(row)) {
83
+ const err = new Error(`buttons[${ri}] must be an array`)
84
+ err.status = 400
85
+ throw err
86
+ }
87
+ return row.map((btn, bi) => {
88
+ const id = String(btn?.id ?? btn?.buttonId ?? '').trim()
89
+ const text = String(btn?.text ?? '').trim()
90
+ if (!id || !text) {
91
+ const err = new Error(`buttons[${ri}][${bi}] requires id and text`)
92
+ err.status = 400
93
+ throw err
94
+ }
95
+ return { id, text }
96
+ })
97
+ })
98
+ }
99
+
100
+ export function normalizeFiles(files) {
101
+ if (!files) return []
102
+ if (!Array.isArray(files)) {
103
+ const err = new Error('files must be an array')
104
+ err.status = 400
105
+ throw err
106
+ }
107
+ return files.map((file, i) => {
108
+ if (!file || typeof file !== 'object') {
109
+ const err = new Error(`files[${i}] must be an object`)
110
+ err.status = 400
111
+ throw err
112
+ }
113
+ if (file.bytes) return file
114
+ if (typeof file.dataBase64 === 'string') {
115
+ return {
116
+ bytes: Buffer.from(file.dataBase64, 'base64'),
117
+ mime: file.mime || 'application/octet-stream',
118
+ kind: file.kind || 'document',
119
+ name: file.name || 'file',
120
+ }
121
+ }
122
+ const err = new Error(`files[${i}] needs bytes or dataBase64`)
123
+ err.status = 400
124
+ throw err
125
+ })
126
+ }
127
+
128
+ export function normalizeSendBody(payload) {
129
+ const text = typeof payload?.text === 'string' ? payload.text.trim() : ''
130
+ const files = normalizeFiles(payload?.files)
131
+ if (!text && !files.length) {
132
+ const err = new Error('text or files required')
133
+ err.status = 400
134
+ throw err
135
+ }
136
+ const body = {}
137
+ if (text) body.text = text
138
+ if (files.length) body.files = files
139
+ if (payload?.replyMarkup) body.replyMarkup = payload.replyMarkup
140
+ return body
141
+ }
142
+
143
+ export function normalizeAskBody(payload) {
144
+ const text = String(payload?.text || '').trim()
145
+ if (!text) {
146
+ const err = new Error('text is required for ask')
147
+ err.status = 400
148
+ throw err
149
+ }
150
+ const buttons = normalizeButtons(payload?.buttons)
151
+ if (!buttons.length || !buttons.some((row) => row.length > 0)) {
152
+ const err = new Error('ask requires at least one button')
153
+ err.status = 400
154
+ throw err
155
+ }
156
+ return { text, buttons }
157
+ }
158
+
159
+ export function normalizeProgressBody(payload) {
160
+ const text = String(payload?.text || '').trim()
161
+ if (!text) {
162
+ const err = new Error('text is required for progress')
163
+ err.status = 400
164
+ throw err
165
+ }
166
+ return { text }
167
+ }
168
+
169
+ export function resolveAskTimeoutMs(timeoutMs) {
170
+ const n = Number(timeoutMs)
171
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_ASK_TIMEOUT_MS
172
+ return Math.min(n, 3_600_000)
173
+ }
174
+
175
+ export function httpStatusForError(err) {
176
+ if (err?.status) return err.status
177
+ const msg = String(err?.message || '')
178
+ if (msg.includes('timed out')) return 504
179
+ if (msg.includes('not running') || msg.includes('unavailable')) return 503
180
+ return 502
181
+ }
182
+
183
+ export async function dispatchMessenger(gw, action, payload) {
184
+ if (!gw) {
185
+ const err = new Error('gateway not running')
186
+ err.status = 503
187
+ throw err
188
+ }
189
+ const target = validateTarget(payload?.target)
190
+ if (action === 'send') {
191
+ await gw.messenger.send(target, normalizeSendBody(payload))
192
+ return { ok: true }
193
+ }
194
+ if (action === 'progress') {
195
+ await gw.messenger.progress(target, normalizeProgressBody(payload))
196
+ return { ok: true }
197
+ }
198
+ if (action === 'ask') {
199
+ const result = await gw.messenger.ask(target, normalizeAskBody(payload), resolveAskTimeoutMs(payload?.timeoutMs))
200
+ return { ok: true, result }
201
+ }
202
+ const err = new Error(`unknown messenger action: ${action}`)
203
+ err.status = 400
204
+ throw err
205
+ }
206
+
207
+ export function createMessengerService(getGw) {
208
+ return {
209
+ adapters: () => getGw()?.messenger.adapters() ?? [],
210
+ activeChats: () => getGw()?.messenger.activeChats() ?? 0,
211
+ send: async (target, payload) => {
212
+ const gw = getGw()
213
+ await dispatchMessenger(gw, 'send', { target, ...payload })
214
+ },
215
+ progress: async (target, payload) => {
216
+ const gw = getGw()
217
+ await dispatchMessenger(gw, 'progress', { target, ...payload })
218
+ },
219
+ ask: async (target, payload, timeoutMs) => {
220
+ const gw = getGw()
221
+ const out = await dispatchMessenger(gw, 'ask', { target, ...payload, timeoutMs })
222
+ return out.result
223
+ },
224
+ }
225
+ }
@@ -0,0 +1,112 @@
1
+ const IMAGE_URL_RE = /(\/(?:dsh-[\w-]+)\/image\?[^\s)]+)/g
2
+
3
+ export function extractImageUrls(text) {
4
+ const urls = []
5
+ for (const m of String(text || '').matchAll(IMAGE_URL_RE)) urls.push(m[1])
6
+ return urls
7
+ }
8
+
9
+ /** Remove harness image URLs from reply text before sending to messenger. */
10
+ export function stripImageUrls(text) {
11
+ return String(text || '')
12
+ .replace(IMAGE_URL_RE, '')
13
+ .replace(/[ \t]+\n/g, '\n')
14
+ .replace(/\n{3,}/g, '\n\n')
15
+ .trim()
16
+ }
17
+
18
+ /** Collect image attachment refs from assistant content (incl. nested tool blocks). */
19
+ export function collectImagesDeep(content) {
20
+ const images = []
21
+ const walk = (blocks) => {
22
+ if (!Array.isArray(blocks)) return
23
+ for (const block of blocks) {
24
+ if (!block || typeof block !== 'object') continue
25
+ if (block.type === 'image' && block.attachment) images.push(block.attachment)
26
+ if (Array.isArray(block.content)) walk(block.content)
27
+ }
28
+ }
29
+ walk(content)
30
+ return images
31
+ }
32
+
33
+ export function collectAssistantParts(message) {
34
+ const texts = []
35
+ for (const block of message?.content || []) {
36
+ if (block.type === 'text' && block.text) texts.push(block.text)
37
+ }
38
+ return { text: texts.join('\n\n').trim(), images: collectImagesDeep(message?.content) }
39
+ }
40
+
41
+ export function dedupeAttachmentRefs(refs) {
42
+ const seen = new Set()
43
+ const out = []
44
+ for (const ref of refs || []) {
45
+ const id = ref?.attachmentId ?? ref?.id
46
+ const key = id !== undefined ? String(id) : JSON.stringify(ref)
47
+ if (seen.has(key)) continue
48
+ seen.add(key)
49
+ out.push(ref)
50
+ }
51
+ return out
52
+ }
53
+
54
+ export function fileNameForRef(ref) {
55
+ const name = String(ref?.name || '').trim()
56
+ if (name) return name.replace(/[^\w.\- ]/g, '_').slice(0, 120) || 'image.png'
57
+ const mt = String(ref?.mediaType || '').toLowerCase()
58
+ if (mt === 'image/jpeg' || mt === 'image/jpg') return 'image.jpg'
59
+ if (mt === 'image/webp') return 'image.webp'
60
+ if (mt === 'image/gif') return 'image.gif'
61
+ return 'image.png'
62
+ }
63
+
64
+ export async function readAttachmentBytes(ctx, ref) {
65
+ const stored = await ctx.attachments.readImage(ref)
66
+ return {
67
+ bytes: stored.data,
68
+ mime: ref.mediaType || stored.ref?.mediaType || 'image/png',
69
+ kind: 'photo',
70
+ name: fileNameForRef(ref),
71
+ }
72
+ }
73
+
74
+ export async function fetchInternalImage(baseUrl, path, signal) {
75
+ const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
76
+ const res = await fetch(url, { signal })
77
+ if (!res.ok) throw new Error(`image fetch HTTP ${res.status}`)
78
+ const bytes = new Uint8Array(await res.arrayBuffer())
79
+ const mime = res.headers.get('content-type') || 'image/png'
80
+ const fromQuery = url.searchParams.get('id') || url.searchParams.get('name')
81
+ const name = fromQuery ? `image-${fromQuery.slice(0, 32)}.png` : 'image.png'
82
+ return { bytes, mime, kind: 'photo', name }
83
+ }
84
+
85
+ const DEFAULT_MAX_OUTBOUND_FILES = 10
86
+
87
+ /** Build Telegram-ready files from assistant image blocks and inline plugin URLs. */
88
+ export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
89
+ const { signal, logger, maxFiles = DEFAULT_MAX_OUTBOUND_FILES } = opts
90
+ const files = []
91
+ const refs = dedupeAttachmentRefs(collector.images)
92
+ for (const ref of refs) {
93
+ if (files.length >= maxFiles) break
94
+ if (signal?.aborted) break
95
+ try {
96
+ files.push(await readAttachmentBytes(ctx, ref))
97
+ } catch (e) {
98
+ logger?.warn?.(`outbound image: ${e.message}`)
99
+ }
100
+ }
101
+ const urls = [...new Set(extractImageUrls(collector.parts.join('\n')))]
102
+ for (const path of urls) {
103
+ if (files.length >= maxFiles) break
104
+ if (signal?.aborted) break
105
+ try {
106
+ files.push(await fetchInternalImage(baseUrl, path, signal))
107
+ } catch (e) {
108
+ logger?.warn?.(`outbound url image: ${e.message}`)
109
+ }
110
+ }
111
+ return files
112
+ }
package/lib/photos.js ADDED
@@ -0,0 +1,30 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ /**
4
+ * Persist an inbound photo for the agent. dsh-vision-bridge rewrites image blocks
5
+ * for text-only models at agent/pre-step and llm/stream.
6
+ */
7
+ export async function attachInboundPhoto(ctx, att, opts = {}) {
8
+ const { signal, maxBytes = 20 * 1024 * 1024 } = opts
9
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
10
+ const bytes = new Uint8Array(await readFile(att.path))
11
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
12
+ if (maxBytes > 0 && bytes.length > maxBytes) {
13
+ throw new Error(`image too large (${bytes.length} bytes, max ${maxBytes})`)
14
+ }
15
+ const ref = await ctx.attachments.saveImage({
16
+ data: bytes,
17
+ mediaType: att.mime || 'image/jpeg',
18
+ ...(att.name ? { name: att.name } : {}),
19
+ })
20
+ return { ref, byteLength: bytes.length }
21
+ }
22
+
23
+ /** Text hint when the user sent photo(s) without a caption. */
24
+ export function photoOnlyHint(attachments, userText) {
25
+ if (String(userText || '').trim()) return ''
26
+ const photos = attachments.filter((a) => a.kind === 'photo')
27
+ if (!photos.length) return ''
28
+ if (photos.length === 1) return '[Пользователь отправил фото]'
29
+ return `[Пользователь отправил ${photos.length} фото]`
30
+ }
package/lib/text.js ADDED
@@ -0,0 +1,22 @@
1
+ export function splitText(text, maxLen) {
2
+ const raw = String(text ?? '')
3
+ if (raw.length <= maxLen) return [raw]
4
+ const chunks = []
5
+ let rest = raw
6
+ while (rest.length > maxLen) {
7
+ let cut = rest.lastIndexOf('\n', maxLen)
8
+ if (cut < maxLen / 2) cut = rest.lastIndexOf(' ', maxLen)
9
+ if (cut < maxLen / 2) cut = maxLen
10
+ chunks.push(rest.slice(0, cut).trim())
11
+ rest = rest.slice(cut).trimStart()
12
+ }
13
+ if (rest.length > 0) chunks.push(rest)
14
+ return chunks
15
+ }
16
+
17
+ export function assistantText(message) {
18
+ return (message?.content || [])
19
+ .filter((block) => block.type === 'text')
20
+ .map((block) => block.text)
21
+ .join('')
22
+ }
package/lib/topics.js ADDED
@@ -0,0 +1,27 @@
1
+ /** Forum topic / thread helpers (Telegram message_thread_id). */
2
+
3
+ export function normalizeThreadId(threadId) {
4
+ const n = Number(threadId)
5
+ if (!Number.isFinite(n) || n <= 0) return 0
6
+ return Math.trunc(n)
7
+ }
8
+
9
+ /** Stable session key: one agent per platform+chat+topic. */
10
+ export function chatKey(platform, chatId, threadId = 0) {
11
+ return `${platform}:${chatId}:${normalizeThreadId(threadId)}`
12
+ }
13
+
14
+ export function parseChatKey(key) {
15
+ const parts = String(key || '').split(':')
16
+ if (parts.length < 2) return null
17
+ const platform = parts[0]
18
+ const chatId = parts[1]
19
+ const threadId = parts.length >= 3 ? normalizeThreadId(parts[2]) : 0
20
+ return { platform, chatId, threadId }
21
+ }
22
+
23
+ /** Extra Telegram API fields for forum topic replies. */
24
+ export function telegramThreadParams(threadId) {
25
+ const tid = normalizeThreadId(threadId)
26
+ return tid > 0 ? { message_thread_id: tid } : {}
27
+ }
package/lib/tts.js ADDED
@@ -0,0 +1,40 @@
1
+ /** Prepare agent text for dsh-tts and map audio to Telegram voice messages. */
2
+
3
+ export const DEFAULT_TTS_MAX_CHARS = 4000
4
+
5
+ export function stripMarkdownForSpeech(text) {
6
+ return String(text || '')
7
+ .replace(/```[\s\S]*?```/g, ' ')
8
+ .replace(/`[^`]+`/g, ' ')
9
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
10
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
11
+ .replace(/https?:\/\/\S+/g, ' ')
12
+ .replace(/[#*_~]/g, '')
13
+ .replace(/\s+/g, ' ')
14
+ .trim()
15
+ }
16
+
17
+ export function prepareTtsText(answer, maxChars = DEFAULT_TTS_MAX_CHARS) {
18
+ const stripped = stripMarkdownForSpeech(answer)
19
+ if (!stripped || stripped.length < 2) return ''
20
+ const limit = Math.max(1, Number(maxChars) || DEFAULT_TTS_MAX_CHARS)
21
+ return stripped.slice(0, limit)
22
+ }
23
+
24
+ export function voiceFileNameForMime(mime) {
25
+ const m = String(mime || '').toLowerCase()
26
+ if (m.includes('ogg') || m.includes('opus')) return 'reply.ogg'
27
+ if (m.includes('mpeg') || m.includes('mp3')) return 'reply.mp3'
28
+ if (m.includes('wav')) return 'reply.wav'
29
+ return 'reply.dat'
30
+ }
31
+
32
+ export function voiceReplyFile(spoken) {
33
+ const mime = spoken?.mime || 'audio/mpeg'
34
+ return {
35
+ bytes: spoken.audio,
36
+ mime,
37
+ kind: 'voice',
38
+ name: voiceFileNameForMime(mime),
39
+ }
40
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
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.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json",
12
+ "./cordis.patch.yml": "./cordis.patch.yml"
13
+ },
14
+ "files": [
15
+ "lib/",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE",
19
+ "docs/"
20
+ ],
21
+ "keywords": [
22
+ "dsh",
23
+ "dsh-plugin",
24
+ "telegram",
25
+ "discord",
26
+ "messenger",
27
+ "deepseek-harness"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-messenger-gateway.git"
32
+ },
33
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-messenger-gateway",
34
+ "scripts": {
35
+ "test": "node --test test/*.test.mjs"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "inject": [
44
+ "@deepseek-ai/dsh-client-runtime",
45
+ "@deepseek-ai/dsh-client-locale",
46
+ "@deepseek-ai/dsh-client-ui-slots",
47
+ "@deepseek-ai/dsh-client-ui-settings"
48
+ ]
49
+ }
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.1",
53
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
57
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
59
+ "@deepseek-ai/schemastery": "^3.18.1"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }