@goodandready/dsh-messenger-gateway 0.3.1 → 0.3.8

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/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join } from 'node:path'
3
- import { settingsNamespace } from '@deepseek-ai/dsh-settings'
4
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
4
  import { Gateway } from './gateway.js'
6
5
  import { readBody, writeJson, isTrustedSettingsRequest } from './http.js'
@@ -14,7 +13,7 @@ import { Config } from './config.js'
14
13
  export const name = 'dsh-messenger-gateway'
15
14
  export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
16
15
 
17
- export const SETTINGS_NAMESPACE = settingsNamespace('dsh-messenger-gateway')
16
+ export const SETTINGS_NAMESPACE = 'dsh-messenger-gateway'
18
17
  export { Config }
19
18
 
20
19
  function resolveConfig(raw) {
@@ -53,6 +52,8 @@ function publicConfig(cfg) {
53
52
  webhookSecretConfigured: Boolean(String(cfg.telegram.webhookSecret || '').trim()),
54
53
  botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
55
54
  voiceMode: cfg.telegram.voiceMode,
55
+ quickActions: cfg.telegram.quickActions !== false,
56
+ artifactPreviews: cfg.telegram.artifactPreviews !== false,
56
57
  notifyBridge: {
57
58
  enabled: Boolean(cfg.telegram.notifyBridge?.enabled),
58
59
  events: cfg.telegram.notifyBridge?.events || ['task_done', 'error'],
@@ -168,15 +169,27 @@ export function apply(ctx, config) {
168
169
  ctx.effect(() => ctx.tools.register(defineTool({
169
170
  name: 'messenger_ask',
170
171
  description:
171
- 'Ask the Telegram user a multiple-choice question with inline buttons and wait for their choice. '
172
- + 'Use for confirmations and short choices. Only works inside messenger-gateway sessions (msgw-*).',
172
+ 'Ask the Telegram user a single or multiple-choice question with inline buttons/checkboxes and wait for their choice. '
173
+ + 'Supports mode: "single" (default) or "multi" (checkboxes with Done/Cancel buttons). Only works inside messenger-gateway sessions (msgw-*).',
173
174
  parameters: {
174
175
  text: { type: 'string', required: true, description: 'Question text shown in Telegram.' },
175
176
  buttons: {
176
177
  type: 'array',
177
- required: true,
178
- description: 'Rows of buttons: [[{ id, text }, ...], ...]',
178
+ description: 'Rows of buttons: [[{ id, text }, ...], ...] (for single choice)',
179
179
  },
180
+ options: {
181
+ type: 'array',
182
+ description: 'List of options: [{ id, text, selected?: boolean }, ...] (for single or multi choice)',
183
+ },
184
+ mode: {
185
+ type: 'string',
186
+ description: '"single" for instant choice or "multi" for checkboxes form',
187
+ },
188
+ selected: {
189
+ type: 'array',
190
+ description: 'Initial selected option IDs for multi-select mode',
191
+ },
192
+ pageSize: { type: 'number', description: 'Number of options per page (default 6).' },
180
193
  timeoutMs: { type: 'number', description: 'Wait timeout ms (default 300000).' },
181
194
  },
182
195
  output: {
@@ -186,6 +199,7 @@ export function apply(ctx, config) {
186
199
  properties: {
187
200
  ok: { type: 'boolean' },
188
201
  buttonId: { type: 'string' },
202
+ selected: { type: 'array', items: { type: 'string' } },
189
203
  data: { type: 'string' },
190
204
  error: { type: 'string' },
191
205
  },
@@ -193,7 +207,7 @@ export function apply(ctx, config) {
193
207
  render: (_args, value) => [{
194
208
  type: 'text',
195
209
  text: value && value.ok
196
- ? `User chose: ${value.buttonId}`
210
+ ? (value.selected?.length ? `User selected: ${value.selected.join(', ')}` : `User chose: ${value.buttonId}`)
197
211
  : `messenger_ask failed: ${value && value.error ? value.error : 'unknown'}`,
198
212
  }],
199
213
  },
@@ -201,12 +215,15 @@ export function apply(ctx, config) {
201
215
  const gw = getGw()
202
216
  if (!gw) return { ok: false, error: 'gateway not running' }
203
217
  try {
204
- const buttons = args.buttons
205
218
  const result = await gw.messengerAskFromAgent(exec.agent, {
206
219
  text: String(args.text || ''),
207
- buttons,
220
+ buttons: args.buttons,
221
+ options: args.options,
222
+ mode: args.mode,
223
+ selected: args.selected,
224
+ pageSize: args.pageSize,
208
225
  }, Number(args.timeoutMs) || 300_000)
209
- return { ok: true, buttonId: result?.buttonId, data: result?.data }
226
+ return { ok: true, buttonId: result?.buttonId, selected: result?.selected, data: result?.data }
210
227
  } catch (err) {
211
228
  return { ok: false, error: err instanceof Error ? err.message : String(err) }
212
229
  }
@@ -278,6 +295,45 @@ export function apply(ctx, config) {
278
295
  },
279
296
  }), 'dsh-messenger-gateway: messenger schema')
280
297
 
298
+ ctx.effect(() => ctx.webServer.register({
299
+ kind: 'exact', path: '/dsh-messenger-gateway/events',
300
+ handler: async (req, res) => {
301
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
302
+ const gw = getGw()
303
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not ready' })
304
+ let payload
305
+ try {
306
+ payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
307
+ } catch {
308
+ return writeJson(res, 400, { ok: false, error: 'invalid json' })
309
+ }
310
+
311
+ const expectedSecret = source().webhooks?.secret
312
+ if (expectedSecret) {
313
+ const authHeader = req.headers?.authorization || ''
314
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
315
+ const tokenHeader = req.headers?.['x-webhook-secret'] || ''
316
+ const provided = bearer || tokenHeader || payload.secret
317
+ if (provided !== expectedSecret) {
318
+ return writeJson(res, 401, { ok: false, error: 'unauthorized' })
319
+ }
320
+ }
321
+
322
+ const { target, text, home, chatId, threadId, platform = 'telegram', files } = payload
323
+ if (!text && (!files || !files.length)) {
324
+ return writeJson(res, 400, { ok: false, error: 'text or files required' })
325
+ }
326
+
327
+ const dest = target || { platform, chatId, threadId, home }
328
+ try {
329
+ const result = await gw.messengerSend(dest, { text, files })
330
+ writeJson(res, 200, { ok: true, sent: result })
331
+ } catch (err) {
332
+ writeJson(res, 500, { ok: false, error: err.message })
333
+ }
334
+ },
335
+ }), 'dsh-messenger-gateway: webhook events')
336
+
281
337
  ctx.effect(() => ctx.webServer.register({
282
338
  kind: 'exact', path: '/dsh-messenger-gateway/config',
283
339
  handler: async (req, res) => {
@@ -147,13 +147,31 @@ export function normalizeAskBody(payload) {
147
147
  err.status = 400
148
148
  throw err
149
149
  }
150
+ const mode = payload?.mode === 'multi' ? 'multi' : 'single'
151
+ if (mode === 'multi' || (Array.isArray(payload?.options) && payload.options.length > 0)) {
152
+ const rawOptions = Array.isArray(payload?.options) ? payload.options : (Array.isArray(payload?.buttons) ? payload.buttons : [])
153
+ if (!rawOptions.length || (Array.isArray(rawOptions[0]) && !rawOptions.some((r) => r.length > 0))) {
154
+ const err = new Error('ask requires at least one option')
155
+ err.status = 400
156
+ throw err
157
+ }
158
+ return {
159
+ text,
160
+ mode: 'multi',
161
+ options: rawOptions,
162
+ selected: Array.isArray(payload?.selected) ? payload.selected.map(String) : [],
163
+ pageSize: Number(payload?.pageSize) || 6,
164
+ doneText: payload?.doneText,
165
+ cancelText: payload?.cancelText,
166
+ }
167
+ }
150
168
  const buttons = normalizeButtons(payload?.buttons)
151
169
  if (!buttons.length || !buttons.some((row) => row.length > 0)) {
152
170
  const err = new Error('ask requires at least one button')
153
171
  err.status = 400
154
172
  throw err
155
173
  }
156
- return { text, buttons }
174
+ return { text, mode: 'single', buttons }
157
175
  }
158
176
 
159
177
  export function normalizeProgressBody(payload) {
@@ -0,0 +1,98 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ export const BUILTIN_PERSONAS = {
5
+ default: {
6
+ id: 'default',
7
+ name: 'Default',
8
+ icon: '🤖',
9
+ description: 'Стандартный универсальный ассистент',
10
+ instruction: '',
11
+ },
12
+ coder: {
13
+ id: 'coder',
14
+ name: 'Senior Developer',
15
+ icon: '💻',
16
+ description: 'Опытный разработчик: чистый код, архитектура, минимум лишних слов',
17
+ instruction: 'You are an expert senior software engineer. Provide high-quality, production-ready code with best practices, proper error handling, and concise explanations.',
18
+ },
19
+ architect: {
20
+ id: 'architect',
21
+ name: 'System Architect',
22
+ icon: '📐',
23
+ description: 'Системный архитектор: проектирование, масштабируемость, компромиссы',
24
+ instruction: 'You are a principal system architect. Focus on high-level architecture, scalability, security, trade-offs, modular design, and clear diagrams.',
25
+ },
26
+ reviewer: {
27
+ id: 'reviewer',
28
+ name: 'Code Reviewer',
29
+ icon: '🔍',
30
+ description: 'Строгий код-ревьюер: поиск багов, безопасность, краевые случаи',
31
+ instruction: 'You are a meticulous code reviewer. Analyze code for bugs, edge cases, security vulnerabilities, performance bottlenecks, and maintainability.',
32
+ },
33
+ writer: {
34
+ id: 'writer',
35
+ name: 'Tech Writer',
36
+ icon: '📝',
37
+ description: 'Технический писатель: понятные тексты, структура, документация',
38
+ instruction: 'You are a professional technical writer and editor. Structure information clearly with clean formatting, intuitive language, and thorough documentation.',
39
+ },
40
+ translator: {
41
+ id: 'translator',
42
+ name: 'Translator',
43
+ icon: '🌐',
44
+ description: 'Переводчик: точный перевод с сохранением терминологии',
45
+ instruction: 'You are an expert translator and localization specialist. Translate accurately while preserving technical context, nuance, and terminology.',
46
+ },
47
+ concise: {
48
+ id: 'concise',
49
+ name: 'Concise',
50
+ icon: '⚡',
51
+ description: 'Лаконичный режим: краткие и емкие ответы без воды',
52
+ instruction: 'Be extremely concise. Answer directly in 1-3 sentences or short bullet points without unnecessary filler or pleasantries.',
53
+ },
54
+ }
55
+
56
+ export function getPersona(id) {
57
+ if (!id) return BUILTIN_PERSONAS.default
58
+ const clean = String(id).toLowerCase().trim()
59
+ return BUILTIN_PERSONAS[clean] || null
60
+ }
61
+
62
+ export function listPersonas() {
63
+ return Object.values(BUILTIN_PERSONAS)
64
+ }
65
+
66
+ export function createPersonaStore(filePath) {
67
+ let cache = {}
68
+ try {
69
+ cache = JSON.parse(readFileSync(filePath, 'utf8'))
70
+ } catch {}
71
+
72
+ function save() {
73
+ try {
74
+ mkdirSync(dirname(filePath), { recursive: true })
75
+ writeFileSync(filePath, JSON.stringify(cache, null, 2), 'utf8')
76
+ } catch {}
77
+ }
78
+
79
+ return {
80
+ get(chatId) {
81
+ if (chatId === undefined || chatId === null) return 'default'
82
+ return cache[String(chatId)] || 'default'
83
+ },
84
+ set(chatId, personaId) {
85
+ if (chatId === undefined || chatId === null) return
86
+ const valid = getPersona(personaId)
87
+ if (valid && valid.id !== 'default') {
88
+ cache[String(chatId)] = valid.id
89
+ } else {
90
+ delete cache[String(chatId)]
91
+ }
92
+ save()
93
+ },
94
+ all() {
95
+ return { ...cache }
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,135 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ export function parseRelativeTime(input) {
6
+ if (!input) return null
7
+ const raw = String(input).trim().toLowerCase()
8
+ const match = /^(\d+)\s*(s|sec|сек|m|min|мин|h|hr|ч|час|d|day|д|день|дня|дней)?$/i.exec(raw)
9
+ if (!match) return null
10
+
11
+ const val = parseInt(match[1], 10)
12
+ if (!Number.isFinite(val) || val <= 0) return null
13
+
14
+ const unit = (match[2] || 'm').toLowerCase()
15
+ if (['s', 'sec', 'сек'].includes(unit)) return val * 1000
16
+ if (['m', 'min', 'мин'].includes(unit)) return val * 60 * 1000
17
+ if (['h', 'hr', 'ч', 'час'].includes(unit)) return val * 3600 * 1000
18
+ if (['d', 'day', 'д', 'день', 'дня', 'дней'].includes(unit)) return val * 86400 * 1000
19
+
20
+ return null
21
+ }
22
+
23
+ export function formatRemaining(ms) {
24
+ if (ms <= 0) return 'сейчас'
25
+ const sec = Math.ceil(ms / 1000)
26
+ if (sec < 60) return `${sec} сек`
27
+ const min = Math.ceil(sec / 60)
28
+ if (min < 60) return `${min} мин`
29
+ const hr = Math.floor(min / 60)
30
+ const remMin = min % 60
31
+ return remMin ? `${hr} ч ${remMin} мин` : `${hr} ч`
32
+ }
33
+
34
+ export function createScheduler(filePath, onDue) {
35
+ let tasks = []
36
+ let loaded = false
37
+ let timer = null
38
+
39
+ async function load() {
40
+ try {
41
+ const raw = await readFile(filePath, 'utf8')
42
+ tasks = JSON.parse(raw)
43
+ } catch {
44
+ tasks = []
45
+ }
46
+ loaded = true
47
+ }
48
+
49
+ async function save() {
50
+ try {
51
+ await mkdir(dirname(filePath), { recursive: true })
52
+ await writeFile(filePath, JSON.stringify(tasks, null, 2), 'utf8')
53
+ } catch {}
54
+ }
55
+
56
+ async function checkDue() {
57
+ if (!loaded) await load()
58
+ const now = Date.now()
59
+ const due = tasks.filter((t) => t.status === 'pending' && t.dueAt <= now)
60
+
61
+ for (const task of due) {
62
+ task.status = 'fired'
63
+ task.firedAt = now
64
+ if (typeof onDue === 'function') {
65
+ try { await onDue(task) } catch {}
66
+ }
67
+ }
68
+
69
+ if (due.length > 0) {
70
+ await save()
71
+ }
72
+ }
73
+
74
+ function start(intervalMs = 5000) {
75
+ if (timer) clearInterval(timer)
76
+ timer = setInterval(() => {
77
+ checkDue().catch(() => {})
78
+ }, intervalMs)
79
+ timer.unref?.()
80
+ checkDue().catch(() => {})
81
+ }
82
+
83
+ function stop() {
84
+ if (timer) {
85
+ clearInterval(timer)
86
+ timer = null
87
+ }
88
+ }
89
+
90
+ async function schedule(taskData) {
91
+ if (!loaded) await load()
92
+ const id = taskData.id || randomUUID().slice(0, 8)
93
+ const task = {
94
+ id,
95
+ platform: taskData.platform || 'telegram',
96
+ chatId: taskData.chatId,
97
+ threadId: taskData.threadId || 0,
98
+ userId: taskData.userId,
99
+ text: taskData.text,
100
+ dueAt: taskData.dueAt,
101
+ createdAt: Date.now(),
102
+ status: 'pending',
103
+ }
104
+ tasks.push(task)
105
+ await save()
106
+ return task
107
+ }
108
+
109
+ async function list(chatId) {
110
+ if (!loaded) await load()
111
+ const now = Date.now()
112
+ return tasks.filter((t) => (chatId ? t.chatId === chatId : true) && t.status === 'pending' && t.dueAt > now)
113
+ }
114
+
115
+ async function cancel(id, chatId) {
116
+ if (!loaded) await load()
117
+ const task = tasks.find((t) => t.id === id && (chatId ? t.chatId === chatId : true) && t.status === 'pending')
118
+ if (!task) return false
119
+ task.status = 'cancelled'
120
+ await save()
121
+ return true
122
+ }
123
+
124
+ return {
125
+ load,
126
+ save,
127
+ start,
128
+ stop,
129
+ schedule,
130
+ list,
131
+ cancel,
132
+ checkDue,
133
+ getTasks: () => tasks,
134
+ }
135
+ }
@@ -0,0 +1,95 @@
1
+ export function exportSessionToMarkdown(session, opts = {}) {
2
+ if (!session) return { filename: 'session-export.md', content: '# Empty session\n', buffer: Buffer.from('# Empty session\n') }
3
+
4
+ const id = session.id || 'unknown'
5
+ const dateStr = new Date().toISOString().replace(/[:.]/g, '-')
6
+ const filename = `session-${id}-${dateStr}.md`
7
+
8
+ const lines = [
9
+ `# Export of Session: ${id}`,
10
+ `*Generated on: ${new Date().toLocaleString()}*`,
11
+ '',
12
+ '---',
13
+ '',
14
+ ]
15
+
16
+ const messages = Array.isArray(session.messages)
17
+ ? session.messages
18
+ : (Array.isArray(session.history) ? session.history : [])
19
+
20
+ if (!messages.length) {
21
+ lines.push('*(No messages in this session)*\n')
22
+ }
23
+
24
+ for (const msg of messages) {
25
+ const role = msg.role || (msg.type === 'user' ? 'user' : 'assistant')
26
+ const time = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString() : ''
27
+ const timeStr = time ? ` (${time})` : ''
28
+
29
+ if (role === 'user') {
30
+ lines.push(`### 👤 User${timeStr}`)
31
+ } else if (role === 'assistant') {
32
+ lines.push(`### 🤖 Assistant${timeStr}`)
33
+ } else if (role === 'system') {
34
+ lines.push(`### ⚙️ System${timeStr}`)
35
+ } else {
36
+ lines.push(`### 💬 ${role}${timeStr}`)
37
+ }
38
+
39
+ let textContent = ''
40
+ if (typeof msg.content === 'string') {
41
+ textContent = msg.content
42
+ } else if (Array.isArray(msg.content)) {
43
+ textContent = msg.content
44
+ .map((part) => {
45
+ if (typeof part === 'string') return part
46
+ if (part?.text) return part.text
47
+ if (part?.type === 'text') return part.text
48
+ if (part?.type === 'tool_use' || part?.tool) {
49
+ return `\n> 🛠️ **Tool call:** \`${part.name || part.tool}\`\n`
50
+ }
51
+ return ''
52
+ })
53
+ .filter(Boolean)
54
+ .join('\n')
55
+ } else if (msg.text) {
56
+ textContent = msg.text
57
+ }
58
+
59
+ lines.push(textContent || '*(empty message)*')
60
+ lines.push('')
61
+ }
62
+
63
+ const content = lines.join('\n')
64
+ return {
65
+ filename,
66
+ content,
67
+ buffer: Buffer.from(content, 'utf8'),
68
+ messagesCount: messages.length,
69
+ }
70
+ }
71
+
72
+ export function rewindSession(session, turnsCount = 1) {
73
+ if (!session) return { removed: 0, remaining: 0 }
74
+ const count = Math.max(1, Number(turnsCount) || 1)
75
+ const messages = Array.isArray(session.messages) ? session.messages : []
76
+ if (!messages.length) return { removed: 0, remaining: 0 }
77
+
78
+ let toRemove = 0
79
+ let userTurnsSeen = 0
80
+
81
+ for (let i = messages.length - 1; i >= 0; i--) {
82
+ const msg = messages[i]
83
+ toRemove++
84
+ if (msg.role === 'user' || msg.type === 'user') {
85
+ userTurnsSeen++
86
+ if (userTurnsSeen >= count) break
87
+ }
88
+ }
89
+
90
+ const removed = messages.splice(messages.length - toRemove, toRemove)
91
+ return {
92
+ removed: removed.length,
93
+ remaining: messages.length,
94
+ }
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.3.1",
3
+ "version": "0.3.8",
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",
@@ -51,15 +51,15 @@
51
51
  }
52
52
  },
53
53
  "peerDependencies": {
54
- "@deepseek-ai/cordis": "^4.0.1",
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
- "@deepseek-ai/schemastery": "^3.18.1",
62
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
54
+ "@deepseek-ai/cordis": "^4.0.2",
55
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-agent-loop": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/schemastery": "^3.18.2",
62
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2"
63
63
  },
64
64
  "publishConfig": {
65
65
  "access": "public"