@goodandready/dsh-messenger-gateway 0.3.2 → 0.3.9
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 +33 -2
- package/README.ru.md +125 -0
- package/README.zh.md +76 -0
- package/lib/adapters/discord.js +129 -4
- package/lib/adapters/index.js +17 -1
- package/lib/adapters/slack.js +91 -0
- package/lib/adapters/telegram.js +35 -2
- package/lib/alerts.js +64 -0
- package/lib/artifacts.js +118 -0
- package/lib/ask.js +89 -0
- package/lib/client.js +12 -0
- package/lib/commands.js +15 -1
- package/lib/config.js +21 -1
- package/lib/documents.js +145 -3
- package/lib/file-manager.js +162 -0
- package/lib/gateway.js +589 -17
- package/lib/index.js +65 -8
- package/lib/messenger-api.js +19 -1
- package/lib/models.js +106 -0
- package/lib/personas.js +98 -0
- package/lib/scheduler.js +135 -0
- package/lib/session-ops.js +95 -0
- package/lib/tts.js +20 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -52,6 +52,8 @@ function publicConfig(cfg) {
|
|
|
52
52
|
webhookSecretConfigured: Boolean(String(cfg.telegram.webhookSecret || '').trim()),
|
|
53
53
|
botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
|
|
54
54
|
voiceMode: cfg.telegram.voiceMode,
|
|
55
|
+
quickActions: cfg.telegram.quickActions !== false,
|
|
56
|
+
artifactPreviews: cfg.telegram.artifactPreviews !== false,
|
|
55
57
|
notifyBridge: {
|
|
56
58
|
enabled: Boolean(cfg.telegram.notifyBridge?.enabled),
|
|
57
59
|
events: cfg.telegram.notifyBridge?.events || ['task_done', 'error'],
|
|
@@ -167,15 +169,27 @@ export function apply(ctx, config) {
|
|
|
167
169
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
168
170
|
name: 'messenger_ask',
|
|
169
171
|
description:
|
|
170
|
-
'Ask the Telegram user a multiple-choice question with inline buttons and wait for their choice. '
|
|
171
|
-
+ '
|
|
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-*).',
|
|
172
174
|
parameters: {
|
|
173
175
|
text: { type: 'string', required: true, description: 'Question text shown in Telegram.' },
|
|
174
176
|
buttons: {
|
|
175
177
|
type: 'array',
|
|
176
|
-
|
|
177
|
-
description: 'Rows of buttons: [[{ id, text }, ...], ...]',
|
|
178
|
+
description: 'Rows of buttons: [[{ id, text }, ...], ...] (for single choice)',
|
|
178
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).' },
|
|
179
193
|
timeoutMs: { type: 'number', description: 'Wait timeout ms (default 300000).' },
|
|
180
194
|
},
|
|
181
195
|
output: {
|
|
@@ -185,6 +199,7 @@ export function apply(ctx, config) {
|
|
|
185
199
|
properties: {
|
|
186
200
|
ok: { type: 'boolean' },
|
|
187
201
|
buttonId: { type: 'string' },
|
|
202
|
+
selected: { type: 'array', items: { type: 'string' } },
|
|
188
203
|
data: { type: 'string' },
|
|
189
204
|
error: { type: 'string' },
|
|
190
205
|
},
|
|
@@ -192,7 +207,7 @@ export function apply(ctx, config) {
|
|
|
192
207
|
render: (_args, value) => [{
|
|
193
208
|
type: 'text',
|
|
194
209
|
text: value && value.ok
|
|
195
|
-
? `User chose: ${value.buttonId}`
|
|
210
|
+
? (value.selected?.length ? `User selected: ${value.selected.join(', ')}` : `User chose: ${value.buttonId}`)
|
|
196
211
|
: `messenger_ask failed: ${value && value.error ? value.error : 'unknown'}`,
|
|
197
212
|
}],
|
|
198
213
|
},
|
|
@@ -200,12 +215,15 @@ export function apply(ctx, config) {
|
|
|
200
215
|
const gw = getGw()
|
|
201
216
|
if (!gw) return { ok: false, error: 'gateway not running' }
|
|
202
217
|
try {
|
|
203
|
-
const buttons = args.buttons
|
|
204
218
|
const result = await gw.messengerAskFromAgent(exec.agent, {
|
|
205
219
|
text: String(args.text || ''),
|
|
206
|
-
buttons,
|
|
220
|
+
buttons: args.buttons,
|
|
221
|
+
options: args.options,
|
|
222
|
+
mode: args.mode,
|
|
223
|
+
selected: args.selected,
|
|
224
|
+
pageSize: args.pageSize,
|
|
207
225
|
}, Number(args.timeoutMs) || 300_000)
|
|
208
|
-
return { ok: true, buttonId: result?.buttonId, data: result?.data }
|
|
226
|
+
return { ok: true, buttonId: result?.buttonId, selected: result?.selected, data: result?.data }
|
|
209
227
|
} catch (err) {
|
|
210
228
|
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
211
229
|
}
|
|
@@ -277,6 +295,45 @@ export function apply(ctx, config) {
|
|
|
277
295
|
},
|
|
278
296
|
}), 'dsh-messenger-gateway: messenger schema')
|
|
279
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
|
+
|
|
280
337
|
ctx.effect(() => ctx.webServer.register({
|
|
281
338
|
kind: 'exact', path: '/dsh-messenger-gateway/config',
|
|
282
339
|
handler: async (req, res) => {
|
package/lib/messenger-api.js
CHANGED
|
@@ -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) {
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const MODEL_PAGE_SIZE = 10
|
|
2
|
+
|
|
3
|
+
export async function listModelCatalog(ctx, fallback = {}) {
|
|
4
|
+
const result = {
|
|
5
|
+
providers: [],
|
|
6
|
+
modelsByProvider: new Map(),
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 1. Try ctx.llm if available
|
|
10
|
+
if (ctx?.llm?.listProviders) {
|
|
11
|
+
try {
|
|
12
|
+
const providers = await ctx.llm.listProviders()
|
|
13
|
+
for (const p of providers || []) {
|
|
14
|
+
const pId = p.id || p
|
|
15
|
+
result.providers.push({ id: pId, name: p.name || pId })
|
|
16
|
+
try {
|
|
17
|
+
const models = await ctx.llm.listModels(pId)
|
|
18
|
+
result.modelsByProvider.set(
|
|
19
|
+
pId,
|
|
20
|
+
(models || []).map((m) => ({ id: m.id || m, name: m.name || m.id || m }))
|
|
21
|
+
)
|
|
22
|
+
} catch {
|
|
23
|
+
result.modelsByProvider.set(pId, [])
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. If no providers from ctx.llm, check settings / fallback
|
|
30
|
+
if (!result.providers.length && fallback.provider) {
|
|
31
|
+
result.providers.push({ id: fallback.provider, name: fallback.provider })
|
|
32
|
+
if (fallback.model) {
|
|
33
|
+
result.modelsByProvider.set(fallback.provider, [{ id: fallback.model, name: fallback.model }])
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// In-memory registry for model picker callback tokens to avoid 64-byte Telegram limit
|
|
41
|
+
const modelIndexStore = new Map()
|
|
42
|
+
let modelIndexCounter = 0
|
|
43
|
+
|
|
44
|
+
export function storeModelSelection(provider, model) {
|
|
45
|
+
const key = String(++modelIndexCounter)
|
|
46
|
+
modelIndexStore.set(key, { provider, model, time: Date.now() })
|
|
47
|
+
// Cleanup entries older than 1 hour
|
|
48
|
+
if (modelIndexStore.size > 200) {
|
|
49
|
+
const cutoff = Date.now() - 3600000
|
|
50
|
+
for (const [k, v] of modelIndexStore.entries()) {
|
|
51
|
+
if (v.time < cutoff) modelIndexStore.delete(k)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return key
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getStoredModelSelection(key) {
|
|
58
|
+
return modelIndexStore.get(key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildProvidersKeyboard(providers, current = {}) {
|
|
62
|
+
const rows = []
|
|
63
|
+
for (const p of providers) {
|
|
64
|
+
const isCurrent = p.id === current.provider
|
|
65
|
+
rows.push([{
|
|
66
|
+
text: `${isCurrent ? '✅ ' : '🔹 '}${p.name || p.id}`,
|
|
67
|
+
callback_data: `mdl:p:${p.id}`,
|
|
68
|
+
}])
|
|
69
|
+
}
|
|
70
|
+
return { inline_keyboard: rows }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildModelsKeyboard(providerId, models, currentModel, page = 0, pageSize = MODEL_PAGE_SIZE) {
|
|
74
|
+
const totalPages = Math.ceil(models.length / pageSize) || 1
|
|
75
|
+
const curPage = Math.max(0, Math.min(page, totalPages - 1))
|
|
76
|
+
const start = curPage * pageSize
|
|
77
|
+
const pageModels = models.slice(start, start + pageSize)
|
|
78
|
+
|
|
79
|
+
const rows = []
|
|
80
|
+
for (const m of pageModels) {
|
|
81
|
+
const isCurrent = m.id === currentModel
|
|
82
|
+
const key = storeModelSelection(providerId, m.id)
|
|
83
|
+
rows.push([{
|
|
84
|
+
text: `${isCurrent ? '✅ ' : ''}${m.name || m.id}`,
|
|
85
|
+
callback_data: `mdl:s:${key}`,
|
|
86
|
+
}])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Navigation row
|
|
90
|
+
const navRow = []
|
|
91
|
+
if (curPage > 0) {
|
|
92
|
+
navRow.push({ text: '⬅️', callback_data: `mdl:pg:${providerId}:${curPage - 1}` })
|
|
93
|
+
}
|
|
94
|
+
if (totalPages > 1) {
|
|
95
|
+
navRow.push({ text: `${curPage + 1}/${totalPages}`, callback_data: `mdl:cur` })
|
|
96
|
+
}
|
|
97
|
+
if (curPage < totalPages - 1) {
|
|
98
|
+
navRow.push({ text: '➡️', callback_data: `mdl:pg:${providerId}:${curPage + 1}` })
|
|
99
|
+
}
|
|
100
|
+
if (navRow.length) rows.push(navRow)
|
|
101
|
+
|
|
102
|
+
// Back row
|
|
103
|
+
rows.push([{ text: '🔙 Назад к провайдерам', callback_data: 'mdl:back' }])
|
|
104
|
+
|
|
105
|
+
return { inline_keyboard: rows, page: curPage, totalPages }
|
|
106
|
+
}
|
package/lib/personas.js
ADDED
|
@@ -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
|
+
}
|
package/lib/scheduler.js
ADDED
|
@@ -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/lib/tts.js
CHANGED
|
@@ -19,8 +19,26 @@ export function stripMarkdownForSpeech(text) {
|
|
|
19
19
|
.trim()
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function
|
|
23
|
-
const stripped = stripMarkdownForSpeech(
|
|
22
|
+
export function prepareVoiceSummary(text, maxChars = 300) {
|
|
23
|
+
const stripped = stripMarkdownForSpeech(text)
|
|
24
|
+
if (!stripped) return ''
|
|
25
|
+
const sentences = stripped.split(/(?<=[.!?])\s+/)
|
|
26
|
+
let summary = ''
|
|
27
|
+
for (const s of sentences) {
|
|
28
|
+
if ((summary + ' ' + s).trim().length <= maxChars) {
|
|
29
|
+
summary = (summary ? summary + ' ' + s : s).trim()
|
|
30
|
+
} else {
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!summary) summary = stripped.slice(0, maxChars).trim()
|
|
35
|
+
return summary
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function prepareTtsText(answer, maxChars = DEFAULT_TTS_MAX_CHARS, opts = {}) {
|
|
39
|
+
const isSummary = opts.voiceSummary === true
|
|
40
|
+
const source = isSummary ? prepareVoiceSummary(answer, Math.min(Number(maxChars) || 300, 400)) : answer
|
|
41
|
+
const stripped = stripMarkdownForSpeech(source)
|
|
24
42
|
if (!stripped || stripped.length < 2) return ''
|
|
25
43
|
const limit = Math.max(1, Number(maxChars) || DEFAULT_TTS_MAX_CHARS)
|
|
26
44
|
return stripped.slice(0, limit)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
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",
|