@goodandready/dsh-messenger-gateway 0.4.5 → 0.4.7
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/gateway-turn.js +288 -288
- package/lib/gateway.js +8 -8
- package/lib/http.js +73 -10
- package/lib/index.js +87 -46
- package/lib/messenger-api.js +1 -0
- package/lib/scheduler.js +169 -169
- package/lib/settings-bridge.js +111 -0
- package/lib/stream.js +8 -5
- package/lib/telegram-errors.js +0 -0
- package/package.json +10 -8
package/lib/http.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
export async function readBody(req) {
|
|
3
|
+
export async function readBody(req, maxBytes = 1_048_576) {
|
|
4
|
+
const cl = Number(req?.headers?.['content-length'])
|
|
5
|
+
if (Number.isFinite(cl) && cl > maxBytes) {
|
|
6
|
+
const err = new Error(`Payload Too Large: content-length ${cl} exceeds ${maxBytes}`)
|
|
7
|
+
err.status = 413
|
|
8
|
+
err.statusCode = 413
|
|
9
|
+
throw err
|
|
10
|
+
}
|
|
4
11
|
const chunks = []
|
|
5
|
-
|
|
12
|
+
let total = 0
|
|
13
|
+
for await (const chunk of req) {
|
|
14
|
+
total += chunk.length
|
|
15
|
+
if (total > maxBytes) {
|
|
16
|
+
const err = new Error(`Payload Too Large: exceeded ${maxBytes} bytes`)
|
|
17
|
+
err.status = 413
|
|
18
|
+
err.statusCode = 413
|
|
19
|
+
throw err
|
|
20
|
+
}
|
|
21
|
+
chunks.push(chunk)
|
|
22
|
+
}
|
|
6
23
|
return Buffer.concat(chunks)
|
|
7
24
|
}
|
|
8
25
|
|
|
@@ -12,21 +29,67 @@ export function writeJson(res, status, body) {
|
|
|
12
29
|
res.end(JSON.stringify(body))
|
|
13
30
|
}
|
|
14
31
|
|
|
32
|
+
export function isLoopback(value) {
|
|
33
|
+
const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
34
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
35
|
+
|| address.startsWith('127.')
|
|
36
|
+
|| address.startsWith('::ffff:127.')
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export function isTrustedSettingsRequest(req) {
|
|
16
|
-
const
|
|
40
|
+
const remote = req?.socket?.remoteAddress
|
|
41
|
+
const host = String(req?.headers?.host || '')
|
|
17
42
|
if (!host) return false
|
|
18
43
|
|
|
19
|
-
const
|
|
44
|
+
const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
45
|
+
if (site === 'cross-site') return false
|
|
46
|
+
|
|
47
|
+
const origin = String(req?.headers?.origin || '')
|
|
20
48
|
if (origin) {
|
|
21
|
-
try {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(origin)
|
|
51
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
52
|
+
return url.host.toLowerCase() === host.toLowerCase()
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
22
56
|
}
|
|
23
57
|
|
|
24
|
-
const
|
|
25
|
-
if (secFetchSite === 'same-origin') return true
|
|
26
|
-
|
|
27
|
-
const referer = String(req.headers?.referer || '')
|
|
58
|
+
const referer = String(req?.headers?.referer || '')
|
|
28
59
|
if (referer) {
|
|
29
|
-
try {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(referer)
|
|
62
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
63
|
+
return url.host.toLowerCase() === host.toLowerCase()
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (site === 'same-origin') {
|
|
70
|
+
if (remote !== undefined && !isLoopback(remote)) return false
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return isLoopback(remote)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isAuthorizedMessengerRequest(req, webhooksConfig) {
|
|
78
|
+
const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
79
|
+
if (site === 'cross-site') return false
|
|
80
|
+
|
|
81
|
+
if (isTrustedSettingsRequest(req)) return true
|
|
82
|
+
if (isLoopback(req?.socket?.remoteAddress)) return true
|
|
83
|
+
|
|
84
|
+
const secret = String(webhooksConfig?.secret || '').trim()
|
|
85
|
+
if (secret) {
|
|
86
|
+
const authHeader = req?.headers?.authorization || ''
|
|
87
|
+
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
|
|
88
|
+
const tokenHeader = req?.headers?.['x-webhook-secret'] || ''
|
|
89
|
+
const provided = bearer || tokenHeader
|
|
90
|
+
if (provided && timingSafeCompare(provided, secret)) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
30
93
|
}
|
|
31
94
|
|
|
32
95
|
return false
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname, resolve } from 'node:path'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
5
|
import { Gateway } from './gateway.js'
|
|
6
|
-
import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare } from './http.js'
|
|
6
|
+
import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare, isAuthorizedMessengerRequest } from './http.js'
|
|
7
7
|
import { registerPluginUpdater } from './updater.js'
|
|
8
8
|
import {
|
|
9
9
|
createMessengerService, dispatchMessenger, httpStatusForError,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from './messenger-api.js'
|
|
12
12
|
import { listHomes } from './homes.js'
|
|
13
13
|
import { Config } from './config.js'
|
|
14
|
+
import { setupSettings } from './settings-bridge.js'
|
|
14
15
|
|
|
15
16
|
export const name = '@goodandready/dsh-messenger-gateway'
|
|
16
17
|
export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
|
|
@@ -90,14 +91,32 @@ function publicConfig(cfg) {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function
|
|
94
|
+
async function readJsonSafe(req, res, maxBytes = 1_048_576) {
|
|
95
|
+
try {
|
|
96
|
+
const raw = await readBody(req, maxBytes)
|
|
97
|
+
const text = raw.toString('utf8').trim()
|
|
98
|
+
return { ok: true, data: text ? JSON.parse(text) : {} }
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err.statusCode === 413 || err.status === 413) {
|
|
101
|
+
writeJson(res, 413, { ok: false, error: 'payload too large' })
|
|
102
|
+
return { ok: false }
|
|
103
|
+
}
|
|
104
|
+
writeJson(res, 400, { ok: false, error: 'invalid json' })
|
|
105
|
+
return { ok: false }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function registerMessengerRoute(ctx, getGw, path, action, source) {
|
|
94
110
|
ctx.effect(() => (ctx.get?.('webServer') || ctx.webServer).register({
|
|
95
111
|
kind: 'exact', path,
|
|
96
112
|
handler: async (req, res) => {
|
|
97
113
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
114
|
+
if (!isAuthorizedMessengerRequest(req, source?.()?.webhooks)) {
|
|
115
|
+
return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
116
|
+
}
|
|
98
117
|
let payload
|
|
99
118
|
try {
|
|
100
|
-
payload = parseMessengerBody((await readBody(req)).toString('utf8'))
|
|
119
|
+
payload = parseMessengerBody((await readBody(req, 1_048_576)).toString('utf8'))
|
|
101
120
|
} catch (err) {
|
|
102
121
|
return writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
|
|
103
122
|
}
|
|
@@ -133,7 +152,7 @@ export function apply(ctx, config) {
|
|
|
133
152
|
if (effective.enabled === false) return
|
|
134
153
|
gateway = new Gateway(ctx, effective, {
|
|
135
154
|
persistAllowedUserIds: async (ids) => {
|
|
136
|
-
if (!settingsApi) return
|
|
155
|
+
if (!settingsApi?.isReady?.()) return
|
|
137
156
|
const cur = source()
|
|
138
157
|
await settingsApi.replace(Config({
|
|
139
158
|
...cur,
|
|
@@ -141,7 +160,7 @@ export function apply(ctx, config) {
|
|
|
141
160
|
}))
|
|
142
161
|
},
|
|
143
162
|
persistHome: async ({ chatId, threadId }) => {
|
|
144
|
-
if (!settingsApi) throw new Error('settings not ready')
|
|
163
|
+
if (!settingsApi?.isReady?.()) throw new Error('settings not ready')
|
|
145
164
|
const cur = source()
|
|
146
165
|
await settingsApi.replace(Config({
|
|
147
166
|
...cur,
|
|
@@ -149,7 +168,7 @@ export function apply(ctx, config) {
|
|
|
149
168
|
}))
|
|
150
169
|
},
|
|
151
170
|
persistHomes: async (nextTelegram) => {
|
|
152
|
-
if (!settingsApi) throw new Error('settings not ready')
|
|
171
|
+
if (!settingsApi?.isReady?.()) throw new Error('settings not ready')
|
|
153
172
|
const cur = source()
|
|
154
173
|
await settingsApi.replace(Config({
|
|
155
174
|
...cur,
|
|
@@ -157,7 +176,7 @@ export function apply(ctx, config) {
|
|
|
157
176
|
}))
|
|
158
177
|
},
|
|
159
178
|
persistAgentModel: async ({ provider, model }) => {
|
|
160
|
-
if (!settingsApi) return
|
|
179
|
+
if (!settingsApi?.isReady?.()) return
|
|
161
180
|
const cur = source()
|
|
162
181
|
await settingsApi.replace(Config({
|
|
163
182
|
...cur,
|
|
@@ -168,11 +187,28 @@ export function apply(ctx, config) {
|
|
|
168
187
|
gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
const settingsBridge = setupSettings(ctx, {
|
|
191
|
+
namespace: SETTINGS_NAMESPACE,
|
|
192
|
+
Config,
|
|
193
|
+
resolveConfig,
|
|
194
|
+
baseConfig: config,
|
|
195
|
+
sync,
|
|
196
|
+
logger: ctx.logger,
|
|
197
|
+
})
|
|
198
|
+
source = () => settingsBridge.getSource()
|
|
199
|
+
settingsApi = {
|
|
200
|
+
get: () => settingsBridge.getSettingsApi()?.get(),
|
|
201
|
+
replace: (next) => {
|
|
202
|
+
const api = settingsBridge.getSettingsApi()
|
|
203
|
+
if (!api) throw new Error('settings not ready')
|
|
204
|
+
return api.replace(next)
|
|
205
|
+
},
|
|
206
|
+
update: (patch) => {
|
|
207
|
+
const api = settingsBridge.getSettingsApi()
|
|
208
|
+
if (!api) throw new Error('settings not ready')
|
|
209
|
+
return api.update(patch)
|
|
210
|
+
},
|
|
211
|
+
isReady: () => Boolean(settingsBridge.getSettingsApi()),
|
|
176
212
|
}
|
|
177
213
|
|
|
178
214
|
const getGw = () => gateway
|
|
@@ -251,7 +287,7 @@ export function apply(ctx, config) {
|
|
|
251
287
|
ctx.effect(() => {
|
|
252
288
|
const onSkillsChange = () => {
|
|
253
289
|
const gw = getGw()
|
|
254
|
-
if (gw) gw.syncTelegramCommands().catch(() => {})
|
|
290
|
+
if (gw) gw.syncTelegramCommands().catch((err) => ctx.logger?.debug?.(`sync telegram commands on skills change: ${err?.message || err}`))
|
|
255
291
|
}
|
|
256
292
|
const unbind = ctx.on('skills/change', onSkillsChange)
|
|
257
293
|
return () => { if (typeof unbind === 'function') unbind() }
|
|
@@ -344,14 +380,9 @@ export function apply(ctx, config) {
|
|
|
344
380
|
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
345
381
|
const gw = getGw()
|
|
346
382
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (raw) payload = JSON.parse(raw)
|
|
351
|
-
} catch {
|
|
352
|
-
return writeJson(res, 400, { ok: false, error: 'invalid json' })
|
|
353
|
-
}
|
|
354
|
-
const timeoutMs = Number(payload.timeoutMs) || 10000
|
|
383
|
+
const parsed = await readJsonSafe(req, res, 65536)
|
|
384
|
+
if (!parsed.ok) return
|
|
385
|
+
const timeoutMs = Number(parsed.data?.timeoutMs) || 10000
|
|
355
386
|
const result = await gw.probeTelegram(timeoutMs)
|
|
356
387
|
writeJson(res, result.ok ? 200 : 502, result)
|
|
357
388
|
},
|
|
@@ -371,12 +402,6 @@ export function apply(ctx, config) {
|
|
|
371
402
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
372
403
|
const gw = getGw()
|
|
373
404
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not ready' })
|
|
374
|
-
let payload
|
|
375
|
-
try {
|
|
376
|
-
payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
|
|
377
|
-
} catch {
|
|
378
|
-
return writeJson(res, 400, { ok: false, error: 'invalid json' })
|
|
379
|
-
}
|
|
380
405
|
|
|
381
406
|
if (source().webhooks?.enabled === false) {
|
|
382
407
|
return writeJson(res, 404, { ok: false, error: 'webhooks disabled' })
|
|
@@ -385,12 +410,28 @@ export function apply(ctx, config) {
|
|
|
385
410
|
if (!expectedSecret) {
|
|
386
411
|
return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
|
|
387
412
|
}
|
|
413
|
+
|
|
414
|
+
// Check header authentication BEFORE reading the body to avoid unbounded buffering
|
|
388
415
|
const authHeader = req.headers?.authorization || ''
|
|
389
416
|
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
|
|
390
417
|
const tokenHeader = req.headers?.['x-webhook-secret'] || ''
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
418
|
+
const headerProvided = bearer || tokenHeader
|
|
419
|
+
|
|
420
|
+
if (headerProvided) {
|
|
421
|
+
if (!timingSafeCompare(headerProvided, expectedSecret)) {
|
|
422
|
+
return writeJson(res, 401, { ok: false, error: 'unauthorized' })
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const parsed = await readJsonSafe(req, res, 1_048_576)
|
|
427
|
+
if (!parsed.ok) return
|
|
428
|
+
const payload = parsed.data
|
|
429
|
+
|
|
430
|
+
if (!headerProvided) {
|
|
431
|
+
const bodySecret = String(payload.secret || '').trim()
|
|
432
|
+
if (!timingSafeCompare(bodySecret, expectedSecret)) {
|
|
433
|
+
return writeJson(res, 401, { ok: false, error: 'unauthorized' })
|
|
434
|
+
}
|
|
394
435
|
}
|
|
395
436
|
|
|
396
437
|
const { target, text, home, chatId, threadId, platform = 'telegram', files } = payload
|
|
@@ -414,9 +455,10 @@ export function apply(ctx, config) {
|
|
|
414
455
|
if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
|
|
415
456
|
if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
|
|
416
457
|
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
417
|
-
if (!settingsApi) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
|
|
418
|
-
|
|
419
|
-
|
|
458
|
+
if (!settingsApi?.isReady?.()) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
|
|
459
|
+
const parsedConfig = await readJsonSafe(req, res, 1_048_576)
|
|
460
|
+
if (!parsedConfig.ok) return
|
|
461
|
+
let payload = parsedConfig.data
|
|
420
462
|
if (payload && typeof payload.config === 'object') payload = payload.config
|
|
421
463
|
try {
|
|
422
464
|
const cur = source()
|
|
@@ -472,9 +514,9 @@ export function apply(ctx, config) {
|
|
|
472
514
|
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
473
515
|
const gw = getGw()
|
|
474
516
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const resu = await gw.approvePairingCode(
|
|
517
|
+
const parsed = await readJsonSafe(req, res, 65536)
|
|
518
|
+
if (!parsed.ok) return
|
|
519
|
+
const resu = await gw.approvePairingCode(parsed.data.code)
|
|
478
520
|
writeJson(res, resu.ok ? 200 : 400, resu)
|
|
479
521
|
},
|
|
480
522
|
}), 'dsh-messenger-gateway: pairing approve')
|
|
@@ -486,9 +528,9 @@ export function apply(ctx, config) {
|
|
|
486
528
|
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
487
529
|
const gw = getGw()
|
|
488
530
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const resu = gw.rejectPairingCode(
|
|
531
|
+
const parsed = await readJsonSafe(req, res, 65536)
|
|
532
|
+
if (!parsed.ok) return
|
|
533
|
+
const resu = gw.rejectPairingCode(parsed.data.code)
|
|
492
534
|
writeJson(res, resu.ok ? 200 : 400, resu)
|
|
493
535
|
},
|
|
494
536
|
}), 'dsh-messenger-gateway: pairing reject')
|
|
@@ -515,10 +557,9 @@ export function apply(ctx, config) {
|
|
|
515
557
|
const gw = getGw()
|
|
516
558
|
const adapter = gw?.getAdapter?.('telegram')
|
|
517
559
|
if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
560
|
+
const parsed = await readJsonSafe(req, res, 2_097_152)
|
|
561
|
+
if (!parsed.ok) return
|
|
562
|
+
const update = parsed.data
|
|
522
563
|
try {
|
|
523
564
|
await adapter.handleWebhookUpdate(update)
|
|
524
565
|
writeJson(res, 200, { ok: true })
|
|
@@ -528,9 +569,9 @@ export function apply(ctx, config) {
|
|
|
528
569
|
},
|
|
529
570
|
}), 'dsh-messenger-gateway: telegram webhook')
|
|
530
571
|
|
|
531
|
-
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send')
|
|
532
|
-
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress')
|
|
533
|
-
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask')
|
|
572
|
+
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send', source)
|
|
573
|
+
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress', source)
|
|
574
|
+
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask', source)
|
|
534
575
|
|
|
535
576
|
const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
|
|
536
577
|
ctx.effect(() => registerPluginUpdater(ctx, {
|
package/lib/messenger-api.js
CHANGED
|
@@ -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
|