@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/README.md +68 -19
- package/lib/adapters/index.js +12 -2
- package/lib/adapters/telegram.js +324 -47
- package/lib/client.js +471 -46
- package/lib/commands.js +30 -0
- package/lib/config.js +75 -0
- package/lib/documents.js +19 -5
- package/lib/gateway.js +476 -55
- package/lib/groups.js +72 -0
- package/lib/homes.js +58 -0
- package/lib/index.js +261 -39
- package/lib/messenger-api.js +5 -5
- package/lib/pairing.js +110 -0
- package/lib/stream.js +64 -0
- package/lib/telegram-format.js +39 -0
- package/lib/text.js +18 -1
- package/lib/topics.js +23 -4
- package/lib/tts.js +82 -2
- package/lib/voice-prefs.js +44 -0
- package/package.json +15 -9
- package/docs/architecture/2026-08-23-messenger-gateway-design.md +0 -30
- package/docs/deployment/0.1.0-install.md +0 -28
- package/docs/testing/0.1.0-smoke.md +0 -12
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:
|
|
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
|
-
|
|
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')
|
package/lib/messenger-api.js
CHANGED
|
@@ -60,12 +60,12 @@ export function validateTarget(target) {
|
|
|
60
60
|
throw err
|
|
61
61
|
}
|
|
62
62
|
const chatId = target.chatId
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw err
|
|
63
|
+
const out = { platform }
|
|
64
|
+
if (chatId !== undefined && chatId !== null && String(chatId).trim() !== '') {
|
|
65
|
+
out.chatId = chatId
|
|
67
66
|
}
|
|
68
|
-
const
|
|
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
|
+
}
|