@goodandready/dsh-messenger-gateway 0.4.4 → 0.4.6

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.
@@ -191,6 +191,7 @@ export function resolveAskTimeoutMs(timeoutMs) {
191
191
  }
192
192
 
193
193
  export function httpStatusForError(err) {
194
+ if (err?.statusCode) return err.statusCode
194
195
  if (err?.status) return err.status
195
196
  const msg = String(err?.message || '')
196
197
  if (msg.includes('timed out')) return 504
package/lib/models.js CHANGED
@@ -1,13 +1,33 @@
1
1
  export const MODEL_PAGE_SIZE = 10
2
2
 
3
- export async function listModelCatalog(ctx, fallback = {}) {
3
+ let catalogCache = new WeakMap()
4
+
5
+ export function clearModelCatalogCache() {
6
+ catalogCache = new WeakMap()
7
+ }
8
+
9
+ export async function listModelCatalog(ctx, fallback = {}, options = {}) {
10
+ const llm = ctx?.get?.('llm') || ctx?.llm
11
+ const ttlMs = typeof options.ttlMs === 'number' ? options.ttlMs : 60_000
12
+ const bypass = Boolean(options.bypassCache)
13
+ const now = Date.now()
14
+
15
+ if (llm && typeof llm === 'object' && !bypass) {
16
+ const cached = catalogCache.get(llm)
17
+ if (cached && (now - cached.timestamp < ttlMs)) {
18
+ return {
19
+ providers: [...cached.providers],
20
+ modelsByProvider: new Map(cached.modelsByProvider),
21
+ }
22
+ }
23
+ }
24
+
4
25
  const result = {
5
26
  providers: [],
6
27
  modelsByProvider: new Map(),
7
28
  }
8
29
 
9
30
  // 1. Try ctx.llm if available
10
- const llm = ctx?.get?.('llm') || ctx?.llm
11
31
  if (llm?.listProviders) {
12
32
  try {
13
33
  const providers = await llm.listProviders()
@@ -25,6 +45,13 @@ export async function listModelCatalog(ctx, fallback = {}) {
25
45
  result.modelsByProvider.set(pId, [])
26
46
  }
27
47
  }
48
+ if (llm && typeof llm === 'object' && result.providers.length > 0) {
49
+ catalogCache.set(llm, {
50
+ timestamp: now,
51
+ providers: result.providers,
52
+ modelsByProvider: new Map(result.modelsByProvider),
53
+ })
54
+ }
28
55
  } catch (err) {
29
56
  /* safe fallback when ctx.llm.listProviders is unavailable or throws */
30
57
  }
package/lib/scheduler.js CHANGED
@@ -1,170 +1,170 @@
1
- import { readFile } from 'node:fs/promises'
2
- import { randomUUID } from 'node:crypto'
3
- import { writeJsonAtomic } from './storage-atomic.js'
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, locale = 'en') {
24
- const isZh = String(locale || '').toLowerCase().startsWith('zh')
25
- if (ms <= 0) return isZh ? '刚刚' : 'now'
26
- const sec = Math.ceil(ms / 1000)
27
- if (sec < 60) return isZh ? `${sec}秒` : `${sec}s`
28
- const min = Math.ceil(sec / 60)
29
- if (min < 60) return isZh ? `${min}分` : `${min}m`
30
- const hr = Math.floor(min / 60)
31
- const remMin = min % 60
32
- if (isZh) return remMin ? `${hr}小时 ${remMin}分` : `${hr}小时`
33
- return remMin ? `${hr}h ${remMin}m` : `${hr}h`
34
- }
35
-
36
- export function createScheduler(filePath, onDue, options = {}) {
37
- const logger = options?.logger || console
38
- let tasks = []
39
- let loaded = false
40
- let timer = null
41
-
42
- async function load() {
43
- try {
44
- const raw = await readFile(filePath, 'utf8')
45
- tasks = JSON.parse(raw)
46
- } catch (err) {
47
- if (err?.code !== 'ENOENT') {
48
- logger?.debug?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
49
- }
50
- tasks = []
51
- }
52
- loaded = true
53
- }
54
-
55
- const RETENTION_MS = 7 * 86400 * 1000
56
-
57
- async function save() {
58
- if (!filePath) return
59
- try {
60
- const now = Date.now()
61
- tasks = tasks.filter((t) => {
62
- if (t.status === 'pending') return true
63
- const finishTime = t.firedAt || t.createdAt || 0
64
- return (now - finishTime) < RETENTION_MS
65
- })
66
- await writeJsonAtomic(filePath, tasks)
67
- } catch (err) {
68
- logger?.warn?.('[dsh-messenger-gateway] scheduler save failed:', err?.message || err)
69
- }
70
- }
71
-
72
- async function checkDue() {
73
- if (!loaded) await load()
74
- const now = Date.now()
75
- const due = tasks.filter((t) => t.status === 'pending' && t.dueAt <= now)
76
-
77
- for (const task of due) {
78
- if (task.recurring && Number(task.intervalMs) > 0) {
79
- task.dueAt = now + Number(task.intervalMs)
80
- task.lastFiredAt = now
81
- } else {
82
- task.status = 'fired'
83
- task.firedAt = now
84
- }
85
- if (typeof onDue === 'function') {
86
- try {
87
- await onDue(task)
88
- } catch (err) {
89
- logger?.warn?.('[dsh-messenger-gateway] scheduler task execution failed:', err?.message || err)
90
- }
91
- }
92
- }
93
-
94
- if (due.length > 0) {
95
- await save()
96
- }
97
- }
98
-
99
- function start(intervalMs = 5000) {
100
- if (timer) clearInterval(timer)
101
- timer = setInterval(() => {
102
- checkDue().catch(() => {})
103
- }, intervalMs)
104
- timer.unref?.()
105
- checkDue().catch(() => {})
106
- }
107
-
108
- function stop() {
109
- if (timer) {
110
- clearInterval(timer)
111
- timer = null
112
- }
113
- }
114
-
115
- async function schedule(taskData) {
116
- if (!loaded) await load()
117
- const id = taskData.id || randomUUID().slice(0, 8)
118
- const task = {
119
- id,
120
- platform: taskData.platform || 'telegram',
121
- chatId: taskData.chatId,
122
- threadId: taskData.threadId || 0,
123
- userId: taskData.userId,
124
- text: taskData.text,
125
- prompt: taskData.prompt || taskData.text || '',
126
- dueAt: taskData.dueAt,
127
- createdAt: Date.now(),
128
- status: 'pending',
129
- recurring: Boolean(taskData.recurring),
130
- intervalMs: taskData.intervalMs || 0,
131
- cronExpr: taskData.cronExpr || '',
132
- }
133
- tasks.push(task)
134
- await save()
135
- return task
136
- }
137
-
138
- async function list(chatId) {
139
- if (!loaded) await load()
140
- const now = Date.now()
141
- return tasks.filter((t) => (chatId ? t.chatId === chatId : true) && t.status === 'pending' && t.dueAt > now)
142
- }
143
-
144
- async function listRecurring(chatId) {
145
- if (!loaded) await load()
146
- return tasks.filter((t) => (chatId ? t.chatId === chatId : true) && t.status === 'pending' && t.recurring)
147
- }
148
-
149
- async function cancel(id, chatId) {
150
- if (!loaded) await load()
151
- const task = tasks.find((t) => t.id === id && (chatId ? t.chatId === chatId : true) && t.status === 'pending')
152
- if (!task) return false
153
- task.status = 'cancelled'
154
- await save()
155
- return true
156
- }
157
-
158
- return {
159
- load,
160
- save,
161
- start,
162
- stop,
163
- schedule,
164
- list,
165
- listRecurring,
166
- cancel,
167
- checkDue,
168
- getTasks: () => tasks,
169
- }
1
+ import { readFile } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { writeJsonAtomic } from './storage-atomic.js'
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, locale = 'en') {
24
+ const isZh = String(locale || '').toLowerCase().startsWith('zh')
25
+ if (ms <= 0) return isZh ? '刚刚' : 'now'
26
+ const sec = Math.ceil(ms / 1000)
27
+ if (sec < 60) return isZh ? `${sec}秒` : `${sec}s`
28
+ const min = Math.ceil(sec / 60)
29
+ if (min < 60) return isZh ? `${min}分` : `${min}m`
30
+ const hr = Math.floor(min / 60)
31
+ const remMin = min % 60
32
+ if (isZh) return remMin ? `${hr}小时 ${remMin}分` : `${hr}小时`
33
+ return remMin ? `${hr}h ${remMin}m` : `${hr}h`
34
+ }
35
+
36
+ export function createScheduler(filePath, onDue, options = {}) {
37
+ const logger = options?.logger || console
38
+ let tasks = []
39
+ let loaded = false
40
+ let timer = null
41
+
42
+ async function load() {
43
+ try {
44
+ const raw = await readFile(filePath, 'utf8')
45
+ tasks = JSON.parse(raw)
46
+ } catch (err) {
47
+ if (err?.code !== 'ENOENT') {
48
+ logger?.debug?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
49
+ }
50
+ tasks = []
51
+ }
52
+ loaded = true
53
+ }
54
+
55
+ const RETENTION_MS = 7 * 86400 * 1000
56
+
57
+ async function save() {
58
+ if (!filePath) return
59
+ try {
60
+ const now = Date.now()
61
+ tasks = tasks.filter((t) => {
62
+ if (t.status === 'pending') return true
63
+ const finishTime = t.firedAt || t.createdAt || 0
64
+ return (now - finishTime) < RETENTION_MS
65
+ })
66
+ await writeJsonAtomic(filePath, tasks)
67
+ } catch (err) {
68
+ logger?.warn?.('[dsh-messenger-gateway] scheduler save failed:', err?.message || err)
69
+ }
70
+ }
71
+
72
+ async function checkDue() {
73
+ if (!loaded) await load()
74
+ const now = Date.now()
75
+ const due = tasks.filter((t) => t.status === 'pending' && t.dueAt <= now)
76
+
77
+ for (const task of due) {
78
+ if (task.recurring && Number(task.intervalMs) > 0) {
79
+ task.dueAt = now + Number(task.intervalMs)
80
+ task.lastFiredAt = now
81
+ } else {
82
+ task.status = 'fired'
83
+ task.firedAt = now
84
+ }
85
+ if (typeof onDue === 'function') {
86
+ try {
87
+ await onDue(task)
88
+ } catch (err) {
89
+ logger?.warn?.('[dsh-messenger-gateway] scheduler task execution failed:', err?.message || err)
90
+ }
91
+ }
92
+ }
93
+
94
+ if (due.length > 0) {
95
+ await save()
96
+ }
97
+ }
98
+
99
+ function start(intervalMs = 5000) {
100
+ if (timer) clearInterval(timer)
101
+ timer = setInterval(() => {
102
+ checkDue().catch((err) => { logger?.debug?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
103
+ }, intervalMs)
104
+ timer.unref?.()
105
+ checkDue().catch((err) => { logger?.debug?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
106
+ }
107
+
108
+ function stop() {
109
+ if (timer) {
110
+ clearInterval(timer)
111
+ timer = null
112
+ }
113
+ }
114
+
115
+ async function schedule(taskData) {
116
+ if (!loaded) await load()
117
+ const id = taskData.id || randomUUID().slice(0, 8)
118
+ const task = {
119
+ id,
120
+ platform: taskData.platform || 'telegram',
121
+ chatId: taskData.chatId,
122
+ threadId: taskData.threadId || 0,
123
+ userId: taskData.userId,
124
+ text: taskData.text,
125
+ prompt: taskData.prompt || taskData.text || '',
126
+ dueAt: taskData.dueAt,
127
+ createdAt: Date.now(),
128
+ status: 'pending',
129
+ recurring: Boolean(taskData.recurring),
130
+ intervalMs: taskData.intervalMs || 0,
131
+ cronExpr: taskData.cronExpr || '',
132
+ }
133
+ tasks.push(task)
134
+ await save()
135
+ return task
136
+ }
137
+
138
+ async function list(chatId) {
139
+ if (!loaded) await load()
140
+ const now = Date.now()
141
+ return tasks.filter((t) => (chatId ? t.chatId === chatId : true) && t.status === 'pending' && t.dueAt > now)
142
+ }
143
+
144
+ async function listRecurring(chatId) {
145
+ if (!loaded) await load()
146
+ return tasks.filter((t) => (chatId ? t.chatId === chatId : true) && t.status === 'pending' && t.recurring)
147
+ }
148
+
149
+ async function cancel(id, chatId) {
150
+ if (!loaded) await load()
151
+ const task = tasks.find((t) => t.id === id && (chatId ? t.chatId === chatId : true) && t.status === 'pending')
152
+ if (!task) return false
153
+ task.status = 'cancelled'
154
+ await save()
155
+ return true
156
+ }
157
+
158
+ return {
159
+ load,
160
+ save,
161
+ start,
162
+ stop,
163
+ schedule,
164
+ list,
165
+ listRecurring,
166
+ cancel,
167
+ checkDue,
168
+ getTasks: () => tasks,
169
+ }
170
170
  }
@@ -0,0 +1,111 @@
1
+ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfig, sync, logger }) {
2
+ let settingsApi
3
+ let source = () => resolveConfig(baseConfig ?? {})
4
+
5
+ const mountSettings = (sctx) => {
6
+ const service = sctx?.settings || (typeof sctx?.get === 'function' ? sctx.get('settings') : null)
7
+ const effect = typeof sctx?.effect === 'function' ? sctx.effect.bind(sctx) : (typeof ctx.effect === 'function' ? ctx.effect.bind(ctx) : null)
8
+ if (!service) return
9
+
10
+ if (typeof service.register === 'function') {
11
+ settingsApi = service.register(namespace, Config, { base: baseConfig || {} })
12
+ source = () => resolveConfig(settingsApi.get() ?? baseConfig ?? {})
13
+ if (effect) effect(() => settingsApi.watch(sync), `${namespace}: settings`)
14
+ return
15
+ }
16
+
17
+ if (typeof service.configure === 'function') {
18
+ try {
19
+ const dispose = service.configure({ auto: false }, ctx.fiber)
20
+ if (effect && typeof dispose === 'function') effect(() => dispose, `${namespace}: configure`)
21
+ } catch (err) {
22
+ logger?.warn?.(`${namespace}: settings configure failed: ${err.message}`)
23
+ }
24
+ }
25
+
26
+ const getRevision = () => {
27
+ try {
28
+ return service.describe?.()?.find?.((row) => row.ns === namespace)?.revision
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ const readLiveSettings = () => {
35
+ try {
36
+ if (typeof service.describe === 'function') {
37
+ const desc = service.describe()?.find?.((row) => row.ns === namespace)
38
+ if (desc?.value && typeof desc.value === 'object') return desc.value
39
+ }
40
+ if (typeof service.get === 'function') {
41
+ const val = service.get(namespace)
42
+ if (val && typeof val === 'object') return val
43
+ }
44
+ } catch (err) {
45
+ logger?.warn?.(`${namespace}: readLiveSettings failed: ${err.message}`)
46
+ }
47
+ return null
48
+ }
49
+
50
+ const liveVal = readLiveSettings()
51
+ let currentConfig = resolveConfig(liveVal ?? baseConfig ?? {})
52
+ source = () => currentConfig
53
+
54
+ const persist = async (next) => {
55
+ const payload = Config(next)
56
+ if (typeof service.replace === 'function') {
57
+ await service.replace(namespace, payload, getRevision())
58
+ } else if (typeof service.update === 'function') {
59
+ await service.update(namespace, payload, getRevision())
60
+ } else if (typeof service.mutate === 'function') {
61
+ await service.mutate(namespace, [{ op: 'set', path: [], value: payload }])
62
+ } else {
63
+ throw new Error('settings service cannot persist configuration')
64
+ }
65
+ currentConfig = resolveConfig(payload)
66
+ sync()
67
+ return currentConfig
68
+ }
69
+
70
+ settingsApi = {
71
+ get: () => source(),
72
+ replace: async (next) => persist(next),
73
+ update: async (patch) => persist({ ...source(), ...patch }),
74
+ watch: (cb) => {
75
+ if (typeof service.watch === 'function') return service.watch(cb)
76
+ return () => {}
77
+ },
78
+ }
79
+
80
+ const onFn = typeof sctx?.on === 'function' ? sctx.on.bind(sctx) : (typeof ctx?.on === 'function' ? ctx.on.bind(ctx) : null)
81
+ const disposeDocUpdated = onFn
82
+ ? onFn('settings/document-updated', (updatedNs) => {
83
+ if (updatedNs === namespace) {
84
+ const nextVal = readLiveSettings()
85
+ if (nextVal) {
86
+ currentConfig = resolveConfig(nextVal)
87
+ sync()
88
+ }
89
+ }
90
+ })
91
+ : null
92
+
93
+ if (effect) {
94
+ effect(() => () => {
95
+ if (typeof disposeDocUpdated === 'function') disposeDocUpdated()
96
+ settingsApi = undefined
97
+ }, `${namespace}: settings`)
98
+ }
99
+ }
100
+
101
+ if (typeof ctx.inject === 'function') {
102
+ ctx.inject(['settings'], mountSettings)
103
+ } else {
104
+ mountSettings({ settings: ctx.get?.('settings') || ctx.settings, effect: ctx.effect })
105
+ }
106
+
107
+ return {
108
+ getSource: () => source(),
109
+ getSettingsApi: () => settingsApi,
110
+ }
111
+ }
package/lib/stream.js CHANGED
@@ -34,7 +34,7 @@ export function parseRetryAfter(err) {
34
34
  return 0
35
35
  }
36
36
 
37
- export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
37
+ export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250, logger = null) {
38
38
  let pending = null
39
39
  let timer = null
40
40
  let lastSent = ''
@@ -55,6 +55,7 @@ export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs
55
55
  await editFn(text)
56
56
  lastSent = text
57
57
  } catch (err) {
58
+ logger?.debug?.('stream chunk edit failed:', err?.message || err)
58
59
  const retry = parseRetryAfter(err)
59
60
  if (retry > 0) {
60
61
  retryDelayMs = retry
@@ -91,20 +92,22 @@ export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs
91
92
  try {
92
93
  await editFn(pending)
93
94
  lastSent = pending
94
- } catch { /* safe best-effort final stream update delivery */ }
95
+ } catch (err) {
96
+ logger?.debug?.('stream final flush error:', err?.message || err)
97
+ }
95
98
  }
96
99
  },
97
100
  }
98
101
  }
99
102
 
100
- export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
103
+ export function startTypingHeartbeat(typingFn, intervalMs = 4000, logger = null) {
101
104
  if (typeof typingFn !== 'function') return () => {}
102
105
  let active = true
103
106
  const safeInvoke = () => {
104
107
  if (!active) return
105
108
  try {
106
- typingFn()?.catch?.(() => { /* safe best-effort typing indicator */ })
107
- } catch { /* safe best-effort typing indicator */ }
109
+ typingFn()?.catch?.((err) => { logger?.debug?.('typing indicator error:', err?.message || err) })
110
+ } catch (err) { logger?.debug?.('typing indicator invoke error:', err?.message || err) }
108
111
  }
109
112
  safeInvoke()
110
113
  const timer = setInterval(safeInvoke, intervalMs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",
@@ -15,6 +15,8 @@
15
15
  "lib/",
16
16
  "cordis.patch.yml",
17
17
  "README.md",
18
+ "README.ru.md",
19
+ "README.zh.md",
18
20
  "LICENSE"
19
21
  ],
20
22
  "keywords": [