@goodandready/dsh-messenger-gateway 0.1.0 → 0.3.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/client.js CHANGED
@@ -1,21 +1,245 @@
1
+ // dsh-messenger-gateway — browser (client) half.
2
+ // Settings card on Plugins → Plugin settings tab (settings.plugin.item).
3
+ // Fallback to sidebar settings.section when the slot is unavailable.
4
+
1
5
  window.__ModuleLoader__.load({
2
6
  id: '@goodandready/dsh-messenger-gateway',
3
7
  factory: (require) => {
4
8
  var module = { exports: {} }
5
9
  var exports = module.exports
10
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
6
11
  const React = require('react')
7
12
 
13
+ const NS = 'dsh-messenger-gateway'
14
+
15
+ const css =
16
+ '.msgw-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
17
+ '.msgw-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
18
+ '.msgw-headText{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
19
+ '.msgw-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
20
+ '.msgw-sub{color:var(--dsw-alias-label-secondary);font-size:13px}' +
21
+ '.msgw-chev{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6}' +
22
+ '.msgw-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
23
+ '.msgw-form{display:flex;flex-direction:column;max-width:760px}' +
24
+ '.msgw-section{display:flex;flex-direction:column}' +
25
+ '.msgw-groupTitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;padding:12px 0 0}' +
26
+ '.msgw-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}' +
27
+ '.msgw-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}' +
28
+ '.msgw-hint{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
29
+ '.msgw-input,.msgw-select,.msgw-textarea{width:100%;box-sizing:border-box;height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font:inherit;font-size:13px}' +
30
+ '.msgw-textarea{height:auto;min-height:72px;padding:8px 12px}' +
31
+ '.msgw-check{display:flex;gap:8px;align-items:center;padding:12px 0;color:var(--dsw-alias-label-primary);font-size:13px}' +
32
+ '.msgw-linkbtn{appearance:none;font:inherit;cursor:pointer;background:0 0;border:0;color:var(--dsw-alias-label-secondary);font-size:13px;padding:8px 0;text-align:left}' +
33
+ '.msgw-err{color:var(--dsw-alias-state-error-primary);font-size:12px;padding:8px 0}' +
34
+ '.msgw-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}' +
35
+ '.msgw-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
36
+ '.msgw-save:disabled{opacity:.5;cursor:default}' +
37
+ '.msgw-ghost{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 14px;font-size:13px;background:0 0;color:var(--dsw-alias-label-primary)}' +
38
+ '.msgw-row{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:13px;color:var(--dsw-alias-label-primary)}' +
39
+ '.msgw-rowActions{display:flex;gap:6px;flex-shrink:0}' +
40
+ '.msgw-mini{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 10px;font-size:12px;background:0 0;color:var(--dsw-alias-label-primary)}'
41
+ const tagId = 'dsh-messenger-gateway/settings-card.module.css'
42
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']')) {
43
+ const tag = document.createElement('style')
44
+ tag.dataset.plugin = NS
45
+ tag.dataset.pluginCss = tagId
46
+ tag.textContent = css
47
+ document.head.appendChild(tag)
48
+ }
49
+
50
+ const en = {
51
+ title: 'Messenger gateway',
52
+ description: 'Telegram bot: text, voice, photos and documents.',
53
+ loading: 'Loading…',
54
+ save: 'Save',
55
+ saving: 'Saving…',
56
+ showAdvanced: 'Advanced settings',
57
+ hideAdvanced: 'Hide advanced',
58
+ telegram: 'Telegram',
59
+ agent: 'Agent',
60
+ media: 'Media',
61
+ tts: 'Voice replies',
62
+ advanced: 'Telegram polling',
63
+ botToken: 'Bot token (write-only)',
64
+ botTokenHint: 'Leave empty to keep the current token',
65
+ allowedIds: 'Allowed user ids',
66
+ allowedIdsHint: 'Comma-separated. Empty = all users',
67
+ textFormat: 'Text format',
68
+ textFormatHtml: 'Markdown → Telegram HTML',
69
+ textFormatPlain: 'Plain text',
70
+ disableTelegram: 'Disable Telegram',
71
+ enableTelegram: 'Enable Telegram',
72
+ provider: 'Provider',
73
+ providerHint: 'Empty = DSH default',
74
+ model: 'Model',
75
+ modelHint: 'Empty = DSH default',
76
+ photoOnly: 'Photo without caption',
77
+ photoPrompt: 'Wait for question (recommended)',
78
+ photoRun: 'Reply immediately',
79
+ instruction: 'Extra agent instruction',
80
+ instructionHint: 'Prepended to each messenger message',
81
+ instructionPlaceholder: 'Empty = built-in relay (Russian, no reasoning)',
82
+ maxMessageLength: 'Max reply length (chars)',
83
+ turnTimeout: 'Agent turn timeout (sec)',
84
+ maxDocMb: 'Max document size (MB)',
85
+ maxImageMb: 'Max photo size (MB)',
86
+ maxTextKb: 'Max text from document (KB)',
87
+ ttsEnable: 'Voice replies via dsh-tts',
88
+ ttsMaxChars: 'Max spoken chars',
89
+ pollTimeout: 'pollTimeoutSeconds',
90
+ pollInterval: 'pollIntervalMs',
91
+ idleTimeout: 'idle timeout (sec)',
92
+ pairing: 'Pairing requests',
93
+ pairingEmpty: 'No pending codes',
94
+ pairingApprove: 'Approve',
95
+ pairingReject: 'Reject',
96
+ pairingRefresh: 'Refresh',
97
+ groupsEnable: 'Respond in groups',
98
+ groupMention: 'Groups require @mention / reply / command',
99
+ reactionsEnable: 'React while processing (👀)',
100
+ progressEnable: 'Progress message (thinking → delete → final)',
101
+ statusIndicator: 'Status indicator (Online/Offline)',
102
+ statusIndicatorHint: 'Sets the bot short description; bots have no presence dot. Opt-in, visible to all users.',
103
+ transport: 'Transport',
104
+ transportPoll: 'Long poll (getUpdates)',
105
+ transportWebhook: 'Webhook',
106
+ webhookUrl: 'Public webhook URL',
107
+ webhookUrlHint: 'Full HTTPS URL Telegram will POST to',
108
+ webhookSecret: 'Webhook secret (write-only)',
109
+ webhookSecretHint: 'Leave empty to keep current',
110
+ sessionScope: 'Group session scope',
111
+ sessionScopeChat: 'Shared per chat/topic',
112
+ sessionScopeUser: 'Per user (recommended)',
113
+ voiceMode: 'Voice replies mode',
114
+ voiceModeMirror: 'Mirror inbound voice',
115
+ voiceModeAlways: 'Always try TTS',
116
+ voiceModeOff: 'Off (unless /voice on)',
117
+ notifyBridge: 'Notify → Telegram home',
118
+ notifyBridgeHint: 'task_done/error from web sessions (not msgw-*)',
119
+ notifyHome: 'Notify home name',
120
+ }
121
+ const ru = {
122
+ title: 'Messenger gateway',
123
+ description: 'Telegram-бот: текст, голос, фото и документы.',
124
+ loading: 'Загрузка…',
125
+ save: 'Сохранить',
126
+ saving: 'Сохранение…',
127
+ showAdvanced: 'Расширенные настройки',
128
+ hideAdvanced: 'Скрыть расширенные',
129
+ telegram: 'Telegram',
130
+ agent: 'Агент',
131
+ media: 'Медиа',
132
+ tts: 'Голосовые ответы',
133
+ advanced: 'Polling Telegram',
134
+ botToken: 'Bot token (только запись)',
135
+ botTokenHint: 'Оставьте пустым, чтобы не менять',
136
+ allowedIds: 'Разрешённые user id',
137
+ allowedIdsHint: 'Через запятую. Пусто = все пользователи',
138
+ textFormat: 'Формат текста',
139
+ textFormatHtml: 'Markdown → Telegram HTML',
140
+ textFormatPlain: 'Простой текст',
141
+ disableTelegram: 'Выключить Telegram',
142
+ enableTelegram: 'Включить Telegram',
143
+ provider: 'Provider',
144
+ providerHint: 'Пусто = провайдер по умолчанию DSH',
145
+ model: 'Model',
146
+ modelHint: 'Пусто = модель по умолчанию',
147
+ photoOnly: 'Фото без подписи',
148
+ photoPrompt: 'Ждать вопрос (рекомендуется)',
149
+ photoRun: 'Отвечать сразу',
150
+ instruction: 'Доп. инструкция агенту',
151
+ instructionHint: 'Добавляется к каждому сообщению из мессенджера',
152
+ instructionPlaceholder: 'Пусто = встроенная инструкция (русский, без рассуждений)',
153
+ maxMessageLength: 'Макс. длина ответа (символов)',
154
+ turnTimeout: 'Таймаут хода агента (сек)',
155
+ maxDocMb: 'Макс. размер документа (МБ)',
156
+ maxImageMb: 'Макс. размер фото (МБ)',
157
+ maxTextKb: 'Макс. текст из документа (КБ)',
158
+ ttsEnable: 'Голосовые ответы (dsh-tts)',
159
+ ttsMaxChars: 'Макс. символов для озвучки',
160
+ pollTimeout: 'pollTimeoutSeconds',
161
+ pollInterval: 'pollIntervalMs',
162
+ idleTimeout: 'idle timeout (сек)',
163
+ pairing: 'Запросы сопряжения',
164
+ pairingEmpty: 'Нет ожидающих кодов',
165
+ pairingApprove: 'Одобрить',
166
+ pairingReject: 'Отклонить',
167
+ pairingRefresh: 'Обновить',
168
+ groupsEnable: 'Отвечать в группах',
169
+ groupMention: 'В группах только @mention / reply / команда',
170
+ reactionsEnable: 'Реакция 👀 пока думает',
171
+ progressEnable: 'Прогресс: думаю → удалить → финальный ответ',
172
+ statusIndicator: 'Индикатор статуса (Online/Offline)',
173
+ statusIndicatorHint: 'Меняет short description бота (у ботов нет точки присутствия). Опцionalно, видно всем.',
174
+ transport: 'Транспорт',
175
+ transportPoll: 'Long poll (getUpdates)',
176
+ transportWebhook: 'Webhook',
177
+ webhookUrl: 'Публичный URL webhook',
178
+ webhookUrlHint: 'Полный HTTPS URL, куда Telegram шлёт обновления',
179
+ webhookSecret: 'Webhook secret (только запись)',
180
+ webhookSecretHint: 'Пусто = не менять',
181
+ sessionScope: 'Сессии в группах',
182
+ sessionScopeChat: 'Общая на чат/топик',
183
+ sessionScopeUser: 'На каждого user (реком.)',
184
+ voiceMode: 'Голосовые ответы',
185
+ voiceModeMirror: 'Как входящее (mirror)',
186
+ voiceModeAlways: 'Всегда TTS',
187
+ voiceModeOff: 'Выкл (кроме /voice on)',
188
+ notifyBridge: 'Notify → Telegram home',
189
+ notifyBridgeHint: 'task_done/error из web-сессий (не msgw-*)',
190
+ notifyHome: 'Имя home для notify',
191
+ }
192
+
193
+ function useActiveLocale(ctx) {
194
+ return React.useSyncExternalStore(
195
+ React.useMemo(() => (cb) => (ctx?.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
196
+ React.useCallback(() => {
197
+ if (ctx?.locale) {
198
+ const active = ctx.locale.getSnapshot().active
199
+ if (typeof active === 'string' && active) return active
200
+ }
201
+ return typeof navigator !== 'undefined' ? String(navigator.language || '').slice(0, 2) : 'en'
202
+ }, [ctx]),
203
+ )
204
+ }
205
+
206
+ function makeT(locale) {
207
+ const DICT = String(locale || '').startsWith('ru') ? ru : en
208
+ return (key) => DICT[key] || en[key] || key
209
+ }
210
+
8
211
  function idsToText(ids) { return (Array.isArray(ids) ? ids : []).map(String).filter(Boolean).join(', ') }
9
212
  function textToIds(text) {
10
213
  return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean).map(Number).filter((n) => Number.isFinite(n))
11
214
  }
215
+ function bytesToMb(n) { return Math.round(Number(n || 0) / (1024 * 1024)) }
216
+ function mbToBytes(n) { return Math.max(1, Math.round(Number(n || 1))) * 1024 * 1024 }
217
+
218
+ function Field({ label, hint, children }) {
219
+ return React.createElement('div', { className: 'msgw-field' },
220
+ React.createElement('label', { className: 'msgw-label' }, label),
221
+ hint ? React.createElement('div', { className: 'msgw-hint' }, hint) : null,
222
+ children,
223
+ )
224
+ }
12
225
 
13
- function Section() {
226
+ function MessengerSettingsForm({ t }) {
14
227
  const [cfg, setCfg] = React.useState(null)
15
228
  const [token, setToken] = React.useState('')
229
+ const [webhookSecret, setWebhookSecret] = React.useState('')
16
230
  const [allowText, setAllowText] = React.useState('')
231
+ const [pending, setPending] = React.useState([])
17
232
  const [err, setErr] = React.useState('')
18
233
  const [busy, setBusy] = React.useState(false)
234
+ const [showAdvanced, setShowAdvanced] = React.useState(false)
235
+
236
+ const loadPairing = React.useCallback(async () => {
237
+ try {
238
+ const res = await fetch('/dsh-messenger-gateway/pairing', { credentials: 'same-origin' })
239
+ const data = await res.json()
240
+ if (res.ok && data.ok) setPending(Array.isArray(data.pending) ? data.pending : [])
241
+ } catch {}
242
+ }, [])
19
243
 
20
244
  const load = React.useCallback(async () => {
21
245
  const res = await fetch('/dsh-messenger-gateway/config', { credentials: 'same-origin' })
@@ -23,74 +247,284 @@ window.__ModuleLoader__.load({
23
247
  if (!res.ok || !data.ok) throw new Error(data.error || res.status)
24
248
  setCfg(data.config)
25
249
  setAllowText(idsToText(data.config?.telegram?.allowedUserIds))
26
- }, [])
250
+ await loadPairing()
251
+ }, [loadPairing])
27
252
 
28
253
  React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
29
254
 
30
- const save = async (patch) => {
255
+ const mergePatch = (base, patch) => ({
256
+ ...base,
257
+ ...patch,
258
+ telegram: { ...base.telegram, ...(patch.telegram || {}) },
259
+ tts: { ...base.tts, ...(patch.tts || {}) },
260
+ agent: { ...base.agent, ...(patch.agent || {}) },
261
+ media: { ...base.media, ...(patch.media || {}) },
262
+ })
263
+
264
+ const save = async (patch = {}) => {
31
265
  setBusy(true); setErr('')
32
266
  try {
33
- const body = {
34
- config: {
35
- ...cfg,
36
- ...patch,
37
- telegram: { ...cfg.telegram, ...(patch.telegram || {}) },
38
- tts: { ...cfg.tts, ...(patch.tts || {}) },
39
- },
40
- }
41
- if (token.trim()) body.config.telegram = { ...body.config.telegram, botToken: token.trim() }
42
- body.config.telegram.allowedUserIds = textToIds(allowText)
267
+ let next = mergePatch(cfg, patch)
268
+ if (token.trim()) next.telegram = { ...next.telegram, botToken: token.trim() }
269
+ if (webhookSecret.trim()) next.telegram = { ...next.telegram, webhookSecret: webhookSecret.trim() }
270
+ next.telegram.allowedUserIds = textToIds(allowText)
43
271
  const res = await fetch('/dsh-messenger-gateway/config', {
44
272
  method: 'PUT', credentials: 'same-origin',
45
- headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
273
+ headers: { 'Content-Type': 'application/json' },
274
+ body: JSON.stringify({ config: next }),
46
275
  })
47
276
  const data = await res.json()
48
277
  if (!res.ok || !data.ok) throw new Error(data.error || res.status)
49
- setCfg(data.config); setToken('')
278
+ setCfg(data.config); setToken(''); setWebhookSecret('')
50
279
  setAllowText(idsToText(data.config?.telegram?.allowedUserIds))
280
+ await loadPairing()
51
281
  } catch (e) { setErr(String(e.message || e)) } finally { setBusy(false) }
52
282
  }
53
283
 
54
- if (!cfg) return React.createElement('div', null, err || 'Loading…')
55
-
56
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 } },
57
- React.createElement('div', { style: { fontWeight: 600 } }, 'Messenger gateway'),
58
- React.createElement('div', { style: { fontSize: 12, opacity: 0.75 } }, 'Telegram: text, voice (dsh-voice), photos, documents. Optional voice replies via dsh-tts.'),
59
- React.createElement('label', null, 'Bot token (write-only)'),
60
- React.createElement('input', { type: 'password', value: token, placeholder: cfg.telegram?.botTokenConfigured ? '••••••••' : 'BotFather token', onChange: (e) => setToken(e.target.value) }),
61
- React.createElement('label', null, 'Allowed user ids (comma-separated, empty = all)'),
62
- React.createElement('textarea', { value: allowText, onChange: (e) => setAllowText(e.target.value), rows: 2 }),
63
- React.createElement('label', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
64
- React.createElement('input', { type: 'checkbox', checked: !!cfg.tts?.enabled, onChange: (e) => save({ tts: { enabled: e.target.checked } }) }),
65
- 'Speak agent replies (dsh-tts)',
284
+ if (!cfg) return React.createElement('div', null, err || t('loading'))
285
+
286
+ return React.createElement('div', { className: 'msgw-form' },
287
+ React.createElement('div', null,
288
+ React.createElement('div', { className: 'msgw-groupTitle' }, t('telegram')),
289
+ React.createElement(Field, { label: t('botToken'), hint: t('botTokenHint') },
290
+ React.createElement('input', { type: 'password', className: 'msgw-input', value: token, placeholder: cfg.telegram?.botTokenConfigured ? '••••••••' : 'BotFather', onChange: (e) => setToken(e.target.value) }),
291
+ ),
292
+ React.createElement(Field, { label: t('allowedIds'), hint: t('allowedIdsHint') },
293
+ React.createElement('textarea', { className: 'msgw-textarea', value: allowText, onChange: (e) => setAllowText(e.target.value), rows: 2 }),
294
+ ),
295
+ React.createElement(Field, { label: t('textFormat') },
296
+ React.createElement('select', { className: 'msgw-select', value: cfg.telegram?.textFormat || 'html', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, textFormat: e.target.value } }) },
297
+ React.createElement('option', { value: 'html' }, t('textFormatHtml')),
298
+ React.createElement('option', { value: 'plain' }, t('textFormatPlain')),
299
+ ),
300
+ ),
301
+ React.createElement('button', { type: 'button', className: 'msgw-ghost', disabled: busy, onClick: () => save({ telegram: { enabled: !cfg.telegram.enabled } }) }, cfg.telegram.enabled ? t('disableTelegram') : t('enableTelegram')),
302
+ ),
303
+
304
+ React.createElement('div', { className: 'msgw-section' },
305
+ React.createElement('div', { className: 'msgw-groupTitle' }, t('agent')),
306
+ React.createElement(Field, { label: t('provider'), hint: t('providerHint') },
307
+ React.createElement('input', { className: 'msgw-input', value: cfg.agent?.provider || '', onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, provider: e.target.value } }) }),
308
+ ),
309
+ React.createElement(Field, { label: t('model'), hint: t('modelHint') },
310
+ React.createElement('input', { className: 'msgw-input', value: cfg.agent?.model || '', onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, model: e.target.value } }) }),
311
+ ),
312
+ React.createElement(Field, { label: t('photoOnly') },
313
+ React.createElement('select', { className: 'msgw-select', value: cfg.agent?.photoOnlyMode || 'prompt', onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, photoOnlyMode: e.target.value } }) },
314
+ React.createElement('option', { value: 'prompt' }, t('photoPrompt')),
315
+ React.createElement('option', { value: 'run' }, t('photoRun')),
316
+ ),
317
+ ),
318
+ React.createElement(Field, { label: t('sessionScope') },
319
+ React.createElement('select', { className: 'msgw-select', value: cfg.agent?.sessionScope || 'user', onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, sessionScope: e.target.value } }) },
320
+ React.createElement('option', { value: 'user' }, t('sessionScopeUser')),
321
+ React.createElement('option', { value: 'chat' }, t('sessionScopeChat')),
322
+ ),
323
+ ),
324
+ React.createElement(Field, { label: t('instruction'), hint: t('instructionHint') },
325
+ React.createElement('textarea', { className: 'msgw-textarea', value: cfg.agent?.instructionPrefix || '', onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, instructionPrefix: e.target.value } }), rows: 3, placeholder: t('instructionPlaceholder') }),
326
+ ),
327
+ React.createElement(Field, { label: t('maxMessageLength') },
328
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 500, max: 8000, value: cfg.agent?.maxMessageLength ?? 4000, onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, maxMessageLength: Number(e.target.value) || 4000 } }) }),
329
+ ),
330
+ React.createElement(Field, { label: t('turnTimeout') },
331
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 30, max: 3600, value: Math.round((cfg.agent?.turnTimeoutMs ?? 600000) / 1000), onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, turnTimeoutMs: (Number(e.target.value) || 600) * 1000 } }) }),
332
+ ),
333
+ ),
334
+
335
+ React.createElement('div', { className: 'msgw-section' },
336
+ React.createElement('div', { className: 'msgw-groupTitle' }, t('media')),
337
+ React.createElement(Field, { label: t('maxDocMb') },
338
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 1, max: 50, value: bytesToMb(cfg.media?.maxDocBytes), onChange: (e) => setCfg({ ...cfg, media: { ...cfg.media, maxDocBytes: mbToBytes(e.target.value) } }) }),
339
+ ),
340
+ React.createElement(Field, { label: t('maxImageMb') },
341
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 1, max: 50, value: bytesToMb(cfg.media?.maxImageBytes), onChange: (e) => setCfg({ ...cfg, media: { ...cfg.media, maxImageBytes: mbToBytes(e.target.value) } }) }),
342
+ ),
343
+ React.createElement(Field, { label: t('maxTextKb') },
344
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 16, max: 512, value: Math.round((cfg.media?.maxTextInjectBytes ?? 102400) / 1024), onChange: (e) => setCfg({ ...cfg, media: { ...cfg.media, maxTextInjectBytes: (Number(e.target.value) || 100) * 1024 } }) }),
345
+ ),
346
+ ),
347
+
348
+ React.createElement('div', { className: 'msgw-section' },
349
+ React.createElement('label', { className: 'msgw-check' },
350
+ React.createElement('input', { type: 'checkbox', checked: !!cfg.tts?.enabled, onChange: (e) => setCfg({ ...cfg, tts: { ...cfg.tts, enabled: e.target.checked } }) }),
351
+ t('ttsEnable'),
352
+ ),
353
+ cfg.tts?.enabled ? React.createElement(Field, { label: t('ttsMaxChars') },
354
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 100, max: 8000, value: cfg.tts?.maxChars ?? 4000, onChange: (e) => setCfg({ ...cfg, tts: { ...cfg.tts, maxChars: Number(e.target.value) || 4000 } }) }),
355
+ ) : null,
356
+ ),
357
+
358
+ React.createElement('div', { className: 'msgw-section' },
359
+ React.createElement('div', { className: 'msgw-groupTitle' }, t('pairing')),
360
+ React.createElement('button', { type: 'button', className: 'msgw-linkbtn', disabled: busy, onClick: () => loadPairing() }, t('pairingRefresh')),
361
+ !(pending && pending.length) ? React.createElement('div', { className: 'msgw-hint' }, t('pairingEmpty')) : pending.map((row) =>
362
+ React.createElement('div', { key: row.code, className: 'msgw-row' },
363
+ React.createElement('div', null, (row.username ? '@' + row.username + ' · ' : '') + 'id ' + row.userId + ' · ' + row.code),
364
+ React.createElement('div', { className: 'msgw-rowActions' },
365
+ React.createElement('button', {
366
+ type: 'button', className: 'msgw-mini', disabled: busy, onClick: async () => {
367
+ setBusy(true); setErr('')
368
+ try {
369
+ const res = await fetch('/dsh-messenger-gateway/pairing/approve', {
370
+ method: 'POST', credentials: 'same-origin',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ code: row.code }),
373
+ })
374
+ const data = await res.json()
375
+ if (!res.ok || !data.ok) throw new Error(data.error || res.status)
376
+ await load()
377
+ } catch (e) { setErr(String(e.message || e)) } finally { setBusy(false) }
378
+ },
379
+ }, t('pairingApprove')),
380
+ React.createElement('button', {
381
+ type: 'button', className: 'msgw-mini', disabled: busy, onClick: async () => {
382
+ setBusy(true); setErr('')
383
+ try {
384
+ const res = await fetch('/dsh-messenger-gateway/pairing/reject', {
385
+ method: 'POST', credentials: 'same-origin',
386
+ headers: { 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({ code: row.code }),
388
+ })
389
+ const data = await res.json()
390
+ if (!res.ok || !data.ok) throw new Error(data.error || res.status)
391
+ await loadPairing()
392
+ } catch (e) { setErr(String(e.message || e)) } finally { setBusy(false) }
393
+ },
394
+ }, t('pairingReject')),
395
+ ),
396
+ ),
397
+ ),
66
398
  ),
67
- cfg.tts?.enabled ? React.createElement('label', null,
68
- 'Max spoken chars',
69
- React.createElement('input', {
70
- type: 'number', min: 100, max: 8000,
71
- value: cfg.tts?.maxChars ?? 4000,
72
- onChange: (e) => setCfg({ ...cfg, tts: { ...cfg.tts, maxChars: Number(e.target.value) || 4000 } }),
73
- }),
399
+
400
+ React.createElement('div', { className: 'msgw-section' },
401
+ React.createElement('label', { className: 'msgw-check' },
402
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.groupsEnabled !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, groupsEnabled: e.target.checked } }) }),
403
+ t('groupsEnable'),
404
+ ),
405
+ React.createElement('label', { className: 'msgw-check' },
406
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.groupRequireMention !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, groupRequireMention: e.target.checked } }) }),
407
+ t('groupMention'),
408
+ ),
409
+ React.createElement('label', { className: 'msgw-check' },
410
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.reactionsEnabled !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, reactionsEnabled: e.target.checked } }) }),
411
+ t('reactionsEnable'),
412
+ ),
413
+ React.createElement('label', { className: 'msgw-check' },
414
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.progressEnabled !== false, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, progressEnabled: e.target.checked } }) }),
415
+ t('progressEnable'),
416
+ ),
417
+ React.createElement('label', { className: 'msgw-check' },
418
+ React.createElement('input', { type: 'checkbox', checked: cfg.telegram?.statusIndicator === true, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, statusIndicator: e.target.checked } }) }),
419
+ t('statusIndicator'),
420
+ ),
421
+ React.createElement('div', { className: 'msgw-hint' }, t('statusIndicatorHint')),
422
+ React.createElement(Field, { label: t('voiceMode') },
423
+ React.createElement('select', { className: 'msgw-select', value: cfg.telegram?.voiceMode || 'mirror', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, voiceMode: e.target.value } }) },
424
+ React.createElement('option', { value: 'mirror' }, t('voiceModeMirror')),
425
+ React.createElement('option', { value: 'always' }, t('voiceModeAlways')),
426
+ React.createElement('option', { value: 'off' }, t('voiceModeOff')),
427
+ ),
428
+ ),
429
+ React.createElement('label', { className: 'msgw-check' },
430
+ React.createElement('input', { type: 'checkbox', checked: !!cfg.telegram?.notifyBridge?.enabled, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, notifyBridge: { ...(cfg.telegram.notifyBridge || {}), enabled: e.target.checked } } }) }),
431
+ t('notifyBridge'),
432
+ ),
433
+ React.createElement('div', { className: 'msgw-hint' }, t('notifyBridgeHint')),
434
+ React.createElement(Field, { label: t('notifyHome') },
435
+ React.createElement('input', { className: 'msgw-input', value: cfg.telegram?.notifyBridge?.home || 'default', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, notifyBridge: { ...(cfg.telegram.notifyBridge || {}), home: e.target.value } } }) }),
436
+ ),
437
+ ),
438
+
439
+ React.createElement('button', { type: 'button', className: 'msgw-linkbtn', onClick: () => setShowAdvanced((v) => !v) }, showAdvanced ? t('hideAdvanced') : t('showAdvanced')),
440
+ showAdvanced ? React.createElement('div', { className: 'msgw-section' },
441
+ React.createElement('div', { className: 'msgw-groupTitle' }, t('advanced')),
442
+ React.createElement(Field, { label: t('transport') },
443
+ React.createElement('select', { className: 'msgw-select', value: cfg.telegram?.transport || 'poll', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, transport: e.target.value } }) },
444
+ React.createElement('option', { value: 'poll' }, t('transportPoll')),
445
+ React.createElement('option', { value: 'webhook' }, t('transportWebhook')),
446
+ ),
447
+ ),
448
+ React.createElement(Field, { label: t('webhookUrl'), hint: t('webhookUrlHint') },
449
+ React.createElement('input', { className: 'msgw-input', value: cfg.telegram?.webhookUrl || '', onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, webhookUrl: e.target.value } }) }),
450
+ ),
451
+ React.createElement(Field, { label: t('webhookSecret'), hint: t('webhookSecretHint') },
452
+ React.createElement('input', { type: 'password', className: 'msgw-input', value: webhookSecret, placeholder: cfg.telegram?.webhookSecretConfigured ? '••••••••' : '', onChange: (e) => setWebhookSecret(e.target.value) }),
453
+ ),
454
+ React.createElement(Field, { label: t('pollTimeout') },
455
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 10, max: 60, value: cfg.telegram?.pollTimeoutSeconds ?? 50, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, pollTimeoutSeconds: Number(e.target.value) || 50 } }) }),
456
+ ),
457
+ React.createElement(Field, { label: t('pollInterval') },
458
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 100, max: 5000, value: cfg.telegram?.pollIntervalMs ?? 500, onChange: (e) => setCfg({ ...cfg, telegram: { ...cfg.telegram, pollIntervalMs: Number(e.target.value) || 500 } }) }),
459
+ ),
460
+ React.createElement(Field, { label: t('idleTimeout') },
461
+ React.createElement('input', { type: 'number', className: 'msgw-input', min: 300, max: 86400, value: Math.round((cfg.agent?.idleTimeoutMs ?? 3600000) / 1000), onChange: (e) => setCfg({ ...cfg, agent: { ...cfg.agent, idleTimeoutMs: (Number(e.target.value) || 3600) * 1000 } }) }),
462
+ ),
74
463
  ) : null,
75
- React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
76
- React.createElement('button', { disabled: busy, onClick: () => save({ telegram: { enabled: !cfg.telegram.enabled } }) }, cfg.telegram.enabled ? 'Disable Telegram' : 'Enable Telegram'),
77
- React.createElement('button', { disabled: busy, onClick: () => save({}) }, 'Save'),
464
+
465
+ err ? React.createElement('div', { className: 'msgw-err' }, err) : null,
466
+ React.createElement('div', { className: 'msgw-foot' },
467
+ React.createElement('button', { type: 'button', className: 'msgw-save', disabled: busy, onClick: () => save({}) }, busy ? t('saving') : t('save')),
468
+ ),
469
+ )
470
+ }
471
+
472
+ function PluginCard(props) {
473
+ const [open, setOpen] = React.useState(false)
474
+ const t = makeT(props.locale)
475
+ return React.createElement('li', { className: 'msgw-card' },
476
+ React.createElement('button', {
477
+ type: 'button',
478
+ className: 'msgw-head',
479
+ 'aria-expanded': open,
480
+ onClick: () => setOpen((v) => !v),
481
+ },
482
+ React.createElement('div', { className: 'msgw-headText' },
483
+ React.createElement('div', { className: 'msgw-title' }, t('title')),
484
+ React.createElement('div', { className: 'msgw-sub' }, t('description')),
485
+ ),
486
+ React.createElement('span', { className: 'msgw-chev' }, open ? '\u25B2' : '\u25BC'),
78
487
  ),
79
- err ? React.createElement('div', { style: { color: 'crimson', fontSize: 12 } }, err) : null,
488
+ open ? React.createElement('div', { className: 'msgw-body' },
489
+ React.createElement(MessengerSettingsForm, { t }),
490
+ ) : null,
80
491
  )
81
492
  }
82
493
 
83
494
  function apply(ctx) {
84
- ctx.slots.inject('settings.section', () => ctx.slots.register({
85
- name: 'settings.section',
86
- id: '@goodandready/dsh-messenger-gateway',
87
- order: 36,
88
- label: () => 'Messenger gateway',
89
- }, Section))
495
+ ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-messenger-gateway: dictionaries')
496
+ function useLocale() { return useActiveLocale(ctx) }
497
+
498
+ const tryPluginItem = () => {
499
+ try {
500
+ ctx.slots.inject('settings.plugin.item', () =>
501
+ ctx.slots.register({
502
+ name: 'settings.plugin.item',
503
+ key: NS,
504
+ locale: NS,
505
+ inject: () => ({ ctx }),
506
+ }, (props) => React.createElement(PluginCard, { ...props, locale: useLocale() })),
507
+ )
508
+ return true
509
+ } catch {
510
+ return false
511
+ }
512
+ }
513
+
514
+ if (!tryPluginItem()) {
515
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
516
+ name: 'settings.section',
517
+ id: NS,
518
+ order: 36,
519
+ label: () => makeT(useActiveLocale(ctx))('title'),
520
+ }, (props) => React.createElement('div', { style: { padding: 16 } },
521
+ React.createElement(MessengerSettingsForm, { t: makeT(useActiveLocale(ctx)) }),
522
+ )))
523
+ }
90
524
  }
91
525
 
92
526
  exports.apply = apply
93
- exports.inject = ['slots']
527
+ exports.inject = ['slots', 'locale']
94
528
  return module.exports
95
529
  },
96
- })
530
+ })
@@ -0,0 +1,30 @@
1
+ export const DEFAULT_TELEGRAM_COMMANDS = [
2
+ { command: 'start', description: 'Подключение и приветствие' },
3
+ { command: 'help', description: 'Список команд' },
4
+ { command: 'new', description: 'Новая сессия агента' },
5
+ { command: 'whoami', description: 'Показать ваш Telegram id' },
6
+ { command: 'stop', description: 'Прервать текущий ответ' },
7
+ { command: 'pair', description: 'Одобрить код сопряжения: /pair CODE' },
8
+ { command: 'sethome', description: 'Home: /sethome [name]' },
9
+ { command: 'home', description: 'Список home-каналов' },
10
+ { command: 'model', description: 'Показать или сменить модель' },
11
+ { command: 'status', description: 'Статус шлюза' },
12
+ { command: 'voice', description: 'Голосовые ответы: /voice on|off|status' },
13
+ ]
14
+
15
+ export function normalizeTelegramCommands(commands) {
16
+ const source = Array.isArray(commands) && commands.length ? commands : DEFAULT_TELEGRAM_COMMANDS
17
+ const out = []
18
+ const seen = new Set()
19
+ for (const raw of source) {
20
+ if (!raw || typeof raw !== 'object') continue
21
+ let command = String(raw.command || '').trim().replace(/^\//, '')
22
+ const description = String(raw.description || '').trim()
23
+ if (!command || !description) continue
24
+ command = command.slice(0, 32).toLowerCase()
25
+ if (seen.has(command)) continue
26
+ seen.add(command)
27
+ out.push({ command, description: description.slice(0, 256) })
28
+ }
29
+ return out.length ? out : [...DEFAULT_TELEGRAM_COMMANDS]
30
+ }