@goodandready/dsh-messenger-gateway 0.3.21 → 0.3.22

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