@goodandready/dsh-messenger-gateway 0.1.0 → 0.2.0

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/groups.js ADDED
@@ -0,0 +1,72 @@
1
+ /** Group/supergroup mention & reply gating for Telegram. */
2
+
3
+ export function isPrivateChat(chatType) {
4
+ return !chatType || chatType === 'private'
5
+ }
6
+
7
+ export function isGroupChat(chatType) {
8
+ return chatType === 'group' || chatType === 'supergroup'
9
+ }
10
+
11
+ /** True if text entities mention @botUsername or /cmd@botUsername. */
12
+ export function textMentionsBot(text, entities, botUsername) {
13
+ const uname = String(botUsername || '').replace(/^@/, '').toLowerCase()
14
+ if (!uname) return false
15
+ const body = String(text || '')
16
+ if (Array.isArray(entities)) {
17
+ for (const ent of entities) {
18
+ if (!ent || typeof ent.offset !== 'number' || typeof ent.length !== 'number') continue
19
+ const slice = body.slice(ent.offset, ent.offset + ent.length)
20
+ if (ent.type === 'mention' && slice.replace(/^@/, '').toLowerCase() === uname) return true
21
+ if (ent.type === 'text_mention' && ent.user?.is_bot && String(ent.user?.username || '').toLowerCase() === uname) return true
22
+ if (ent.type === 'bot_command') {
23
+ const cmd = slice.toLowerCase()
24
+ if (cmd.includes('@') && cmd.endsWith('@' + uname)) return true
25
+ }
26
+ }
27
+ }
28
+ const lower = body.toLowerCase()
29
+ if (lower.includes('@' + uname)) return true
30
+ return false
31
+ }
32
+
33
+ export function isReplyToBot(replyTo, botId) {
34
+ if (!replyTo?.from) return false
35
+ const id = Number(botId)
36
+ if (Number.isFinite(id) && Number(replyTo.from.id) === id) return true
37
+ return Boolean(replyTo.from.is_bot)
38
+ }
39
+
40
+ /**
41
+ * Decide whether the gateway should process this inbound message.
42
+ * Private: always. Groups: only if enabled + (mention | reply-to-bot | command).
43
+ */
44
+ export function shouldProcessTelegramMessage({
45
+ chatType,
46
+ text,
47
+ entities,
48
+ replyTo,
49
+ botId,
50
+ botUsername,
51
+ groupsEnabled = true,
52
+ requireMention = true,
53
+ }) {
54
+ if (isPrivateChat(chatType)) return { ok: true, reason: 'private' }
55
+ if (!isGroupChat(chatType)) return { ok: false, reason: 'unsupported-chat' }
56
+ if (!groupsEnabled) return { ok: false, reason: 'groups-disabled' }
57
+ const body = String(text || '').trim()
58
+ if (body.startsWith('/')) return { ok: true, reason: 'command' }
59
+ if (isReplyToBot(replyTo, botId)) return { ok: true, reason: 'reply-to-bot' }
60
+ if (!requireMention) return { ok: true, reason: 'groups-open' }
61
+ if (textMentionsBot(body, entities, botUsername)) return { ok: true, reason: 'mention' }
62
+ return { ok: false, reason: 'no-mention' }
63
+ }
64
+
65
+ /** Strip @bot from /cmd@bot for command parsing. */
66
+ export function stripBotCommandSuffix(text, botUsername) {
67
+ const raw = String(text || '')
68
+ const uname = String(botUsername || '').replace(/^@/, '')
69
+ if (!uname) return raw
70
+ const escaped = uname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
71
+ return raw.replace(new RegExp('^(/[a-z0-9_]+)@' + escaped + '\\b', 'i'), '$1')
72
+ }
package/lib/homes.js ADDED
@@ -0,0 +1,58 @@
1
+ import { normalizeThreadId } from "./topics.js"
2
+
3
+ export function normalizeHomeName(name) {
4
+ return String(name || "").trim().toLowerCase().replace(/\s+/g, "-").slice(0, 32)
5
+ }
6
+
7
+ /** Legacy single home + named list → unified list. */
8
+ export function listHomes(tg = {}) {
9
+ const out = []
10
+ const seen = new Set()
11
+ const push = (name, chatId, threadId) => {
12
+ const n = normalizeHomeName(name) || "default"
13
+ if (seen.has(n)) return
14
+ if (chatId === undefined || chatId === null || String(chatId).trim() === "") return
15
+ seen.add(n)
16
+ out.push({ name: n, chatId, threadId: normalizeThreadId(threadId) })
17
+ }
18
+ const homes = Array.isArray(tg.homes) ? tg.homes : []
19
+ for (const h of homes) {
20
+ if (!h || typeof h !== "object") continue
21
+ push(h.name || h.id, h.chatId, h.threadId)
22
+ }
23
+ // legacy fields as default if not already present
24
+ if (tg.homeChatId !== undefined && tg.homeChatId !== null && String(tg.homeChatId).trim() !== "") {
25
+ push("default", tg.homeChatId, tg.homeThreadId)
26
+ }
27
+ return out
28
+ }
29
+
30
+ export function resolveNamedHome(tg, nameOrTarget) {
31
+ const homes = listHomes(tg)
32
+ if (!homes.length) return null
33
+ if (!nameOrTarget) return homes.find((h) => h.name === "default") || homes[0]
34
+ if (typeof nameOrTarget === "object") {
35
+ if (nameOrTarget.chatId != null && String(nameOrTarget.chatId).trim() !== "") {
36
+ return {
37
+ name: normalizeHomeName(nameOrTarget.name) || "custom",
38
+ chatId: nameOrTarget.chatId,
39
+ threadId: normalizeThreadId(nameOrTarget.threadId),
40
+ }
41
+ }
42
+ nameOrTarget = nameOrTarget.name || nameOrTarget.home
43
+ }
44
+ const want = normalizeHomeName(nameOrTarget)
45
+ return homes.find((h) => h.name === want) || null
46
+ }
47
+
48
+ export function upsertHome(tg, { name, chatId, threadId }) {
49
+ const n = normalizeHomeName(name) || "default"
50
+ const homes = listHomes(tg).filter((h) => h.name !== n)
51
+ homes.push({ name: n, chatId, threadId: normalizeThreadId(threadId) })
52
+ const next = { ...tg, homes }
53
+ if (n === "default") {
54
+ next.homeChatId = chatId
55
+ next.homeThreadId = normalizeThreadId(threadId)
56
+ }
57
+ return next
58
+ }
package/lib/index.js CHANGED
@@ -1,52 +1,21 @@
1
- import z from '@deepseek-ai/schemastery'
2
1
  import { homedir } from 'node:os'
3
2
  import { join } from 'node:path'
4
3
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
5
  import { Gateway } from './gateway.js'
6
6
  import { readBody, writeJson, isTrustedSettingsRequest } from './http.js'
7
7
  import {
8
8
  createMessengerService, dispatchMessenger, httpStatusForError,
9
9
  messengerApiSchema, parseMessengerBody,
10
10
  } from './messenger-api.js'
11
+ import { listHomes } from './homes.js'
12
+ import { Config } from './config.js'
11
13
 
12
14
  export const name = 'dsh-messenger-gateway'
13
- export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments']
15
+ export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
14
16
 
15
17
  export const SETTINGS_NAMESPACE = settingsNamespace('dsh-messenger-gateway')
16
-
17
- export const Config = z.object({
18
- enabled: z.boolean().default(true),
19
- internalBaseURL: z.string().default('http://127.0.0.1:3080'),
20
- telegram: z.object({
21
- enabled: z.boolean().default(false),
22
- botToken: z.string().role('secret').default(''),
23
- allowedUserIds: z.array(z.number()).default([]),
24
- pollTimeoutSeconds: z.number().default(50),
25
- pollIntervalMs: z.number().default(500),
26
- }),
27
- discord: z.object({
28
- enabled: z.boolean().default(false),
29
- botToken: z.string().role('secret').default(''),
30
- }),
31
- media: z.object({
32
- cacheDir: z.string().default(''),
33
- maxDocBytes: z.number().default(20 * 1024 * 1024),
34
- maxTextInjectBytes: z.number().default(100 * 1024),
35
- maxImageBytes: z.number().default(20 * 1024 * 1024),
36
- }),
37
- tts: z.object({
38
- enabled: z.boolean().default(false),
39
- maxChars: z.number().default(4000),
40
- }),
41
- agent: z.object({
42
- provider: z.string().default(''),
43
- model: z.string().default(''),
44
- cwd: z.string().default(''),
45
- instructionPrefix: z.string().default(''),
46
- maxMessageLength: z.number().default(4000),
47
- idleTimeoutMs: z.number().default(3_600_000),
48
- }),
49
- })
18
+ export { Config }
50
19
 
51
20
  function resolveConfig(raw) {
52
21
  const cfg = Config(raw)
@@ -66,12 +35,44 @@ function publicConfig(cfg) {
66
35
  allowedUserIds: cfg.telegram.allowedUserIds,
67
36
  pollTimeoutSeconds: cfg.telegram.pollTimeoutSeconds,
68
37
  pollIntervalMs: cfg.telegram.pollIntervalMs,
38
+ commands: cfg.telegram.commands,
39
+ textFormat: cfg.telegram.textFormat,
40
+ homeChatId: cfg.telegram.homeChatId,
41
+ homeThreadId: cfg.telegram.homeThreadId,
42
+ homes: listHomes(cfg.telegram),
43
+ pairingEnabled: cfg.telegram.pairingEnabled,
44
+ streaming: cfg.telegram.streaming,
45
+ progressEnabled: cfg.telegram.progressEnabled,
46
+ approvalsEnabled: cfg.telegram.approvalsEnabled,
47
+ groupsEnabled: cfg.telegram.groupsEnabled,
48
+ groupRequireMention: cfg.telegram.groupRequireMention,
49
+ reactionsEnabled: cfg.telegram.reactionsEnabled,
50
+ transport: cfg.telegram.transport,
51
+ webhookUrl: cfg.telegram.webhookUrl,
52
+ webhookPath: cfg.telegram.webhookPath,
53
+ webhookSecretConfigured: Boolean(String(cfg.telegram.webhookSecret || '').trim()),
69
54
  botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
55
+ voiceMode: cfg.telegram.voiceMode,
56
+ notifyBridge: {
57
+ enabled: Boolean(cfg.telegram.notifyBridge?.enabled),
58
+ events: cfg.telegram.notifyBridge?.events || ['task_done', 'error'],
59
+ home: cfg.telegram.notifyBridge?.home || 'default',
60
+ excludeSessionPrefixes: cfg.telegram.notifyBridge?.excludeSessionPrefixes || ['msgw-'],
61
+ },
70
62
  },
71
63
  discord: { enabled: cfg.discord.enabled, botTokenConfigured: Boolean(String(cfg.discord.botToken || '').trim()) },
72
64
  media: { maxDocBytes: cfg.media.maxDocBytes, maxTextInjectBytes: cfg.media.maxTextInjectBytes, maxImageBytes: cfg.media.maxImageBytes },
73
65
  tts: cfg.tts,
74
- agent: cfg.agent,
66
+ agent: {
67
+ provider: cfg.agent.provider,
68
+ model: cfg.agent.model,
69
+ photoOnlyMode: cfg.agent.photoOnlyMode,
70
+ maxMessageLength: cfg.agent.maxMessageLength,
71
+ turnTimeoutMs: cfg.agent.turnTimeoutMs,
72
+ idleTimeoutMs: cfg.agent.idleTimeoutMs,
73
+ instructionPrefix: cfg.agent.instructionPrefix,
74
+ sessionScope: cfg.agent.sessionScope,
75
+ },
75
76
  }
76
77
  }
77
78
 
@@ -96,17 +97,59 @@ function registerMessengerRoute(ctx, getGw, path, action) {
96
97
  }), `dsh-messenger-gateway: ${action}`)
97
98
  }
98
99
 
100
+ function isExcludedSession(sessionId, prefixes) {
101
+ const sid = String(sessionId || '')
102
+ for (const p of prefixes || []) {
103
+ if (typeof p === 'string' && p.length > 0 && sid.startsWith(p)) return true
104
+ }
105
+ return false
106
+ }
107
+
99
108
  export function apply(ctx, config) {
100
109
  const entry = resolveConfig(structuredClone(config || {}))
101
110
  let gateway
102
111
  let source = () => entry
103
112
  let settingsApi
113
+ const turnStarts = new Map()
104
114
 
105
115
  const sync = () => {
106
116
  if (gateway) { gateway.stop(); gateway = undefined }
107
117
  const effective = source()
108
118
  if (effective.enabled === false) return
109
- gateway = new Gateway(ctx, effective)
119
+ gateway = new Gateway(ctx, effective, {
120
+ persistAllowedUserIds: async (ids) => {
121
+ if (!settingsApi) return
122
+ const cur = source()
123
+ await settingsApi.replace(Config({
124
+ ...cur,
125
+ telegram: { ...cur.telegram, allowedUserIds: ids },
126
+ }))
127
+ },
128
+ persistHome: async ({ chatId, threadId }) => {
129
+ if (!settingsApi) throw new Error('settings not ready')
130
+ const cur = source()
131
+ await settingsApi.replace(Config({
132
+ ...cur,
133
+ telegram: { ...cur.telegram, homeChatId: chatId, homeThreadId: threadId || 0 },
134
+ }))
135
+ },
136
+ persistHomes: async (nextTelegram) => {
137
+ if (!settingsApi) throw new Error('settings not ready')
138
+ const cur = source()
139
+ await settingsApi.replace(Config({
140
+ ...cur,
141
+ telegram: { ...cur.telegram, ...nextTelegram },
142
+ }))
143
+ },
144
+ persistAgentModel: async ({ provider, model }) => {
145
+ if (!settingsApi) return
146
+ const cur = source()
147
+ await settingsApi.replace(Config({
148
+ ...cur,
149
+ agent: { ...cur.agent, provider, model },
150
+ }))
151
+ },
152
+ })
110
153
  gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
111
154
  }
112
155
 
@@ -120,6 +163,91 @@ export function apply(ctx, config) {
120
163
 
121
164
  ctx.effect(() => ctx.provide('messenger', createMessengerService(getGw)), 'dsh-messenger-gateway: messenger service')
122
165
 
166
+ // Agent tool: inline buttons in the telegram chat bound to this msgw session
167
+ if (ctx.tools?.register) {
168
+ ctx.effect(() => ctx.tools.register(defineTool({
169
+ name: 'messenger_ask',
170
+ description:
171
+ 'Ask the Telegram user a multiple-choice question with inline buttons and wait for their choice. '
172
+ + 'Use for confirmations and short choices. Only works inside messenger-gateway sessions (msgw-*).',
173
+ parameters: {
174
+ text: { type: 'string', required: true, description: 'Question text shown in Telegram.' },
175
+ buttons: {
176
+ type: 'array',
177
+ required: true,
178
+ description: 'Rows of buttons: [[{ id, text }, ...], ...]',
179
+ },
180
+ timeoutMs: { type: 'number', description: 'Wait timeout ms (default 300000).' },
181
+ },
182
+ output: {
183
+ schema: {
184
+ type: 'object',
185
+ additionalProperties: false,
186
+ properties: {
187
+ ok: { type: 'boolean' },
188
+ buttonId: { type: 'string' },
189
+ data: { type: 'string' },
190
+ error: { type: 'string' },
191
+ },
192
+ },
193
+ render: (_args, value) => [{
194
+ type: 'text',
195
+ text: value && value.ok
196
+ ? `User chose: ${value.buttonId}`
197
+ : `messenger_ask failed: ${value && value.error ? value.error : 'unknown'}`,
198
+ }],
199
+ },
200
+ execute: async (args, exec) => {
201
+ const gw = getGw()
202
+ if (!gw) return { ok: false, error: 'gateway not running' }
203
+ try {
204
+ const buttons = args.buttons
205
+ const result = await gw.messengerAskFromAgent(exec.agent, {
206
+ text: String(args.text || ''),
207
+ buttons,
208
+ }, Number(args.timeoutMs) || 300_000)
209
+ return { ok: true, buttonId: result?.buttonId, data: result?.data }
210
+ } catch (err) {
211
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
212
+ }
213
+ },
214
+ })), 'dsh-messenger-gateway: messenger_ask tool')
215
+ }
216
+
217
+ // Notify → Telegram home bridge (non-msgw sessions only)
218
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
219
+ const cfg = source().telegram?.notifyBridge
220
+ if (!cfg?.enabled) return
221
+ if (event.type === 'turn/start') {
222
+ turnStarts.set(String(session.id), Date.now())
223
+ return
224
+ }
225
+ if (event.type !== 'turn/end') return
226
+ const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
227
+ if (isExcludedSession(session.id, prefixes)) return
228
+ const reason = event.data?.reason
229
+ const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
230
+ const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
231
+ if (!events.has(kind)) return
232
+ const gw = getGw()
233
+ if (!gw) return
234
+ const started = turnStarts.get(String(session.id))
235
+ turnStarts.delete(String(session.id))
236
+ const dur = started ? Math.round((Date.now() - started) / 1000) : null
237
+ const title = kind === 'task_done' ? 'Задача завершена' : 'Ошибка агента'
238
+ const lines = [
239
+ `【${title}】`,
240
+ `session: ${session.id}`,
241
+ ]
242
+ if (dur != null) lines.push(`duration: ${dur}s`)
243
+ if (reason?.kind === 'error') {
244
+ lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
245
+ }
246
+ const homeName = cfg.home || 'default'
247
+ gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
248
+ .catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
249
+ }), 'dsh-messenger-gateway: notify bridge')
250
+
123
251
  ctx.effect(() => ctx.webServer.register({
124
252
  kind: 'exact', path: '/dsh-messenger-gateway/status',
125
253
  handler: async (req, res) => {
@@ -132,6 +260,9 @@ export function apply(ctx, config) {
132
260
  activeChats: gw?.messenger.activeChats() || 0,
133
261
  ttsEnabled: Boolean(source().tts?.enabled),
134
262
  messengerService: 'messenger',
263
+ home: gateway?.messenger.home?.() || null,
264
+ homes: gateway?.messenger.homes?.() || [],
265
+ pairingPending: gw?.messenger.pairingPending?.()?.length || 0,
135
266
  config: publicConfig(source()),
136
267
  })
137
268
  },
@@ -156,7 +287,23 @@ export function apply(ctx, config) {
156
287
  try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
157
288
  if (payload && typeof payload.config === 'object') payload = payload.config
158
289
  try {
159
- await settingsApi.replace(Config({ ...source(), ...payload }))
290
+ const cur = source()
291
+ const nextTg = { ...cur.telegram, ...(payload.telegram || {}) }
292
+ if (payload.telegram && !String(payload.telegram.botToken || '').trim()) nextTg.botToken = cur.telegram.botToken
293
+ if (payload.telegram && Object.prototype.hasOwnProperty.call(payload.telegram, 'webhookSecret') && !String(payload.telegram.webhookSecret || '').trim()) {
294
+ nextTg.webhookSecret = cur.telegram.webhookSecret
295
+ }
296
+ if (payload.telegram?.notifyBridge) {
297
+ nextTg.notifyBridge = { ...cur.telegram.notifyBridge, ...payload.telegram.notifyBridge }
298
+ }
299
+ await settingsApi.replace(Config({
300
+ ...cur,
301
+ ...payload,
302
+ telegram: nextTg,
303
+ tts: { ...cur.tts, ...(payload.tts || {}) },
304
+ agent: { ...cur.agent, ...(payload.agent || {}) },
305
+ media: { ...cur.media, ...(payload.media || {}) },
306
+ }))
160
307
  sync()
161
308
  writeJson(res, 200, { ok: true, config: publicConfig(source()) })
162
309
  } catch (err) {
@@ -165,6 +312,81 @@ export function apply(ctx, config) {
165
312
  },
166
313
  }), 'dsh-messenger-gateway: config')
167
314
 
315
+ ctx.effect(() => ctx.webServer.register({
316
+ kind: 'exact', path: '/dsh-messenger-gateway/pairing',
317
+ handler: async (req, res) => {
318
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
319
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
320
+ const gw = getGw()
321
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
322
+ writeJson(res, 200, {
323
+ ok: true,
324
+ pending: gw.messenger.pairingPending(),
325
+ approved: gw.messenger.pairingApproved(),
326
+ })
327
+ },
328
+ }), 'dsh-messenger-gateway: pairing list')
329
+
330
+ ctx.effect(() => ctx.webServer.register({
331
+ kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
332
+ handler: async (req, res) => {
333
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
334
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
335
+ const gw = getGw()
336
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
337
+ let payload
338
+ try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
339
+ const resu = await gw.approvePairingCode(payload.code)
340
+ writeJson(res, resu.ok ? 200 : 400, resu)
341
+ },
342
+ }), 'dsh-messenger-gateway: pairing approve')
343
+
344
+ ctx.effect(() => ctx.webServer.register({
345
+ kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
346
+ handler: async (req, res) => {
347
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
348
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
349
+ const gw = getGw()
350
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
351
+ let payload
352
+ try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
353
+ const resu = gw.rejectPairingCode(payload.code)
354
+ writeJson(res, resu.ok ? 200 : 400, resu)
355
+ },
356
+ }), 'dsh-messenger-gateway: pairing reject')
357
+
358
+ const webhookPath = () => {
359
+ const p = String(source().telegram?.webhookPath || '').trim()
360
+ return p || '/dsh-messenger-gateway/telegram/webhook'
361
+ }
362
+
363
+ ctx.effect(() => ctx.webServer.register({
364
+ kind: 'exact', path: webhookPath(),
365
+ handler: async (req, res) => {
366
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
367
+ const cfg = source().telegram || {}
368
+ if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
369
+ const secret = String(cfg.webhookSecret || '').trim()
370
+ if (secret) {
371
+ const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
372
+ if (hdr !== secret) return writeJson(res, 403, { ok: false, error: 'bad secret' })
373
+ }
374
+ const gw = getGw()
375
+ const adapter = gw?.getAdapter?.('telegram')
376
+ if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
377
+ let update
378
+ try { update = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch {
379
+ return writeJson(res, 400, { ok: false, error: 'invalid json' })
380
+ }
381
+ try {
382
+ await adapter.handleWebhookUpdate(update)
383
+ writeJson(res, 200, { ok: true })
384
+ } catch (err) {
385
+ writeJson(res, 500, { ok: false, error: err.message })
386
+ }
387
+ },
388
+ }), 'dsh-messenger-gateway: telegram webhook')
389
+
168
390
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send')
169
391
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress')
170
392
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask')
@@ -60,12 +60,12 @@ export function validateTarget(target) {
60
60
  throw err
61
61
  }
62
62
  const chatId = target.chatId
63
- if (chatId === undefined || chatId === null || String(chatId).trim() === '') {
64
- const err = new Error('target.chatId is required')
65
- err.status = 400
66
- throw err
63
+ const out = { platform }
64
+ if (chatId !== undefined && chatId !== null && String(chatId).trim() !== '') {
65
+ out.chatId = chatId
67
66
  }
68
- const out = { platform, chatId }
67
+ const home = String(target.home || target.name || '').trim()
68
+ if (home) out.home = home
69
69
  const threadId = normalizeThreadId(target.threadId)
70
70
  if (threadId > 0) out.threadId = threadId
71
71
  return out
package/lib/pairing.js ADDED
@@ -0,0 +1,110 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
6
+
7
+ export function generatePairingCode(length = 8) {
8
+ const bytes = randomBytes(length)
9
+ let out = ''
10
+ for (let i = 0; i < length; i++) out += ALPHABET[bytes[i] % ALPHABET.length]
11
+ return out
12
+ }
13
+
14
+ export function createPairingStore(filePath) {
15
+ const state = { pending: {}, /** @type {number[]} */ approved: [] }
16
+ if (filePath && existsSync(filePath)) {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'))
19
+ if (Array.isArray(raw.approved)) state.approved = raw.approved.map(Number).filter(Number.isFinite)
20
+ if (raw.pending && typeof raw.pending === 'object') state.pending = raw.pending
21
+ } catch {}
22
+ }
23
+ const persist = () => {
24
+ if (!filePath) return
25
+ mkdirSync(dirname(filePath), { recursive: true })
26
+ writeFileSync(filePath, JSON.stringify({ approved: state.approved, pending: state.pending }, null, 2))
27
+ }
28
+ const prune = () => {
29
+ const now = Date.now()
30
+ for (const [code, row] of Object.entries(state.pending)) {
31
+ if (!row || now > Number(row.expiresAt || 0)) delete state.pending[code]
32
+ }
33
+ }
34
+ return {
35
+ listApproved: () => [...state.approved],
36
+ listPending: () => {
37
+ prune()
38
+ return Object.entries(state.pending).map(([code, row]) => ({
39
+ code,
40
+ userId: Number(row.userId),
41
+ username: String(row.username || ''),
42
+ createdAt: Number(row.createdAt) || 0,
43
+ expiresAt: Number(row.expiresAt) || 0,
44
+ })).sort((a, b) => a.createdAt - b.createdAt)
45
+ },
46
+ isApproved: (userId) => state.approved.includes(Number(userId)),
47
+ addApproved: (userId) => {
48
+ const id = Number(userId)
49
+ if (!Number.isFinite(id)) return false
50
+ if (!state.approved.includes(id)) {
51
+ state.approved.push(id)
52
+ persist()
53
+ }
54
+ return true
55
+ },
56
+ requestCode(userId, meta = {}) {
57
+ prune()
58
+ const uid = Number(userId)
59
+ for (const [code, row] of Object.entries(state.pending)) {
60
+ if (Number(row.userId) === uid && Date.now() < Number(row.expiresAt)) return { code, reused: true, ...row }
61
+ }
62
+ const recent = Object.values(state.pending).find((r) => Number(r.userId) === uid && Date.now() - Number(r.createdAt || 0) < 600_000)
63
+ if (recent) {
64
+ const err = new Error('pairing rate limited')
65
+ err.code = 'RATE_LIMIT'
66
+ throw err
67
+ }
68
+ if (Object.keys(state.pending).length >= 20) {
69
+ const err = new Error('too many pending pairing codes')
70
+ err.code = 'FULL'
71
+ throw err
72
+ }
73
+ const code = generatePairingCode()
74
+ const row = {
75
+ userId: uid,
76
+ username: String(meta.username || ''),
77
+ createdAt: Date.now(),
78
+ expiresAt: Date.now() + 3_600_000,
79
+ }
80
+ state.pending[code] = row
81
+ persist()
82
+ return { code, reused: false, ...row }
83
+ },
84
+ approveCode(code, actorUserId) {
85
+ prune()
86
+ const key = String(code || '').trim().toUpperCase()
87
+ const row = state.pending[key]
88
+ if (!row) return { ok: false, error: 'unknown or expired code' }
89
+ if (Date.now() > Number(row.expiresAt)) {
90
+ delete state.pending[key]
91
+ persist()
92
+ return { ok: false, error: 'code expired' }
93
+ }
94
+ delete state.pending[key]
95
+ const id = Number(row.userId)
96
+ if (Number.isFinite(id) && !state.approved.includes(id)) state.approved.push(id)
97
+ persist()
98
+ return { ok: true, userId: row.userId, username: row.username, actorUserId: Number(actorUserId) }
99
+ },
100
+ rejectCode(code) {
101
+ prune()
102
+ const key = String(code || '').trim().toUpperCase()
103
+ if (!state.pending[key]) return { ok: false, error: 'unknown or expired code' }
104
+ const row = state.pending[key]
105
+ delete state.pending[key]
106
+ persist()
107
+ return { ok: true, userId: row.userId, username: row.username }
108
+ },
109
+ }
110
+ }