@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.
package/lib/index.js CHANGED
@@ -1,540 +1,585 @@
1
- import { homedir } from 'node:os'
2
- import { join, dirname, resolve } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { defineTool } from '@deepseek-ai/dsh-tools'
5
- import { Gateway } from './gateway.js'
6
- import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare } from './http.js'
7
- import { registerPluginUpdater } from './updater.js'
8
- import {
9
- createMessengerService, dispatchMessenger, httpStatusForError,
10
- messengerApiSchema, parseMessengerBody,
11
- } from './messenger-api.js'
12
- import { listHomes } from './homes.js'
13
- import { Config } from './config.js'
14
-
15
- export const name = '@goodandready/dsh-messenger-gateway'
16
- export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
17
-
18
- export const SETTINGS_NAMESPACE = 'dsh-messenger-gateway'
19
- export { Config }
20
-
21
- function resolveConfig(raw) {
22
- const cfg = Config(raw)
23
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
24
- if (!cfg.media.cacheDir) {
25
- cfg.media = { ...cfg.media, cacheDir: join(home, 'messenger-gateway', 'cache') }
26
- }
27
- return cfg
28
- }
29
-
30
- function publicConfig(cfg) {
31
- return {
32
- enabled: cfg.enabled,
33
- internalBaseURL: cfg.internalBaseURL,
34
- telegram: {
35
- enabled: cfg.telegram.enabled,
36
- allowedUserIds: cfg.telegram.allowedUserIds,
37
- pollTimeoutSeconds: cfg.telegram.pollTimeoutSeconds,
38
- pollIntervalMs: cfg.telegram.pollIntervalMs,
39
- commands: cfg.telegram.commands,
40
- textFormat: cfg.telegram.textFormat,
41
- homeChatId: cfg.telegram.homeChatId,
42
- homeThreadId: cfg.telegram.homeThreadId,
43
- homes: listHomes(cfg.telegram),
44
- pairingEnabled: cfg.telegram.pairingEnabled,
45
- streaming: cfg.telegram.streaming,
46
- progressEnabled: cfg.telegram.progressEnabled,
47
- approvalsEnabled: cfg.telegram.approvalsEnabled,
48
- groupsEnabled: cfg.telegram.groupsEnabled,
49
- groupRequireMention: cfg.telegram.groupRequireMention,
50
- reactionsEnabled: cfg.telegram.reactionsEnabled,
51
- transport: cfg.telegram.transport,
52
- webhookUrl: cfg.telegram.webhookUrl,
53
- webhookPath: cfg.telegram.webhookPath,
54
- webhookSecretConfigured: Boolean(String(cfg.telegram.webhookSecret || '').trim()),
55
- botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
56
- voiceMode: cfg.telegram.voiceMode,
57
- quickActions: cfg.telegram.quickActions !== false,
58
- artifactPreviews: cfg.telegram.artifactPreviews !== false,
59
- notifyBridge: {
60
- enabled: Boolean(cfg.telegram.notifyBridge?.enabled),
61
- events: cfg.telegram.notifyBridge?.events || ['task_done', 'error'],
62
- home: cfg.telegram.notifyBridge?.home || 'default',
63
- excludeSessionPrefixes: cfg.telegram.notifyBridge?.excludeSessionPrefixes || ['msgw-'],
64
- },
65
- forumMirrorEnabled: Boolean(cfg.telegram.forumMirrorEnabled),
66
- forumMirrorChatId: cfg.telegram.forumMirrorChatId || '',
67
- },
68
- discord: {
69
- enabled: Boolean(cfg.discord?.enabled),
70
- botTokenConfigured: Boolean(String(cfg.discord?.botToken || '').trim()),
71
- webhookConfigured: Boolean(String(cfg.discord?.webhookUrl || '').trim()),
72
- },
73
- slack: {
74
- enabled: Boolean(cfg.slack?.enabled),
75
- botTokenConfigured: Boolean(String(cfg.slack?.botToken || '').trim()),
76
- webhookConfigured: Boolean(String(cfg.slack?.webhookUrl || '').trim()),
77
- },
78
- media: { maxDocBytes: cfg.media.maxDocBytes, maxTextInjectBytes: cfg.media.maxTextInjectBytes, maxImageBytes: cfg.media.maxImageBytes },
79
- tts: cfg.tts,
80
- agent: {
81
- provider: cfg.agent.provider,
82
- model: cfg.agent.model,
83
- photoOnlyMode: cfg.agent.photoOnlyMode,
84
- maxMessageLength: cfg.agent.maxMessageLength,
85
- turnTimeoutMs: cfg.agent.turnTimeoutMs,
86
- idleTimeoutMs: cfg.agent.idleTimeoutMs,
87
- instructionPrefix: cfg.agent.instructionPrefix,
88
- sessionScope: cfg.agent.sessionScope,
89
- },
90
- }
91
- }
92
-
93
- function registerMessengerRoute(ctx, getGw, path, action) {
94
- ctx.effect(() => (ctx.get?.('webServer') || ctx.webServer).register({
95
- kind: 'exact', path,
96
- handler: async (req, res) => {
97
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
98
- let payload
99
- try {
100
- payload = parseMessengerBody((await readBody(req)).toString('utf8'))
101
- } catch (err) {
102
- return writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
103
- }
104
- try {
105
- const out = await dispatchMessenger(getGw(), action, payload)
106
- writeJson(res, 200, out)
107
- } catch (err) {
108
- writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
109
- }
110
- },
111
- }), `dsh-messenger-gateway: ${action}`)
112
- }
113
-
114
- function isExcludedSession(sessionId, prefixes) {
115
- const sid = String(sessionId || '')
116
- for (const p of prefixes || []) {
117
- if (typeof p === 'string' && p.length > 0 && sid.startsWith(p)) return true
118
- }
119
- return false
120
- }
121
-
122
- export function apply(ctx, config) {
123
- const entry = resolveConfig(structuredClone(config || {}))
124
- let gateway
125
- let source = () => entry
126
- let settingsApi
127
- const turnStarts = new Map()
128
- const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
129
-
130
- const sync = () => {
131
- if (gateway) { gateway.stop(); gateway = undefined }
132
- const effective = source()
133
- if (effective.enabled === false) return
134
- gateway = new Gateway(ctx, effective, {
135
- persistAllowedUserIds: async (ids) => {
136
- if (!settingsApi) return
137
- const cur = source()
138
- await settingsApi.replace(Config({
139
- ...cur,
140
- telegram: { ...cur.telegram, allowedUserIds: ids },
141
- }))
142
- },
143
- persistHome: async ({ chatId, threadId }) => {
144
- if (!settingsApi) throw new Error('settings not ready')
145
- const cur = source()
146
- await settingsApi.replace(Config({
147
- ...cur,
148
- telegram: { ...cur.telegram, homeChatId: chatId, homeThreadId: threadId || 0 },
149
- }))
150
- },
151
- persistHomes: async (nextTelegram) => {
152
- if (!settingsApi) throw new Error('settings not ready')
153
- const cur = source()
154
- await settingsApi.replace(Config({
155
- ...cur,
156
- telegram: { ...cur.telegram, ...nextTelegram },
157
- }))
158
- },
159
- persistAgentModel: async ({ provider, model }) => {
160
- if (!settingsApi) return
161
- const cur = source()
162
- await settingsApi.replace(Config({
163
- ...cur,
164
- agent: { ...cur.agent, provider, model },
165
- }))
166
- },
167
- })
168
- gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
169
- }
170
-
171
- const settings = ctx.get?.('settings') || ctx.settings
172
- if (settings?.register) {
173
- settingsApi = settings.register(SETTINGS_NAMESPACE, Config, { base: config || {} })
174
- source = () => resolveConfig(settingsApi.get() ?? config ?? {})
175
- ctx.effect(() => settingsApi.watch(sync), 'dsh-messenger-gateway: settings')
176
- }
177
-
178
- const getGw = () => gateway
179
-
180
- ctx.effect(() => ctx.provide('messenger', createMessengerService(getGw)), 'dsh-messenger-gateway: messenger service')
181
-
182
- // Agent tool: inline buttons in the telegram chat bound to this msgw session
183
- const tools = ctx.get?.('tools') || ctx.tools
184
- if (tools?.register) {
185
- ctx.effect(() => tools.register(defineTool({
186
- name: 'messenger_ask',
187
- description:
188
- 'Ask the Telegram user a single or multiple-choice question with inline buttons/checkboxes and wait for their choice. '
189
- + 'Supports mode: "single" (default) or "multi" (checkboxes with Done/Cancel buttons). Only works inside messenger-gateway sessions (msgw-*).',
190
- parameters: {
191
- text: { type: 'string', required: true, description: 'Question text shown in Telegram.' },
192
- buttons: {
193
- type: 'array',
194
- description: 'Rows of buttons: [[{ id, text }, ...], ...] (for single choice)',
195
- },
196
- options: {
197
- type: 'array',
198
- description: 'List of options: [{ id, text, selected?: boolean }, ...] (for single or multi choice)',
199
- },
200
- mode: {
201
- type: 'string',
202
- description: '"single" for instant choice or "multi" for checkboxes form',
203
- },
204
- selected: {
205
- type: 'array',
206
- description: 'Initial selected option IDs for multi-select mode',
207
- },
208
- pageSize: { type: 'number', description: 'Number of options per page (default 6).' },
209
- timeoutMs: { type: 'number', description: 'Wait timeout ms (default 300000).' },
210
- },
211
- output: {
212
- schema: {
213
- type: 'object',
214
- additionalProperties: false,
215
- properties: {
216
- ok: { type: 'boolean' },
217
- buttonId: { type: 'string' },
218
- selected: { type: 'array', items: { type: 'string' } },
219
- data: { type: 'string' },
220
- error: { type: 'string' },
221
- },
222
- },
223
- render: (_args, value) => [{
224
- type: 'text',
225
- text: value && value.ok
226
- ? (value.selected?.length ? `User selected: ${value.selected.join(', ')}` : `User chose: ${value.buttonId}`)
227
- : `messenger_ask failed: ${value && value.error ? value.error : 'unknown'}`,
228
- }],
229
- },
230
- execute: async (args, exec) => {
231
- const gw = getGw()
232
- if (!gw) return { ok: false, error: 'gateway not running' }
233
- try {
234
- const result = await gw.messengerAskFromAgent(exec.agent, {
235
- text: String(args.text || ''),
236
- buttons: args.buttons,
237
- options: args.options,
238
- mode: args.mode,
239
- selected: args.selected,
240
- pageSize: args.pageSize,
241
- }, Number(args.timeoutMs) || 300_000)
242
- return { ok: true, buttonId: result?.buttonId, selected: result?.selected, data: result?.data }
243
- } catch (err) {
244
- return { ok: false, error: err instanceof Error ? err.message : String(err) }
245
- }
246
- },
247
- })), 'dsh-messenger-gateway: messenger_ask tool')
248
- }
249
-
250
- // Dynamic skills sync
251
- ctx.effect(() => {
252
- const onSkillsChange = () => {
253
- const gw = getGw()
254
- if (gw) gw.syncTelegramCommands().catch(() => {})
255
- }
256
- const unbind = ctx.on('skills/change', onSkillsChange)
257
- return () => { if (typeof unbind === 'function') unbind() }
258
- }, 'dsh-messenger-gateway: dynamic skills sync')
259
-
260
- // Notify → Telegram home bridge (non-msgw sessions only)
261
- ctx.effect(() => ctx.on('session/event', (session, event) => {
262
- const cfg = source().telegram?.notifyBridge
263
- if (!cfg?.enabled) return
264
- if (event.type === 'turn/start') {
265
- const now = Date.now()
266
- if (turnStarts.size > 50) {
267
- for (const [sid, ts] of turnStarts.entries()) {
268
- if (now - ts > 7_200_000) turnStarts.delete(sid)
269
- }
270
- }
271
- turnStarts.set(String(session.id), now)
272
- return
273
- }
274
- if (event.type !== 'turn/end') return
275
- const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
276
- if (isExcludedSession(session.id, prefixes)) return
277
- const reason = event.data?.reason
278
- const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
279
- const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
280
- if (!events.has(kind)) return
281
- const gw = getGw()
282
- if (!gw) return
283
- const started = turnStarts.get(String(session.id))
284
- turnStarts.delete(String(session.id))
285
- const dur = started ? Math.round((Date.now() - started) / 1000) : null
286
- const title = kind === 'task_done' ? 'Task completed' : 'Agent error'
287
- const lines = [
288
- `【${title}】`,
289
- `session: ${session.id}`,
290
- ]
291
- if (dur != null) lines.push(`duration: ${dur}s`)
292
- if (reason?.kind === 'error') {
293
- lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
294
- }
295
- const homeName = cfg.home || 'default'
296
- const home = gw.messenger.home?.() || null
297
- if (home && gw.isMuted?.(home.chatId)) return
298
- gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
299
- .catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
300
- }), 'dsh-messenger-gateway: notify bridge')
301
-
302
- ctx.effect(() => getWebServer().register({
303
- kind: 'exact', path: '/dsh-messenger-gateway/status',
304
- handler: async (req, res) => {
305
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
306
- const gw = getGw()
307
- const botInfo = gw?.getBotInfo?.() || {}
308
- const uptimeSec = gw?.stats?.startedAt ? Math.max(0, Math.round((Date.now() - gw.stats.startedAt) / 1000)) : 0
309
- const scheduledCount = gw?.scheduler?.list ? (await gw.scheduler.list())?.length || 0 : 0
310
- writeJson(res, 200, {
311
- ok: true,
312
- running: Boolean(gw),
313
- adapters: gw?.messenger.adapters() || [],
314
- activeChats: gw?.messenger.activeChats() || 0,
315
- ttsEnabled: Boolean(source().tts?.enabled),
316
- messengerService: 'messenger',
317
- home: gw?.messenger.home?.() || null,
318
- homes: gw?.messenger.homes?.() || [],
319
- pairingPending: gw?.messenger.pairingPending?.()?.length || 0,
320
- botId: botInfo.botId || 0,
321
- botUsername: botInfo.botUsername || '',
322
- pollingConflict: Boolean(botInfo.pollingConflict),
323
- uptimeSec,
324
- stats: {
325
- sent: gw?.stats?.sent || 0,
326
- errors: gw?.stats?.errors || 0,
327
- startedAt: gw?.stats?.startedAt || 0,
328
- scheduledCount,
329
- },
330
- apiHealth: {
331
- degraded: (gw?.consecutiveApiFailures || 0) >= 3,
332
- consecutiveFailures: gw?.consecutiveApiFailures || 0,
333
- lastError: gw?.lastApiError || null,
334
- },
335
- config: publicConfig(source()),
336
- })
337
- },
338
- }), 'dsh-messenger-gateway: status')
339
-
340
- ctx.effect(() => getWebServer().register({
341
- kind: 'exact', path: '/dsh-messenger-gateway/smoke',
342
- handler: async (req, res) => {
343
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
344
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
345
- const gw = getGw()
346
- if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
347
- let payload = {}
348
- try {
349
- const raw = (await readBody(req)).toString('utf8').trim()
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
355
- const result = await gw.probeTelegram(timeoutMs)
356
- writeJson(res, result.ok ? 200 : 502, result)
357
- },
358
- }), 'dsh-messenger-gateway: smoke test')
359
-
360
- ctx.effect(() => getWebServer().register({
361
- kind: 'exact', path: '/dsh-messenger-gateway/messenger',
362
- handler: async (req, res) => {
363
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
364
- writeJson(res, 200, { ok: true, schema: messengerApiSchema })
365
- },
366
- }), 'dsh-messenger-gateway: messenger schema')
367
-
368
- ctx.effect(() => getWebServer().register({
369
- kind: 'exact', path: '/dsh-messenger-gateway/events',
370
- handler: async (req, res) => {
371
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
372
- const gw = getGw()
373
- 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
-
381
- const expectedSecret = source().webhooks?.secret
382
- if (expectedSecret) {
383
- const authHeader = req.headers?.authorization || ''
384
- const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
385
- const tokenHeader = req.headers?.['x-webhook-secret'] || ''
386
- const provided = bearer || tokenHeader || payload.secret
387
- if (provided !== expectedSecret) {
388
- return writeJson(res, 401, { ok: false, error: 'unauthorized' })
389
- }
390
- }
391
-
392
- const { target, text, home, chatId, threadId, platform = 'telegram', files } = payload
393
- if (!text && (!files || !files.length)) {
394
- return writeJson(res, 400, { ok: false, error: 'text or files required' })
395
- }
396
-
397
- const dest = target || { platform, chatId, threadId, home }
398
- try {
399
- const result = await gw.messengerSend(dest, { text, files, replyMarkup: payload.replyMarkup })
400
- writeJson(res, 200, { ok: true, sent: result })
401
- } catch (err) {
402
- writeJson(res, 500, { ok: false, error: err.message })
403
- }
404
- },
405
- }), 'dsh-messenger-gateway: webhook events')
406
-
407
- ctx.effect(() => getWebServer().register({
408
- kind: 'exact', path: '/dsh-messenger-gateway/config',
409
- handler: async (req, res) => {
410
- if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
411
- if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
412
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
413
- if (!settingsApi) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
414
- let payload
415
- try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
416
- if (payload && typeof payload.config === 'object') payload = payload.config
417
- try {
418
- const cur = source()
419
- const nextTg = { ...cur.telegram, ...(payload.telegram || {}) }
420
- if (payload.telegram && !String(payload.telegram.botToken || '').trim()) nextTg.botToken = cur.telegram.botToken
421
- if (payload.telegram && Object.prototype.hasOwnProperty.call(payload.telegram, 'webhookSecret') && !String(payload.telegram.webhookSecret || '').trim()) {
422
- nextTg.webhookSecret = cur.telegram.webhookSecret
423
- }
424
- if (payload.telegram?.notifyBridge) {
425
- nextTg.notifyBridge = { ...cur.telegram.notifyBridge, ...payload.telegram.notifyBridge }
426
- }
427
- const nextDiscord = { ...(cur.discord || {}), ...(payload.discord || {}) }
428
- if (payload.discord && !String(payload.discord.botToken || '').trim()) nextDiscord.botToken = cur.discord?.botToken
429
- const nextSlack = { ...(cur.slack || {}), ...(payload.slack || {}) }
430
- if (payload.slack && !String(payload.slack.botToken || '').trim()) nextSlack.botToken = cur.slack?.botToken
431
- await settingsApi.replace(Config({
432
- ...cur,
433
- ...payload,
434
- telegram: nextTg,
435
- discord: nextDiscord,
436
- slack: nextSlack,
437
- tts: { ...cur.tts, ...(payload.tts || {}) },
438
- agent: { ...cur.agent, ...(payload.agent || {}) },
439
- media: { ...cur.media, ...(payload.media || {}) },
440
- }))
441
- sync()
442
- writeJson(res, 200, { ok: true, config: publicConfig(source()) })
443
- } catch (err) {
444
- writeJson(res, 400, { ok: false, error: err.message })
445
- }
446
- },
447
- }), 'dsh-messenger-gateway: config')
448
-
449
- ctx.effect(() => getWebServer().register({
450
- kind: 'exact', path: '/dsh-messenger-gateway/pairing',
451
- handler: async (req, res) => {
452
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
453
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
454
- const gw = getGw()
455
- if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
456
- writeJson(res, 200, {
457
- ok: true,
458
- pending: gw.messenger.pairingPending(),
459
- approved: gw.messenger.pairingApproved(),
460
- })
461
- },
462
- }), 'dsh-messenger-gateway: pairing list')
463
-
464
- ctx.effect(() => getWebServer().register({
465
- kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
466
- handler: async (req, res) => {
467
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
468
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
469
- const gw = getGw()
470
- if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
471
- let payload
472
- try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
473
- const resu = await gw.approvePairingCode(payload.code)
474
- writeJson(res, resu.ok ? 200 : 400, resu)
475
- },
476
- }), 'dsh-messenger-gateway: pairing approve')
477
-
478
- ctx.effect(() => getWebServer().register({
479
- kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
480
- handler: async (req, res) => {
481
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
482
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
483
- const gw = getGw()
484
- if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
485
- let payload
486
- try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
487
- const resu = gw.rejectPairingCode(payload.code)
488
- writeJson(res, resu.ok ? 200 : 400, resu)
489
- },
490
- }), 'dsh-messenger-gateway: pairing reject')
491
-
492
- const webhookPath = () => {
493
- const p = String(source().telegram?.webhookPath || '').trim()
494
- return p || '/dsh-messenger-gateway/telegram/webhook'
495
- }
496
-
497
- ctx.effect(() => getWebServer().register({
498
- kind: 'exact', path: webhookPath(),
499
- handler: async (req, res) => {
500
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
501
- const cfg = source().telegram || {}
502
- if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
503
- const secret = String(cfg.webhookSecret || '').trim()
504
- if (!secret) {
505
- return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
506
- }
507
- const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
508
- if (!timingSafeCompare(hdr, secret)) {
509
- return writeJson(res, 403, { ok: false, error: 'bad secret' })
510
- }
511
- const gw = getGw()
512
- const adapter = gw?.getAdapter?.('telegram')
513
- if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
514
- let update
515
- try { update = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch {
516
- return writeJson(res, 400, { ok: false, error: 'invalid json' })
517
- }
518
- try {
519
- await adapter.handleWebhookUpdate(update)
520
- writeJson(res, 200, { ok: true })
521
- } catch (err) {
522
- writeJson(res, 500, { ok: false, error: err.message })
523
- }
524
- },
525
- }), 'dsh-messenger-gateway: telegram webhook')
526
-
527
- registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send')
528
- registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress')
529
- registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask')
530
-
531
- const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
532
- ctx.effect(() => registerPluginUpdater(ctx, {
533
- manifestPath,
534
- packageName: '@goodandready/dsh-messenger-gateway',
535
- endpoint: '/dsh-messenger-gateway/update',
536
- }), 'dsh-messenger-gateway: plugin updater')
537
-
538
- ctx.on('dispose', () => { if (gateway) gateway.stop() })
539
- sync()
540
- }
1
+ import { homedir } from 'node:os'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import { Gateway } from './gateway.js'
6
+ import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare, isAuthorizedMessengerRequest } from './http.js'
7
+ import { registerPluginUpdater } from './updater.js'
8
+ import {
9
+ createMessengerService, dispatchMessenger, httpStatusForError,
10
+ messengerApiSchema, parseMessengerBody,
11
+ } from './messenger-api.js'
12
+ import { listHomes } from './homes.js'
13
+ import { Config } from './config.js'
14
+ import { setupSettings } from './settings-bridge.js'
15
+
16
+ export const name = '@goodandready/dsh-messenger-gateway'
17
+ export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
18
+
19
+ export const SETTINGS_NAMESPACE = 'dsh-messenger-gateway'
20
+ export { Config }
21
+
22
+ function resolveConfig(raw) {
23
+ const cfg = Config(raw)
24
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
25
+ if (!cfg.media.cacheDir) {
26
+ cfg.media = { ...cfg.media, cacheDir: join(home, 'messenger-gateway', 'cache') }
27
+ }
28
+ return cfg
29
+ }
30
+
31
+ function publicConfig(cfg) {
32
+ return {
33
+ enabled: cfg.enabled,
34
+ internalBaseURL: cfg.internalBaseURL,
35
+ telegram: {
36
+ enabled: cfg.telegram.enabled,
37
+ allowedUserIds: cfg.telegram.allowedUserIds,
38
+ pollTimeoutSeconds: cfg.telegram.pollTimeoutSeconds,
39
+ pollIntervalMs: cfg.telegram.pollIntervalMs,
40
+ commands: cfg.telegram.commands,
41
+ textFormat: cfg.telegram.textFormat,
42
+ homeChatId: cfg.telegram.homeChatId,
43
+ homeThreadId: cfg.telegram.homeThreadId,
44
+ homes: listHomes(cfg.telegram),
45
+ pairingEnabled: cfg.telegram.pairingEnabled,
46
+ streaming: cfg.telegram.streaming,
47
+ progressEnabled: cfg.telegram.progressEnabled,
48
+ approvalsEnabled: cfg.telegram.approvalsEnabled,
49
+ groupsEnabled: cfg.telegram.groupsEnabled,
50
+ groupRequireMention: cfg.telegram.groupRequireMention,
51
+ reactionsEnabled: cfg.telegram.reactionsEnabled,
52
+ transport: cfg.telegram.transport,
53
+ webhookUrl: cfg.telegram.webhookUrl,
54
+ webhookPath: cfg.telegram.webhookPath,
55
+ webhookSecretConfigured: Boolean(String(cfg.telegram.webhookSecret || '').trim()),
56
+ botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
57
+ voiceMode: cfg.telegram.voiceMode,
58
+ quickActions: cfg.telegram.quickActions !== false,
59
+ artifactPreviews: cfg.telegram.artifactPreviews !== false,
60
+ notifyBridge: {
61
+ enabled: Boolean(cfg.telegram.notifyBridge?.enabled),
62
+ events: cfg.telegram.notifyBridge?.events || ['task_done', 'error'],
63
+ home: cfg.telegram.notifyBridge?.home || 'default',
64
+ excludeSessionPrefixes: cfg.telegram.notifyBridge?.excludeSessionPrefixes || ['msgw-'],
65
+ },
66
+ forumMirrorEnabled: Boolean(cfg.telegram.forumMirrorEnabled),
67
+ forumMirrorChatId: cfg.telegram.forumMirrorChatId || '',
68
+ },
69
+ discord: {
70
+ enabled: Boolean(cfg.discord?.enabled),
71
+ botTokenConfigured: Boolean(String(cfg.discord?.botToken || '').trim()),
72
+ webhookConfigured: Boolean(String(cfg.discord?.webhookUrl || '').trim()),
73
+ },
74
+ slack: {
75
+ enabled: Boolean(cfg.slack?.enabled),
76
+ botTokenConfigured: Boolean(String(cfg.slack?.botToken || '').trim()),
77
+ webhookConfigured: Boolean(String(cfg.slack?.webhookUrl || '').trim()),
78
+ },
79
+ media: { maxDocBytes: cfg.media.maxDocBytes, maxTextInjectBytes: cfg.media.maxTextInjectBytes, maxImageBytes: cfg.media.maxImageBytes },
80
+ tts: cfg.tts,
81
+ agent: {
82
+ provider: cfg.agent.provider,
83
+ model: cfg.agent.model,
84
+ photoOnlyMode: cfg.agent.photoOnlyMode,
85
+ maxMessageLength: cfg.agent.maxMessageLength,
86
+ turnTimeoutMs: cfg.agent.turnTimeoutMs,
87
+ idleTimeoutMs: cfg.agent.idleTimeoutMs,
88
+ instructionPrefix: cfg.agent.instructionPrefix,
89
+ sessionScope: cfg.agent.sessionScope,
90
+ },
91
+ }
92
+ }
93
+
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) {
110
+ ctx.effect(() => (ctx.get?.('webServer') || ctx.webServer).register({
111
+ kind: 'exact', path,
112
+ handler: async (req, res) => {
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
+ }
117
+ let payload
118
+ try {
119
+ payload = parseMessengerBody((await readBody(req, 1_048_576)).toString('utf8'))
120
+ } catch (err) {
121
+ return writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
122
+ }
123
+ try {
124
+ const out = await dispatchMessenger(getGw(), action, payload)
125
+ writeJson(res, 200, out)
126
+ } catch (err) {
127
+ writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
128
+ }
129
+ },
130
+ }), `dsh-messenger-gateway: ${action}`)
131
+ }
132
+
133
+ function isExcludedSession(sessionId, prefixes) {
134
+ const sid = String(sessionId || '')
135
+ for (const p of prefixes || []) {
136
+ if (typeof p === 'string' && p.length > 0 && sid.startsWith(p)) return true
137
+ }
138
+ return false
139
+ }
140
+
141
+ export function apply(ctx, config) {
142
+ const entry = resolveConfig(structuredClone(config || {}))
143
+ let gateway
144
+ let source = () => entry
145
+ let settingsApi
146
+ const turnStarts = new Map()
147
+ const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
148
+
149
+ const sync = () => {
150
+ if (gateway) { gateway.stop(); gateway = undefined }
151
+ const effective = source()
152
+ if (effective.enabled === false) return
153
+ gateway = new Gateway(ctx, effective, {
154
+ persistAllowedUserIds: async (ids) => {
155
+ if (!settingsApi?.isReady?.()) return
156
+ const cur = source()
157
+ await settingsApi.replace(Config({
158
+ ...cur,
159
+ telegram: { ...cur.telegram, allowedUserIds: ids },
160
+ }))
161
+ },
162
+ persistHome: async ({ chatId, threadId }) => {
163
+ if (!settingsApi?.isReady?.()) throw new Error('settings not ready')
164
+ const cur = source()
165
+ await settingsApi.replace(Config({
166
+ ...cur,
167
+ telegram: { ...cur.telegram, homeChatId: chatId, homeThreadId: threadId || 0 },
168
+ }))
169
+ },
170
+ persistHomes: async (nextTelegram) => {
171
+ if (!settingsApi?.isReady?.()) throw new Error('settings not ready')
172
+ const cur = source()
173
+ await settingsApi.replace(Config({
174
+ ...cur,
175
+ telegram: { ...cur.telegram, ...nextTelegram },
176
+ }))
177
+ },
178
+ persistAgentModel: async ({ provider, model }) => {
179
+ if (!settingsApi?.isReady?.()) return
180
+ const cur = source()
181
+ await settingsApi.replace(Config({
182
+ ...cur,
183
+ agent: { ...cur.agent, provider, model },
184
+ }))
185
+ },
186
+ })
187
+ gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
188
+ }
189
+
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()),
212
+ }
213
+
214
+ const getGw = () => gateway
215
+
216
+ ctx.effect(() => ctx.provide('messenger', createMessengerService(getGw)), 'dsh-messenger-gateway: messenger service')
217
+
218
+ // Agent tool: inline buttons in the telegram chat bound to this msgw session
219
+ const tools = ctx.get?.('tools') || ctx.tools
220
+ if (tools?.register) {
221
+ ctx.effect(() => tools.register(defineTool({
222
+ name: 'messenger_ask',
223
+ description:
224
+ 'Ask the Telegram user a single or multiple-choice question with inline buttons/checkboxes and wait for their choice. '
225
+ + 'Supports mode: "single" (default) or "multi" (checkboxes with Done/Cancel buttons). Only works inside messenger-gateway sessions (msgw-*).',
226
+ parameters: {
227
+ text: { type: 'string', required: true, description: 'Question text shown in Telegram.' },
228
+ buttons: {
229
+ type: 'array',
230
+ description: 'Rows of buttons: [[{ id, text }, ...], ...] (for single choice)',
231
+ },
232
+ options: {
233
+ type: 'array',
234
+ description: 'List of options: [{ id, text, selected?: boolean }, ...] (for single or multi choice)',
235
+ },
236
+ mode: {
237
+ type: 'string',
238
+ description: '"single" for instant choice or "multi" for checkboxes form',
239
+ },
240
+ selected: {
241
+ type: 'array',
242
+ description: 'Initial selected option IDs for multi-select mode',
243
+ },
244
+ pageSize: { type: 'number', description: 'Number of options per page (default 6).' },
245
+ timeoutMs: { type: 'number', description: 'Wait timeout ms (default 300000).' },
246
+ },
247
+ output: {
248
+ schema: {
249
+ type: 'object',
250
+ additionalProperties: false,
251
+ properties: {
252
+ ok: { type: 'boolean' },
253
+ buttonId: { type: 'string' },
254
+ selected: { type: 'array', items: { type: 'string' } },
255
+ data: { type: 'string' },
256
+ error: { type: 'string' },
257
+ },
258
+ },
259
+ render: (_args, value) => [{
260
+ type: 'text',
261
+ text: value && value.ok
262
+ ? (value.selected?.length ? `User selected: ${value.selected.join(', ')}` : `User chose: ${value.buttonId}`)
263
+ : `messenger_ask failed: ${value && value.error ? value.error : 'unknown'}`,
264
+ }],
265
+ },
266
+ execute: async (args, exec) => {
267
+ const gw = getGw()
268
+ if (!gw) return { ok: false, error: 'gateway not running' }
269
+ try {
270
+ const result = await gw.messengerAskFromAgent(exec.agent, {
271
+ text: String(args.text || ''),
272
+ buttons: args.buttons,
273
+ options: args.options,
274
+ mode: args.mode,
275
+ selected: args.selected,
276
+ pageSize: args.pageSize,
277
+ }, Number(args.timeoutMs) || 300_000)
278
+ return { ok: true, buttonId: result?.buttonId, selected: result?.selected, data: result?.data }
279
+ } catch (err) {
280
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
281
+ }
282
+ },
283
+ })), 'dsh-messenger-gateway: messenger_ask tool')
284
+ }
285
+
286
+ // Dynamic skills sync
287
+ ctx.effect(() => {
288
+ const onSkillsChange = () => {
289
+ const gw = getGw()
290
+ if (gw) gw.syncTelegramCommands().catch((err) => ctx.logger?.debug?.(`sync telegram commands on skills change: ${err?.message || err}`))
291
+ }
292
+ const unbind = ctx.on('skills/change', onSkillsChange)
293
+ return () => { if (typeof unbind === 'function') unbind() }
294
+ }, 'dsh-messenger-gateway: dynamic skills sync')
295
+
296
+ // Notify → Telegram home bridge (non-msgw sessions only)
297
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
298
+ const cfg = source().telegram?.notifyBridge
299
+ if (!cfg?.enabled) return
300
+ if (event.type === 'turn/start') {
301
+ const now = Date.now()
302
+ if (turnStarts.size > 50) {
303
+ for (const [sid, ts] of turnStarts.entries()) {
304
+ if (now - ts > 7_200_000) turnStarts.delete(sid)
305
+ }
306
+ }
307
+ turnStarts.set(String(session.id), now)
308
+ return
309
+ }
310
+ if (event.type !== 'turn/end') return
311
+ const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
312
+ if (isExcludedSession(session.id, prefixes)) return
313
+ const reason = event.data?.reason
314
+ const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
315
+ const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
316
+ if (!events.has(kind)) return
317
+ const gw = getGw()
318
+ if (!gw) return
319
+ const started = turnStarts.get(String(session.id))
320
+ turnStarts.delete(String(session.id))
321
+ const dur = started ? Math.round((Date.now() - started) / 1000) : null
322
+ const title = kind === 'task_done' ? 'Task completed' : 'Agent error'
323
+ const lines = [
324
+ `【${title}】`,
325
+ `session: ${session.id}`,
326
+ ]
327
+ if (dur != null) lines.push(`duration: ${dur}s`)
328
+ if (reason?.kind === 'error') {
329
+ lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
330
+ }
331
+ const homeName = cfg.home || 'default'
332
+ const home = gw.messenger.home?.() || null
333
+ if (home && gw.isMuted?.(home.chatId)) return
334
+ gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
335
+ .catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
336
+ }), 'dsh-messenger-gateway: notify bridge')
337
+
338
+ ctx.effect(() => getWebServer().register({
339
+ kind: 'exact', path: '/dsh-messenger-gateway/status',
340
+ handler: async (req, res) => {
341
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
342
+ const gw = getGw()
343
+ const botInfo = gw?.getBotInfo?.() || {}
344
+ const uptimeSec = gw?.stats?.startedAt ? Math.max(0, Math.round((Date.now() - gw.stats.startedAt) / 1000)) : 0
345
+ const scheduledCount = gw?.scheduler?.list ? (await gw.scheduler.list())?.length || 0 : 0
346
+ writeJson(res, 200, {
347
+ ok: true,
348
+ running: Boolean(gw),
349
+ adapters: gw?.messenger.adapters() || [],
350
+ activeChats: gw?.messenger.activeChats() || 0,
351
+ ttsEnabled: Boolean(source().tts?.enabled),
352
+ messengerService: 'messenger',
353
+ home: gw?.messenger.home?.() || null,
354
+ homes: gw?.messenger.homes?.() || [],
355
+ pairingPending: gw?.messenger.pairingPending?.()?.length || 0,
356
+ botId: botInfo.botId || 0,
357
+ botUsername: botInfo.botUsername || '',
358
+ pollingConflict: Boolean(botInfo.pollingConflict),
359
+ uptimeSec,
360
+ stats: {
361
+ sent: gw?.stats?.sent || 0,
362
+ errors: gw?.stats?.errors || 0,
363
+ startedAt: gw?.stats?.startedAt || 0,
364
+ scheduledCount,
365
+ },
366
+ apiHealth: {
367
+ degraded: (gw?.consecutiveApiFailures || 0) >= 3,
368
+ consecutiveFailures: gw?.consecutiveApiFailures || 0,
369
+ lastError: gw?.lastApiError || null,
370
+ },
371
+ config: publicConfig(source()),
372
+ })
373
+ },
374
+ }), 'dsh-messenger-gateway: status')
375
+
376
+ ctx.effect(() => getWebServer().register({
377
+ kind: 'exact', path: '/dsh-messenger-gateway/smoke',
378
+ handler: async (req, res) => {
379
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
380
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
381
+ const gw = getGw()
382
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
383
+ const parsed = await readJsonSafe(req, res, 65536)
384
+ if (!parsed.ok) return
385
+ const timeoutMs = Number(parsed.data?.timeoutMs) || 10000
386
+ const result = await gw.probeTelegram(timeoutMs)
387
+ writeJson(res, result.ok ? 200 : 502, result)
388
+ },
389
+ }), 'dsh-messenger-gateway: smoke test')
390
+
391
+ ctx.effect(() => getWebServer().register({
392
+ kind: 'exact', path: '/dsh-messenger-gateway/messenger',
393
+ handler: async (req, res) => {
394
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
395
+ writeJson(res, 200, { ok: true, schema: messengerApiSchema })
396
+ },
397
+ }), 'dsh-messenger-gateway: messenger schema')
398
+
399
+ ctx.effect(() => getWebServer().register({
400
+ kind: 'exact', path: '/dsh-messenger-gateway/events',
401
+ handler: async (req, res) => {
402
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
403
+ const gw = getGw()
404
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not ready' })
405
+
406
+ if (source().webhooks?.enabled === false) {
407
+ return writeJson(res, 404, { ok: false, error: 'webhooks disabled' })
408
+ }
409
+ const expectedSecret = String(source().webhooks?.secret || '').trim()
410
+ if (!expectedSecret) {
411
+ return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
412
+ }
413
+
414
+ // Check header authentication BEFORE reading the body to avoid unbounded buffering
415
+ const authHeader = req.headers?.authorization || ''
416
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
417
+ const tokenHeader = req.headers?.['x-webhook-secret'] || ''
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
+ }
435
+ }
436
+
437
+ const { target, text, home, chatId, threadId, platform = 'telegram', files } = payload
438
+ if (!text && (!files || !files.length)) {
439
+ return writeJson(res, 400, { ok: false, error: 'text or files required' })
440
+ }
441
+
442
+ const dest = target || { platform, chatId, threadId, home }
443
+ try {
444
+ const result = await gw.messengerSend(dest, { text, files, replyMarkup: payload.replyMarkup })
445
+ writeJson(res, 200, { ok: true, sent: result })
446
+ } catch (err) {
447
+ writeJson(res, 500, { ok: false, error: err.message })
448
+ }
449
+ },
450
+ }), 'dsh-messenger-gateway: webhook events')
451
+
452
+ ctx.effect(() => getWebServer().register({
453
+ kind: 'exact', path: '/dsh-messenger-gateway/config',
454
+ handler: async (req, res) => {
455
+ if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
456
+ if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
457
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
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
462
+ if (payload && typeof payload.config === 'object') payload = payload.config
463
+ try {
464
+ const cur = source()
465
+ const nextTg = { ...cur.telegram, ...(payload.telegram || {}) }
466
+ if (payload.telegram && !String(payload.telegram.botToken || '').trim()) nextTg.botToken = cur.telegram.botToken
467
+ if (payload.telegram && Object.prototype.hasOwnProperty.call(payload.telegram, 'webhookSecret') && !String(payload.telegram.webhookSecret || '').trim()) {
468
+ nextTg.webhookSecret = cur.telegram.webhookSecret
469
+ }
470
+ if (payload.telegram?.notifyBridge) {
471
+ nextTg.notifyBridge = { ...cur.telegram.notifyBridge, ...payload.telegram.notifyBridge }
472
+ }
473
+ const nextDiscord = { ...(cur.discord || {}), ...(payload.discord || {}) }
474
+ if (payload.discord && !String(payload.discord.botToken || '').trim()) nextDiscord.botToken = cur.discord?.botToken
475
+ const nextSlack = { ...(cur.slack || {}), ...(payload.slack || {}) }
476
+ if (payload.slack && !String(payload.slack.botToken || '').trim()) nextSlack.botToken = cur.slack?.botToken
477
+ await settingsApi.replace(Config({
478
+ ...cur,
479
+ ...payload,
480
+ telegram: nextTg,
481
+ discord: nextDiscord,
482
+ slack: nextSlack,
483
+ tts: { ...cur.tts, ...(payload.tts || {}) },
484
+ agent: { ...cur.agent, ...(payload.agent || {}) },
485
+ media: { ...cur.media, ...(payload.media || {}) },
486
+ }))
487
+ sync()
488
+ writeJson(res, 200, { ok: true, config: publicConfig(source()) })
489
+ } catch (err) {
490
+ writeJson(res, 400, { ok: false, error: err.message })
491
+ }
492
+ },
493
+ }), 'dsh-messenger-gateway: config')
494
+
495
+ ctx.effect(() => getWebServer().register({
496
+ kind: 'exact', path: '/dsh-messenger-gateway/pairing',
497
+ handler: async (req, res) => {
498
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
499
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
500
+ const gw = getGw()
501
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
502
+ writeJson(res, 200, {
503
+ ok: true,
504
+ pending: gw.messenger.pairingPending(),
505
+ approved: gw.messenger.pairingApproved(),
506
+ })
507
+ },
508
+ }), 'dsh-messenger-gateway: pairing list')
509
+
510
+ ctx.effect(() => getWebServer().register({
511
+ kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
512
+ handler: async (req, res) => {
513
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
514
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
515
+ const gw = getGw()
516
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
517
+ const parsed = await readJsonSafe(req, res, 65536)
518
+ if (!parsed.ok) return
519
+ const resu = await gw.approvePairingCode(parsed.data.code)
520
+ writeJson(res, resu.ok ? 200 : 400, resu)
521
+ },
522
+ }), 'dsh-messenger-gateway: pairing approve')
523
+
524
+ ctx.effect(() => getWebServer().register({
525
+ kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
526
+ handler: async (req, res) => {
527
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
528
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
529
+ const gw = getGw()
530
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
531
+ const parsed = await readJsonSafe(req, res, 65536)
532
+ if (!parsed.ok) return
533
+ const resu = gw.rejectPairingCode(parsed.data.code)
534
+ writeJson(res, resu.ok ? 200 : 400, resu)
535
+ },
536
+ }), 'dsh-messenger-gateway: pairing reject')
537
+
538
+ const webhookPath = () => {
539
+ const p = String(source().telegram?.webhookPath || '').trim()
540
+ return p || '/dsh-messenger-gateway/telegram/webhook'
541
+ }
542
+
543
+ ctx.effect(() => getWebServer().register({
544
+ kind: 'exact', path: webhookPath(),
545
+ handler: async (req, res) => {
546
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
547
+ const cfg = source().telegram || {}
548
+ if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
549
+ const secret = String(cfg.webhookSecret || '').trim()
550
+ if (!secret) {
551
+ return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
552
+ }
553
+ const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
554
+ if (!timingSafeCompare(hdr, secret)) {
555
+ return writeJson(res, 403, { ok: false, error: 'bad secret' })
556
+ }
557
+ const gw = getGw()
558
+ const adapter = gw?.getAdapter?.('telegram')
559
+ if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
560
+ const parsed = await readJsonSafe(req, res, 2_097_152)
561
+ if (!parsed.ok) return
562
+ const update = parsed.data
563
+ try {
564
+ await adapter.handleWebhookUpdate(update)
565
+ writeJson(res, 200, { ok: true })
566
+ } catch (err) {
567
+ writeJson(res, 500, { ok: false, error: err.message })
568
+ }
569
+ },
570
+ }), 'dsh-messenger-gateway: telegram webhook')
571
+
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)
575
+
576
+ const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
577
+ ctx.effect(() => registerPluginUpdater(ctx, {
578
+ manifestPath,
579
+ packageName: '@goodandready/dsh-messenger-gateway',
580
+ endpoint: '/dsh-messenger-gateway/update',
581
+ }), 'dsh-messenger-gateway: plugin updater')
582
+
583
+ ctx.on('dispose', () => { if (gateway) gateway.stop() })
584
+ sync()
585
+ }