@goodandready/dsh-messenger-gateway 0.3.21 → 0.4.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/LICENSE +1 -1
- package/cordis.patch.yml +1 -1
- package/lib/adapters/discord.js +9 -1
- package/lib/adapters/slack.js +7 -1
- package/lib/adapters/telegram-inbound.js +141 -0
- package/lib/adapters/telegram.js +42 -154
- package/lib/alerts.js +104 -63
- package/lib/api-health.js +38 -0
- package/lib/artifacts.js +118 -118
- package/lib/ask.js +203 -152
- package/lib/client.js +1660 -1312
- package/lib/commands.js +53 -0
- package/lib/config.js +104 -104
- package/lib/content-guard.js +14 -14
- package/lib/documents.js +1 -1
- package/lib/file-manager.js +27 -1
- package/lib/forum-mirror.js +105 -0
- package/lib/gateway-callbacks.js +187 -0
- package/lib/gateway-commands.js +584 -0
- package/lib/gateway-turn.js +285 -0
- package/lib/gateway.js +590 -1737
- package/lib/http.js +12 -1
- package/lib/index.js +48 -8
- package/lib/integrations.js +1 -1
- package/lib/locales/en.js +123 -114
- package/lib/locales/index.js +21 -21
- package/lib/locales/zh.js +123 -114
- package/lib/media.js +1 -9
- package/lib/models.js +6 -3
- package/lib/outbound.js +1 -1
- package/lib/pairing.js +8 -4
- package/lib/personas.js +8 -4
- package/lib/photos.js +1 -1
- package/lib/scheduler.js +14 -4
- package/lib/stream.js +115 -115
- package/lib/text.js +39 -39
- package/lib/topics.js +0 -14
- package/lib/tts.js +0 -10
- package/lib/updater.js +304 -0
- package/lib/voice-prefs.js +8 -4
- package/package.json +69 -65
package/lib/commands.js
CHANGED
|
@@ -30,6 +30,7 @@ export const DEFAULT_TELEGRAM_COMMANDS = [
|
|
|
30
30
|
{ command: 'tts', description: 'Speech in this chat: /tts on|off|status' },
|
|
31
31
|
{ command: 'mute', description: 'Mute notifications in this chat' },
|
|
32
32
|
{ command: 'unmute', description: 'Unmute notifications in this chat' },
|
|
33
|
+
{ command: 'update', description: 'Check or install plugin update: /update [now]' },
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
export function normalizeTelegramCommands(commands) {
|
|
@@ -71,3 +72,55 @@ export function mergeDynamicCommands(baseCommands = DEFAULT_TELEGRAM_COMMANDS, d
|
|
|
71
72
|
return out.slice(0, maxTotal)
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
export function buildQuickActionsKeyboard() {
|
|
76
|
+
return {
|
|
77
|
+
keyboard: [
|
|
78
|
+
[{ text: '🔄 /new' }, { text: '🛑 /stop' }],
|
|
79
|
+
[{ text: '🎙️ /voice' }, { text: '📊 /status' }],
|
|
80
|
+
],
|
|
81
|
+
resize_keyboard: true,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const REMOVE_REPLY_KEYBOARD = { remove_keyboard: true }
|
|
86
|
+
|
|
87
|
+
export const HELP_TEXT = [
|
|
88
|
+
'📖 <b>Messenger Gateway Help:</b>',
|
|
89
|
+
'',
|
|
90
|
+
'💬 <b>Session & Chat:</b>',
|
|
91
|
+
'• /help — show this command reference',
|
|
92
|
+
'• /new — start fresh session',
|
|
93
|
+
'• /stop — interrupt current response',
|
|
94
|
+
'• /model — interactive model selector (/model list)',
|
|
95
|
+
'• /role [name] — switch persona or role (/role list)',
|
|
96
|
+
'• /bind [role] — bind persona to topic / chat',
|
|
97
|
+
'• /preset [name] — bind preset to topic / chat',
|
|
98
|
+
'• /lang [en|zh] — switch user language',
|
|
99
|
+
'• /rewind [N] — rewind last N turns',
|
|
100
|
+
'• /fork — fork session into new branch',
|
|
101
|
+
'• /export — export history to Markdown',
|
|
102
|
+
'',
|
|
103
|
+
'🛠️ <b>Tools, Files & Cron:</b>',
|
|
104
|
+
'• /skills / /tools — list active tools & skills',
|
|
105
|
+
'• /files [dir] — workspace file explorer',
|
|
106
|
+
'• /get <path> — download file from workspace',
|
|
107
|
+
'• /remind <time> <text> — set reminder (/remind 10m check deploy)',
|
|
108
|
+
'• /cron <interval> <prompt> — recurring autonomous task',
|
|
109
|
+
'',
|
|
110
|
+
'⚙️ <b>Settings & Stats:</b>',
|
|
111
|
+
'• /status — gateway and active model status',
|
|
112
|
+
'• /top — system resource usage (RAM, uptime)',
|
|
113
|
+
'• /keyboard on|off — quick action keyboard',
|
|
114
|
+
'• /voice on|off|status — voice replies preference',
|
|
115
|
+
'• /tts on|off|status — speech synthesis in this chat',
|
|
116
|
+
'• /mute / /unmute — mute notifications in this chat',
|
|
117
|
+
'',
|
|
118
|
+
'🔒 <b>Access & Channels:</b>',
|
|
119
|
+
'• /whoami — your messenger user ID',
|
|
120
|
+
'• /pair CODE — approve pairing code',
|
|
121
|
+
'• /sethome [name] — set home notification channel',
|
|
122
|
+
'• /setalert — set alert channel',
|
|
123
|
+
].join('\n')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
package/lib/config.js
CHANGED
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
import z from '@deepseek-ai/schemastery'
|
|
2
|
-
|
|
3
|
-
const HomeEntry = z.object({
|
|
4
|
-
name: z.string().default('default'),
|
|
5
|
-
chatId: z.union([z.number(), z.string()]),
|
|
6
|
-
threadId: z.number().default(0),
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
export const PluginConfig = z.object({
|
|
10
|
-
enabled: z.boolean().default(true),
|
|
11
|
-
internalBaseURL: z.string().default('http://127.0.0.1:3080'),
|
|
12
|
-
telegram: z.object({
|
|
13
|
-
enabled: z.boolean().default(false),
|
|
14
|
-
botToken: z.string().role('secret').default(''),
|
|
15
|
-
allowedUserIds: z.array(z.number()).default([]),
|
|
16
|
-
pollTimeoutSeconds: z.number().default(50),
|
|
17
|
-
pollIntervalMs: z.number().default(500),
|
|
18
|
-
commands: z.array(z.object({
|
|
19
|
-
command: z.string(),
|
|
20
|
-
description: z.string(),
|
|
21
|
-
})).default([]),
|
|
22
|
-
textFormat: z.union([z.const('html'), z.const('plain')]).default('html').description('html: Markdown→Telegram HTML; plain: unformatted plain text'),
|
|
23
|
-
homeChatId: z.union([z.number(), z.string()]).default('').description('Legacy default home chatId'),
|
|
24
|
-
homeThreadId: z.number().default(0),
|
|
25
|
-
homes: z.array(HomeEntry).default([]).description('Named homes (forum topics / channels)'),
|
|
26
|
-
pairingEnabled: z.boolean().default(true).description('Issue pairing codes to unknown users when allowlist is non-empty'),
|
|
27
|
-
streaming: z.boolean().default(false).description('If true, edit one Telegram message while tokens arrive; default off'),
|
|
28
|
-
streamEditIntervalMs: z.number().default(1200),
|
|
29
|
-
progressEnabled: z.boolean().default(true),
|
|
30
|
-
approvalsEnabled: z.boolean().default(true),
|
|
31
|
-
groupsEnabled: z.boolean().default(true).description('Process group/supergroup messages'),
|
|
32
|
-
groupRequireMention: z.boolean().default(true).description('In groups, only respond to @mention, reply-to-bot, or /commands'),
|
|
33
|
-
reactionsEnabled: z.boolean().default(true).description('React with 👀 while processing a turn'),
|
|
34
|
-
statusIndicator: z.boolean().default(false).description('Opt-in: set bot short description to Online/Offline (bots have no presence)'),
|
|
35
|
-
statusOnline: z.string().default('Online'),
|
|
36
|
-
statusOffline: z.string().default('Offline'),
|
|
37
|
-
transport: z.union([z.const('poll'), z.const('webhook')]).default('poll'),
|
|
38
|
-
webhookUrl: z.string().default(''),
|
|
39
|
-
webhookSecret: z.string().role('secret').default(''),
|
|
40
|
-
webhookPath: z.string().default('/dsh-messenger-gateway/telegram/webhook'),
|
|
41
|
-
voiceMode: z.union([z.const('mirror'), z.const('always'), z.const('off')]).default('mirror')
|
|
42
|
-
.description('mirror: TTS when inbound was voice; always/off override (per-user /voice wins)'),
|
|
43
|
-
quickActions: z.boolean().default(false).description('Show quick actions keyboard in Telegram (/new, /stop, /voice, /status)'),
|
|
44
|
-
artifactPreviews: z.boolean().default(true).description('Render diagrams and formatted tables as previews'),
|
|
45
|
-
notifyBridge: z.object({
|
|
46
|
-
enabled: z.boolean().default(false),
|
|
47
|
-
events: z.array(z.string()).default(['task_done', 'error']),
|
|
48
|
-
home: z.string().default('default'),
|
|
49
|
-
excludeSessionPrefixes: z.array(z.string()).default(['msgw-']),
|
|
50
|
-
}).default({ enabled: false }),
|
|
51
|
-
alerts: z.object({
|
|
52
|
-
enabled: z.boolean().default(false),
|
|
53
|
-
chatId: z.union([z.number(), z.string()]).default(''),
|
|
54
|
-
threadId: z.number().default(0),
|
|
55
|
-
home: z.string().default(''),
|
|
56
|
-
events: z.array(z.string()).default(['error', 'pairing']),
|
|
57
|
-
}).default({ enabled: false }),
|
|
58
|
-
forumMirror: z.object({
|
|
59
|
-
enabled: z.boolean().default(false),
|
|
60
|
-
chatId: z.union([z.number(), z.string()]).default(''),
|
|
61
|
-
}).default({ enabled: false }),
|
|
62
|
-
forumMirrorChatId: z.union([z.number(), z.string()]).default(''),
|
|
63
|
-
forumMirrorEnabled: z.boolean().default(false),
|
|
64
|
-
}),
|
|
65
|
-
discord: z.object({
|
|
66
|
-
enabled: z.boolean().default(false),
|
|
67
|
-
botToken: z.string().role('secret').default(''),
|
|
68
|
-
webhookUrl: z.string().default(''),
|
|
69
|
-
}).default({ enabled: false }),
|
|
70
|
-
slack: z.object({
|
|
71
|
-
enabled: z.boolean().default(false),
|
|
72
|
-
botToken: z.string().role('secret').default(''),
|
|
73
|
-
webhookUrl: z.string().default(''),
|
|
74
|
-
}).default({ enabled: false }),
|
|
75
|
-
webhooks: z.object({
|
|
76
|
-
enabled: z.boolean().default(true),
|
|
77
|
-
secret: z.string().role('secret').default(''),
|
|
78
|
-
}).default({ enabled: true }),
|
|
79
|
-
media: z.object({
|
|
80
|
-
cacheDir: z.string().default(''),
|
|
81
|
-
maxDocBytes: z.number().default(20 * 1024 * 1024),
|
|
82
|
-
maxTextInjectBytes: z.number().default(100 * 1024),
|
|
83
|
-
maxImageBytes: z.number().default(20 * 1024 * 1024),
|
|
84
|
-
}),
|
|
85
|
-
tts: z.object({
|
|
86
|
-
enabled: z.boolean().default(false),
|
|
87
|
-
maxChars: z.number().default(4000),
|
|
88
|
-
voiceSummary: z.boolean().default(false).description('Speak concise TL;DR summary while full text is sent to chat'),
|
|
89
|
-
}),
|
|
90
|
-
agent: z.object({
|
|
91
|
-
provider: z.string().default(''),
|
|
92
|
-
model: z.string().default(''),
|
|
93
|
-
cwd: z.string().default(''),
|
|
94
|
-
instructionPrefix: z.string().default(''),
|
|
95
|
-
maxMessageLength: z.number().default(4000),
|
|
96
|
-
idleTimeoutMs: z.number().default(3_600_000),
|
|
97
|
-
turnTimeoutMs: z.number().default(600_000),
|
|
98
|
-
photoOnlyMode: z.union([z.const('prompt'), z.const('run')]).default('prompt'),
|
|
99
|
-
sessionScope: z.union([z.const('chat'), z.const('user')]).default('user')
|
|
100
|
-
.description('In groups: user = per-user session; chat = shared session for the chat/topic'),
|
|
101
|
-
}),
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
export const Config = PluginConfig
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
|
|
3
|
+
const HomeEntry = z.object({
|
|
4
|
+
name: z.string().default('default'),
|
|
5
|
+
chatId: z.union([z.number(), z.string()]),
|
|
6
|
+
threadId: z.number().default(0),
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export const PluginConfig = z.object({
|
|
10
|
+
enabled: z.boolean().default(true),
|
|
11
|
+
internalBaseURL: z.string().default('http://127.0.0.1:3080'),
|
|
12
|
+
telegram: z.object({
|
|
13
|
+
enabled: z.boolean().default(false),
|
|
14
|
+
botToken: z.string().role('secret').default(''),
|
|
15
|
+
allowedUserIds: z.array(z.number()).default([]),
|
|
16
|
+
pollTimeoutSeconds: z.number().default(50),
|
|
17
|
+
pollIntervalMs: z.number().default(500),
|
|
18
|
+
commands: z.array(z.object({
|
|
19
|
+
command: z.string(),
|
|
20
|
+
description: z.string(),
|
|
21
|
+
})).default([]),
|
|
22
|
+
textFormat: z.union([z.const('html'), z.const('plain')]).default('html').description('html: Markdown→Telegram HTML; plain: unformatted plain text'),
|
|
23
|
+
homeChatId: z.union([z.number(), z.string()]).default('').description('Legacy default home chatId'),
|
|
24
|
+
homeThreadId: z.number().default(0),
|
|
25
|
+
homes: z.array(HomeEntry).default([]).description('Named homes (forum topics / channels)'),
|
|
26
|
+
pairingEnabled: z.boolean().default(true).description('Issue pairing codes to unknown users when allowlist is non-empty'),
|
|
27
|
+
streaming: z.boolean().default(false).description('If true, edit one Telegram message while tokens arrive; default off'),
|
|
28
|
+
streamEditIntervalMs: z.number().default(1200),
|
|
29
|
+
progressEnabled: z.boolean().default(true),
|
|
30
|
+
approvalsEnabled: z.boolean().default(true),
|
|
31
|
+
groupsEnabled: z.boolean().default(true).description('Process group/supergroup messages'),
|
|
32
|
+
groupRequireMention: z.boolean().default(true).description('In groups, only respond to @mention, reply-to-bot, or /commands'),
|
|
33
|
+
reactionsEnabled: z.boolean().default(true).description('React with 👀 while processing a turn'),
|
|
34
|
+
statusIndicator: z.boolean().default(false).description('Opt-in: set bot short description to Online/Offline (bots have no presence)'),
|
|
35
|
+
statusOnline: z.string().default('Online'),
|
|
36
|
+
statusOffline: z.string().default('Offline'),
|
|
37
|
+
transport: z.union([z.const('poll'), z.const('webhook')]).default('poll'),
|
|
38
|
+
webhookUrl: z.string().default(''),
|
|
39
|
+
webhookSecret: z.string().role('secret').default(''),
|
|
40
|
+
webhookPath: z.string().default('/dsh-messenger-gateway/telegram/webhook'),
|
|
41
|
+
voiceMode: z.union([z.const('mirror'), z.const('always'), z.const('off')]).default('mirror')
|
|
42
|
+
.description('mirror: TTS when inbound was voice; always/off override (per-user /voice wins)'),
|
|
43
|
+
quickActions: z.boolean().default(false).description('Show quick actions keyboard in Telegram (/new, /stop, /voice, /status)'),
|
|
44
|
+
artifactPreviews: z.boolean().default(true).description('Render diagrams and formatted tables as previews'),
|
|
45
|
+
notifyBridge: z.object({
|
|
46
|
+
enabled: z.boolean().default(false),
|
|
47
|
+
events: z.array(z.string()).default(['task_done', 'error']),
|
|
48
|
+
home: z.string().default('default'),
|
|
49
|
+
excludeSessionPrefixes: z.array(z.string()).default(['msgw-']),
|
|
50
|
+
}).default({ enabled: false }),
|
|
51
|
+
alerts: z.object({
|
|
52
|
+
enabled: z.boolean().default(false),
|
|
53
|
+
chatId: z.union([z.number(), z.string()]).default(''),
|
|
54
|
+
threadId: z.number().default(0),
|
|
55
|
+
home: z.string().default(''),
|
|
56
|
+
events: z.array(z.string()).default(['error', 'pairing']),
|
|
57
|
+
}).default({ enabled: false }),
|
|
58
|
+
forumMirror: z.object({
|
|
59
|
+
enabled: z.boolean().default(false),
|
|
60
|
+
chatId: z.union([z.number(), z.string()]).default(''),
|
|
61
|
+
}).default({ enabled: false }),
|
|
62
|
+
forumMirrorChatId: z.union([z.number(), z.string()]).default(''),
|
|
63
|
+
forumMirrorEnabled: z.boolean().default(false),
|
|
64
|
+
}),
|
|
65
|
+
discord: z.object({
|
|
66
|
+
enabled: z.boolean().default(false),
|
|
67
|
+
botToken: z.string().role('secret').default(''),
|
|
68
|
+
webhookUrl: z.string().default(''),
|
|
69
|
+
}).default({ enabled: false }),
|
|
70
|
+
slack: z.object({
|
|
71
|
+
enabled: z.boolean().default(false),
|
|
72
|
+
botToken: z.string().role('secret').default(''),
|
|
73
|
+
webhookUrl: z.string().default(''),
|
|
74
|
+
}).default({ enabled: false }),
|
|
75
|
+
webhooks: z.object({
|
|
76
|
+
enabled: z.boolean().default(true),
|
|
77
|
+
secret: z.string().role('secret').default(''),
|
|
78
|
+
}).default({ enabled: true }),
|
|
79
|
+
media: z.object({
|
|
80
|
+
cacheDir: z.string().default(''),
|
|
81
|
+
maxDocBytes: z.number().default(20 * 1024 * 1024),
|
|
82
|
+
maxTextInjectBytes: z.number().default(100 * 1024),
|
|
83
|
+
maxImageBytes: z.number().default(20 * 1024 * 1024),
|
|
84
|
+
}),
|
|
85
|
+
tts: z.object({
|
|
86
|
+
enabled: z.boolean().default(false),
|
|
87
|
+
maxChars: z.number().default(4000),
|
|
88
|
+
voiceSummary: z.boolean().default(false).description('Speak concise TL;DR summary while full text is sent to chat'),
|
|
89
|
+
}),
|
|
90
|
+
agent: z.object({
|
|
91
|
+
provider: z.string().default(''),
|
|
92
|
+
model: z.string().default(''),
|
|
93
|
+
cwd: z.string().default(''),
|
|
94
|
+
instructionPrefix: z.string().default(''),
|
|
95
|
+
maxMessageLength: z.number().default(4000),
|
|
96
|
+
idleTimeoutMs: z.number().default(3_600_000),
|
|
97
|
+
turnTimeoutMs: z.number().default(600_000),
|
|
98
|
+
photoOnlyMode: z.union([z.const('prompt'), z.const('run')]).default('prompt'),
|
|
99
|
+
sessionScope: z.union([z.const('chat'), z.const('user')]).default('user')
|
|
100
|
+
.description('In groups: user = per-user session; chat = shared session for the chat/topic'),
|
|
101
|
+
}),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const Config = PluginConfig
|
package/lib/content-guard.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Normalize any content value to ContentBlock[].
|
|
3
|
-
* DSH core (dsh-llm) calls content.some() without Array.isArray guard,
|
|
4
|
-
* so we must ensure every message we create or copy has array content.
|
|
5
|
-
* Refs: #51
|
|
6
|
-
*/
|
|
7
|
-
export function ensureContentArray(content) {
|
|
8
|
-
if (Array.isArray(content)) return content
|
|
9
|
-
if (typeof content === 'string') {
|
|
10
|
-
return content ? [{ type: 'text', text: content }] : [{ type: 'text', text: '(empty message)' }]
|
|
11
|
-
}
|
|
12
|
-
if (content && typeof content === 'object' && content.type) return [content]
|
|
13
|
-
return [{ type: 'text', text: String(content ?? '(empty message)') }]
|
|
14
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Normalize any content value to ContentBlock[].
|
|
3
|
+
* DSH core (dsh-llm) calls content.some() without Array.isArray guard,
|
|
4
|
+
* so we must ensure every message we create or copy has array content.
|
|
5
|
+
* Refs: #51
|
|
6
|
+
*/
|
|
7
|
+
export function ensureContentArray(content) {
|
|
8
|
+
if (Array.isArray(content)) return content
|
|
9
|
+
if (typeof content === 'string') {
|
|
10
|
+
return content ? [{ type: 'text', text: content }] : [{ type: 'text', text: '(empty message)' }]
|
|
11
|
+
}
|
|
12
|
+
if (content && typeof content === 'object' && content.type) return [content]
|
|
13
|
+
return [{ type: 'text', text: String(content ?? '(empty message)') }]
|
|
14
|
+
}
|
package/lib/documents.js
CHANGED
package/lib/file-manager.js
CHANGED
|
@@ -160,4 +160,30 @@ export async function getFileForDownload(baseCwd, relativePath, maxBytes = 50 *
|
|
|
160
160
|
size: st.size,
|
|
161
161
|
mime,
|
|
162
162
|
}
|
|
163
|
-
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function handleFilesCommand(agentCwd, subPath = '.') {
|
|
166
|
+
const res = await listFiles(agentCwd, subPath)
|
|
167
|
+
if (!res.ok) return { text: `❌ ${res.error}` }
|
|
168
|
+
return { text: res.formattedText }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function handleGetCommand(agentCwd, targetRel, maxDocBytes = 50 * 1024 * 1024) {
|
|
172
|
+
if (!targetRel) {
|
|
173
|
+
return { text: 'Specify file path to download: <code>/get <path></code>\nBrowse: <code>/files</code>' }
|
|
174
|
+
}
|
|
175
|
+
const res = await getFileForDownload(agentCwd, targetRel, maxDocBytes)
|
|
176
|
+
if (!res.ok) return { text: `❌ ${res.error}` }
|
|
177
|
+
const file = {
|
|
178
|
+
name: res.name,
|
|
179
|
+
mime: res.mime,
|
|
180
|
+
kind: 'document',
|
|
181
|
+
bytes: res.bytes,
|
|
182
|
+
dataBase64: res.bytes.toString('base64'),
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
text: `📄 File: <b>${res.name}</b> (${formatFileSize(res.size)})`,
|
|
186
|
+
files: [file],
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { mergeDynamicCommands } from './commands.js'
|
|
2
|
+
import { t } from './locales/index.js'
|
|
3
|
+
import { stripReasoningPreamble, splitText } from './text.js'
|
|
4
|
+
import { stripImageUrls } from './outbound.js'
|
|
5
|
+
|
|
6
|
+
export function collectDynamicSkills(ctx) {
|
|
7
|
+
const skills = []
|
|
8
|
+
try {
|
|
9
|
+
const skillsService = ctx.get?.('skills') || gw.ctx.skills
|
|
10
|
+
const allSkills = skillsService?.list?.() || []
|
|
11
|
+
for (const s of allSkills) {
|
|
12
|
+
if (!s || !s.name) continue
|
|
13
|
+
if (s.userInvocable === false) continue
|
|
14
|
+
skills.push({
|
|
15
|
+
name: String(s.name),
|
|
16
|
+
description: String(s.description || s.title || `Skill ${s.name}`).slice(0, 256),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
} catch (err) {
|
|
20
|
+
ctx.logger?.debug?.(`Failed to collect dynamic skills: ${err?.message || err}`)
|
|
21
|
+
}
|
|
22
|
+
return skills
|
|
23
|
+
}
|
|
24
|
+
export async function syncTelegramCommands(gw) {
|
|
25
|
+
const tgAdapter = gw.adapters.get('telegram')
|
|
26
|
+
if (!tgAdapter || typeof tgAdapter.registerCommands !== 'function') return
|
|
27
|
+
const baseCommands = gw.config.telegram?.commands || []
|
|
28
|
+
const dynamicSkills = collectDynamicSkills(gw.ctx)
|
|
29
|
+
const merged = mergeDynamicCommands(baseCommands, dynamicSkills, 100)
|
|
30
|
+
try {
|
|
31
|
+
await tgAdapter.registerCommands(merged)
|
|
32
|
+
ctx.logger?.info?.(`Synced ${merged.length} Telegram commands (including ${dynamicSkills.length} dynamic skills)`)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
ctx.logger?.warn?.(`Failed to sync Telegram commands: ${err?.message || err}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function mirrorSessionToForumTopic(gw, session) {
|
|
38
|
+
if (!session || !session.id) return
|
|
39
|
+
const sessionId = String(session.id)
|
|
40
|
+
if (sessionId.startsWith('msgw-')) return
|
|
41
|
+
if (gw.sessionToThread.has(sessionId)) return
|
|
42
|
+
|
|
43
|
+
const tgCfg = gw.config.telegram || {}
|
|
44
|
+
if (!tgCfg.forumMirrorEnabled || !tgCfg.forumMirrorChatId) return
|
|
45
|
+
|
|
46
|
+
const tgAdapter = gw.adapters.get('telegram')
|
|
47
|
+
if (!tgAdapter || typeof tgAdapter.createForumTopic !== 'function') return
|
|
48
|
+
|
|
49
|
+
const forumChatId = tgCfg.forumMirrorChatId
|
|
50
|
+
const title = String(session.title || session.meta?.name || `Session ${sessionId.slice(0, 8)}`).slice(0, 120)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const topic = await tgAdapter.createForumTopic(forumChatId, title)
|
|
54
|
+
const threadId = topic?.message_thread_id
|
|
55
|
+
if (!threadId) return
|
|
56
|
+
|
|
57
|
+
const threadKey = `${forumChatId}:${threadId}`
|
|
58
|
+
gw.sessionToThread.set(sessionId, { chatId: forumChatId, threadId })
|
|
59
|
+
gw.threadToSession.set(threadKey, sessionId)
|
|
60
|
+
|
|
61
|
+
const text = t('mirror.created', { sessionId, title }, 'en')
|
|
62
|
+
await tgAdapter.sendTo(forumChatId, { text }, { threadId })
|
|
63
|
+
ctx.logger?.info?.(`Mirrored session ${sessionId} to Telegram forum topic ${threadId} in ${forumChatId}`)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
ctx.logger?.warn?.(`Failed to mirror session ${sessionId} to forum topic: ${err?.message || err}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function relayTurnToForumMirror(gw, session, event) {
|
|
69
|
+
if (!session || !session.id) return
|
|
70
|
+
const sessionId = String(session.id)
|
|
71
|
+
const threadInfo = gw.sessionToThread.get(sessionId)
|
|
72
|
+
if (!threadInfo) return
|
|
73
|
+
if (gw.pending.has(sessionId)) return
|
|
74
|
+
|
|
75
|
+
const tgAdapter = gw.adapters.get('telegram')
|
|
76
|
+
if (!tgAdapter) return
|
|
77
|
+
|
|
78
|
+
const messages = Array.isArray(session.messages)
|
|
79
|
+
? session.messages
|
|
80
|
+
: (Array.isArray(session.history) ? session.history : [])
|
|
81
|
+
|
|
82
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
83
|
+
const msg = messages[i]
|
|
84
|
+
const role = msg.role || (msg.type === 'user' ? 'user' : 'assistant')
|
|
85
|
+
if (role === 'assistant') {
|
|
86
|
+
let text = ''
|
|
87
|
+
if (typeof msg.content === 'string') text = msg.content
|
|
88
|
+
else if (Array.isArray(msg.content)) {
|
|
89
|
+
text = msg.content
|
|
90
|
+
.map((p) => (typeof p === 'string' ? p : (p?.text || '')))
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n')
|
|
93
|
+
} else if (msg.text) text = msg.text
|
|
94
|
+
const clean = stripReasoningPreamble(stripImageUrls(text)).trim()
|
|
95
|
+
if (clean) {
|
|
96
|
+
const maxLen = Number(gw.config.agent?.maxMessageLength) || 4000
|
|
97
|
+
const chunks = splitText(clean, maxLen)
|
|
98
|
+
for (const chunk of chunks) {
|
|
99
|
+
await tgAdapter.sendTo(threadInfo.chatId, { text: chunk }, { threadId: threadInfo.threadId })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { t } from './locales/index.js'
|
|
2
|
+
import {
|
|
3
|
+
parseCallbackData, parseAskCallback, targetMatchesAsk,
|
|
4
|
+
releaseCallbacks, buildMultiSelectKeyboard, indexCallbacks, REMOVE_KEYBOARD
|
|
5
|
+
} from './ask.js'
|
|
6
|
+
import {
|
|
7
|
+
listModelCatalog, buildProvidersKeyboard, buildModelsKeyboard, storeModelSelection
|
|
8
|
+
} from './models.js'
|
|
9
|
+
|
|
10
|
+
export async function handleGatewayCallback(gw, cb) {
|
|
11
|
+
if (cb.userId && !gw.isUserAllowed(cb.userId)) {
|
|
12
|
+
try { await cb.answer(t('msg.not_allowed', {}, 'en')) } catch (err) { gw.recordApiFailure('cb.answer.not_allowed', err) }
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
const indexed = gw.callbackIndex.get(cb.data)
|
|
16
|
+
const { token, buttonId } = parseCallbackData(cb.data)
|
|
17
|
+
const askToken = indexed || token
|
|
18
|
+
if (askToken && gw.pendingAsks.has(askToken)) {
|
|
19
|
+
const pending = gw.pendingAsks.get(askToken)
|
|
20
|
+
if (!targetMatchesAsk(pending, cb)) {
|
|
21
|
+
await cb.answer(t('ask.other_chat', {}, 'en'))
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const action = parseAskCallback(cb.data)
|
|
25
|
+
if (pending.isMulti) {
|
|
26
|
+
if (action.kind === 'toggle') {
|
|
27
|
+
if (pending.selected.has(action.id)) pending.selected.delete(action.id)
|
|
28
|
+
else pending.selected.add(action.id)
|
|
29
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
30
|
+
const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
|
|
31
|
+
pending.callbackKeys = nextKb.callbackKeys
|
|
32
|
+
indexCallbacks(gw.callbackIndex, nextKb.callbackKeys, askToken)
|
|
33
|
+
try {
|
|
34
|
+
if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
|
|
35
|
+
else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
gw.recordApiFailure('cb.editMessage.ask_multi_toggle', err)
|
|
38
|
+
}
|
|
39
|
+
await cb.answer(pending.selected.has(action.id) ? 'Selected' : 'Deselected')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
if (action.kind === 'page') {
|
|
43
|
+
pending.page = action.page
|
|
44
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
45
|
+
const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
|
|
46
|
+
pending.callbackKeys = nextKb.callbackKeys
|
|
47
|
+
indexCallbacks(gw.callbackIndex, nextKb.callbackKeys, askToken)
|
|
48
|
+
try {
|
|
49
|
+
if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
|
|
50
|
+
else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
gw.recordApiFailure('cb.editMessage.ask_multi_page', err)
|
|
53
|
+
}
|
|
54
|
+
await cb.answer()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (action.kind === 'done') {
|
|
58
|
+
gw.pendingAsks.delete(askToken)
|
|
59
|
+
clearTimeout(pending.timer)
|
|
60
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
61
|
+
await cb.answer('OK')
|
|
62
|
+
try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_multi_done', err) }
|
|
63
|
+
pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (action.kind === 'cancel') {
|
|
67
|
+
gw.pendingAsks.delete(askToken)
|
|
68
|
+
clearTimeout(pending.timer)
|
|
69
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
70
|
+
await cb.answer('Cancelled')
|
|
71
|
+
try { await cb.editMessage(cb.message?.text || 'Cancelled', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_multi_cancel', err) }
|
|
72
|
+
pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
gw.pendingAsks.delete(askToken)
|
|
77
|
+
clearTimeout(pending.timer)
|
|
78
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
79
|
+
await cb.answer('OK')
|
|
80
|
+
try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_single_done', err) }
|
|
81
|
+
pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Model picker interactive flow
|
|
86
|
+
if (cb.data?.startsWith('mdl:')) {
|
|
87
|
+
const parts = cb.data.split(':')
|
|
88
|
+
const sub = parts[1]
|
|
89
|
+
// Step 2: Selected provider -> show its models (10 per page)
|
|
90
|
+
if (sub === 'p') {
|
|
91
|
+
const providerId = parts.slice(2).join(':')
|
|
92
|
+
const current = gw.resolveAgentModel()
|
|
93
|
+
const catalog = await listModelCatalog(gw.ctx, current)
|
|
94
|
+
const models = catalog.modelsByProvider.get(providerId) || []
|
|
95
|
+
if (!models.length) {
|
|
96
|
+
await cb.answer(t('model.no_models', {}, 'en'))
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const kb = buildModelsKeyboard(providerId, models, current.model, 0)
|
|
100
|
+
await cb.answer()
|
|
101
|
+
const text = [
|
|
102
|
+
`🤖 <b>Provider:</b> <code>${providerId}</code>`,
|
|
103
|
+
`Select model (page ${kb.page + 1}/${kb.totalPages}):`,
|
|
104
|
+
].join('\n')
|
|
105
|
+
try {
|
|
106
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
gw.recordApiFailure('cb.editMessage.model_picker_provider', err)
|
|
109
|
+
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
// Pagination for models
|
|
113
|
+
if (sub === 'pg') {
|
|
114
|
+
const page = parseInt(parts[parts.length - 1], 10) || 0
|
|
115
|
+
const providerId = parts.slice(2, -1).join(':')
|
|
116
|
+
const current = gw.resolveAgentModel()
|
|
117
|
+
const catalog = await listModelCatalog(gw.ctx, current)
|
|
118
|
+
const models = catalog.modelsByProvider.get(providerId) || []
|
|
119
|
+
const kb = buildModelsKeyboard(providerId, models, current.model, page)
|
|
120
|
+
await cb.answer()
|
|
121
|
+
const text = [
|
|
122
|
+
`🤖 <b>Provider:</b> <code>${providerId}</code>`,
|
|
123
|
+
`Select model (page ${kb.page + 1}/${kb.totalPages}):`,
|
|
124
|
+
].join('\n')
|
|
125
|
+
try {
|
|
126
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
gw.recordApiFailure('cb.editMessage.model_picker_page', err)
|
|
129
|
+
}
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// Back to providers
|
|
133
|
+
if (sub === 'back') {
|
|
134
|
+
const current = gw.resolveAgentModel()
|
|
135
|
+
const catalog = await listModelCatalog(gw.ctx, current)
|
|
136
|
+
const kb = buildProvidersKeyboard(catalog.providers, current)
|
|
137
|
+
await cb.answer()
|
|
138
|
+
const text = [
|
|
139
|
+
'🤖 <b>Choose Provider:</b>',
|
|
140
|
+
`Current: <code>${current.provider}/${current.model}</code>`,
|
|
141
|
+
].join('\n')
|
|
142
|
+
try {
|
|
143
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
gw.recordApiFailure('cb.editMessage.model_picker_back', err)
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
// Select model
|
|
150
|
+
if (sub === 's') {
|
|
151
|
+
const key = parts[2]
|
|
152
|
+
const stored = getStoredModelSelection(key)
|
|
153
|
+
if (!stored) {
|
|
154
|
+
await cb.answer(t('ask.expired', {}, 'en'))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const { provider, model } = stored
|
|
158
|
+
try {
|
|
159
|
+
const adm = gw.ctx.get('agentDefaultModel')
|
|
160
|
+
if (adm?.saveSelection) {
|
|
161
|
+
await adm.saveSelection({ provider, model })
|
|
162
|
+
}
|
|
163
|
+
gw.config.agent = { ...gw.config.agent, provider, model }
|
|
164
|
+
try {
|
|
165
|
+
await gw.hooks?.persistAgentModel?.({ provider, model })
|
|
166
|
+
} catch (e) {
|
|
167
|
+
gw.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
|
|
168
|
+
}
|
|
169
|
+
await cb.answer(t('ask.chose', { choice: model }, 'en'))
|
|
170
|
+
try {
|
|
171
|
+
if (cb.editMessage) await cb.editMessage(t('model.switched', { provider, model }, 'en'), REMOVE_KEYBOARD)
|
|
172
|
+
} catch (err) {
|
|
173
|
+
gw.recordApiFailure('cb.editMessage.model_picker_switched', err)
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
await cb.answer(`Error: ${err.message}`)
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (sub === 'cur') {
|
|
181
|
+
await cb.answer()
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await cb.answer()
|
|
187
|
+
}
|