dsh-telegram-bot 0.0.0-stage → 0.1.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/client.js ADDED
@@ -0,0 +1,826 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-telegram-bot',
3
+ factory: (require) => {
4
+ var module = { exports: {} }
5
+ var exports = module.exports
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
7
+
8
+ const React = require('react')
9
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
10
+ const { Button, SettingsSecretField, Switch } = primitives
11
+ const h = React.createElement
12
+
13
+ /** Loader row id / settings namespace, equal to the package name. */
14
+ const NS = 'telegram-bot'
15
+ /** Default credential reference holding the Telegram bot token. */
16
+ const DEFAULT_TOKEN_REF = 'TELEGRAM_BOT_TOKEN'
17
+
18
+ const en = {
19
+ nav: 'Telegram',
20
+ title: 'Telegram bot',
21
+ description: 'Configure the bot token and who may talk to the bot.',
22
+ botToken: 'Bot token',
23
+ tokenValue: 'Token value',
24
+ tokenHint: 'Create a bot with @BotFather and paste its token. The token is stored in the credential store, never in the settings file.',
25
+ tokenConfigured: 'A token is configured',
26
+ tokenMissing: 'No token is configured yet',
27
+ tokenReadOnly: 'The token is supplied by the environment or the store is read-only, so it cannot be changed here.',
28
+ removeToken: 'Remove token',
29
+ removeTokenConfirm: 'Remove the stored bot token?',
30
+ tokenRemoved: 'The stored token was removed.',
31
+ access: 'Access',
32
+ botLanguage: 'Bot language',
33
+ botLanguageHint: 'Language of all Telegram messages; English is the default.',
34
+ enabled: 'Enable bot',
35
+ enabledHint: 'The runtime polls Telegram while this profile is running.',
36
+ showThinking: 'Show model thinking',
37
+ showThinkingHint: 'Send reasoning deltas as a separate Thinking message in the session topic.',
38
+ showToolCalls: 'Show tool calls',
39
+ showToolCallsHint: 'Send tool names and arguments as they are called.',
40
+ allowedUsers: 'Allowed users',
41
+ allowedUsersHint: 'Telegram user ids or @usernames allowed to talk to the bot. Empty means nobody.',
42
+ allowedChats: 'Allowed chats',
43
+ allowedChatsHint: 'Telegram chat ids or @channelnames the bot may answer in. Empty means no group or channel chats.',
44
+ add: 'Add',
45
+ remove: 'Remove',
46
+ emptyUsers: 'No users added yet.',
47
+ emptyChats: 'No chats added yet.',
48
+ placeholderUsers: '123456789 or @username',
49
+ placeholderChats: '-1001234567890 or @channel',
50
+ save: 'Save',
51
+ saving: 'Saving…',
52
+ saved: 'Saved.',
53
+ restart: 'Restart bot',
54
+ restarting: 'Restarting…',
55
+ restartRequested: 'Restart requested. Check Telegram or the DSH log.',
56
+ restartFailed: 'The Host did not accept the restart request.',
57
+ bundleSummary: 'Telegram bot',
58
+ pluginPageDescription: 'This plugin must be configured in Settings → Telegram: bot token, access lists.',
59
+ pluginPageLocation: 'Open Settings → Telegram to configure this plugin.',
60
+ pluginConfigFiles: 'Manual configuration files:',
61
+ pluginConfigSettings: 'plugin settings (cordis.patch.yml)',
62
+ pluginConfigToken: 'bot token (credential store)',
63
+ pluginConfigEnv: 'token fallback for headless deployments',
64
+ running: 'Running',
65
+ stopped: 'Stopped',
66
+ runtimeMissingShort: 'Host not loaded',
67
+ on: 'On',
68
+ off: 'Off',
69
+ pluginRuntimeHint: 'Runtime state from this DSH process. Turning the bundle off stops polling.',
70
+ pluginQuickControls: 'Quick controls',
71
+ pluginDisplay: 'Display',
72
+ bundleSettingsHint: 'Full allowlists are in Settings → Telegram.',
73
+ tokenConfiguredShort: 'token configured',
74
+ tokenMissingShort: 'no token',
75
+ usersShort: 'users',
76
+ chatsShort: 'chats',
77
+ bundleDisableHint: 'Turning the plugin off in Plugins stops polling.',
78
+ hostNeedsRestart: 'The Telegram runtime is not loaded in this DSH process yet. Restart dsh web once; after that this page can restart the bot.',
79
+ discard: 'Discard',
80
+ saveFailed: 'The Host did not accept these values.',
81
+ invalidToken: 'Enter a token without spaces.',
82
+ loading: 'Loading…',
83
+ loadingSettings: 'Loading settings…',
84
+ unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
85
+ readOnly: 'This deployment stores settings read-only.',
86
+ }
87
+
88
+ const ru = {
89
+ nav: 'Telegram',
90
+ title: 'Telegram-бот',
91
+ description: 'Токен бота и список тех, кому можно с ним общаться.',
92
+ botToken: 'Токен бота',
93
+ tokenValue: 'Значение токена',
94
+ tokenHint: 'Создайте бота через @BotFather и вставьте токен. Он хранится в credential store, а не в файле настроек.',
95
+ tokenConfigured: 'Токен настроен',
96
+ tokenMissing: 'Токен ещё не задан',
97
+ tokenReadOnly: 'Токен задан через окружение или хранилище доступно только для чтения, поэтому здесь его нельзя изменить.',
98
+ removeToken: 'Удалить токен',
99
+ removeTokenConfirm: 'Удалить сохранённый токен бота?',
100
+ tokenRemoved: 'Сохранённый токен удалён.',
101
+ access: 'Доступ',
102
+ botLanguage: 'Язык бота',
103
+ botLanguageHint: 'Язык сообщений в Telegram; по умолчанию английский.',
104
+ enabled: 'Включить бота',
105
+ enabledHint: 'Runtime опрашивает Telegram, пока запущен этот профиль.',
106
+ showThinking: 'Показывать think модели',
107
+ showThinkingHint: 'Отправлять reasoning отдельным сообщением «Thinking» в топик сессии.',
108
+ showToolCalls: 'Показывать tool calls',
109
+ showToolCallsHint: 'Отправлять имя инструмента и аргументы по мере вызова.',
110
+ allowedUsers: 'Разрешённые пользователи',
111
+ allowedUsersHint: 'Telegram user id или @username, кому можно писать боту. Пустой список — никому.',
112
+ allowedChats: 'Разрешённые чаты',
113
+ allowedChatsHint: 'Telegram chat id или @channelname, где бот может отвечать. Пустой список — только личные сообщения.',
114
+ add: 'Добавить',
115
+ remove: 'Удалить',
116
+ emptyUsers: 'Пользователи пока не добавлены.',
117
+ emptyChats: 'Чаты пока не добавлены.',
118
+ placeholderUsers: '123456789 или @username',
119
+ placeholderChats: '-1001234567890 или @channel',
120
+ save: 'Сохранить',
121
+ saving: 'Сохранение…',
122
+ saved: 'Сохранено.',
123
+ restart: 'Перезапустить бота',
124
+ restarting: 'Перезапуск…',
125
+ restartRequested: 'Запрошен перезапуск. Проверьте Telegram или лог DSH.',
126
+ restartFailed: 'Host не принял запрос на перезапуск.',
127
+ bundleSummary: 'Telegram-бот',
128
+ pluginPageDescription: 'Плагин настраивается в Settings → Telegram: токен бота, списки доступа.',
129
+ pluginConfigFiles: 'Файлы для ручной настройки:',
130
+ pluginConfigSettings: 'настройки плагина (cordis.patch.yml)',
131
+ pluginConfigToken: 'токен бота (credential store)',
132
+ pluginConfigEnv: 'fallback токена для headless',
133
+ pluginPageLocation: 'Открой Settings → Telegram, чтобы настроить плагин.',
134
+ running: 'Работает',
135
+ stopped: 'Остановлен',
136
+ runtimeMissingShort: 'Host не загружен',
137
+ on: 'Вкл',
138
+ off: 'Выкл',
139
+ pluginRuntimeHint: 'Состояние runtime в этом процессе DSH. Выключение bundle останавливает polling.',
140
+ pluginQuickControls: 'Быстрые настройки',
141
+ pluginDisplay: 'Отображение',
142
+ bundleSettingsHint: 'Полные списки доступа — в Settings → Telegram.',
143
+ tokenConfiguredShort: 'токен настроен',
144
+ tokenMissingShort: 'нет токена',
145
+ usersShort: 'пользователей',
146
+ chatsShort: 'чатов',
147
+ bundleDisableHint: 'Выключение плагина в Plugins останавливает polling.',
148
+ hostNeedsRestart: 'Telegram runtime ещё не загружен в этот процесс DSH. Перезапусти dsh web один раз; после этого кнопка рестарта заработает.',
149
+ discard: 'Отменить',
150
+ saveFailed: 'Host не принял эти значения.',
151
+ invalidToken: 'Введите токен без пробелов.',
152
+ loading: 'Загрузка…',
153
+ loadingSettings: 'Загрузка настроек…',
154
+ unavailable: 'Плагин не загружен, поэтому настройки сейчас недоступны.',
155
+ readOnly: 'Этот deployment хранит настройки только для чтения.',
156
+ }
157
+
158
+ const CSS = `
159
+ .dtb-page{display:flex;flex-direction:column;gap:16px;max-width:680px;padding:4px 0 32px;color:var(--dsw-alias-label-primary)}
160
+ .dtb-header{display:flex;flex-direction:column;gap:6px}
161
+ .dtb-title{margin:0;font-size:20px;font-weight:600;line-height:28px}
162
+ .dtb-description{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
163
+ .dtb-card{display:flex;flex-direction:column;gap:12px;padding:16px 18px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}
164
+ .dtb-cardTitle{margin:0;font-size:14px;font-weight:600;line-height:22px}
165
+ .dtb-badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:22px;white-space:nowrap}
166
+ .dtb-badgeOk{background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-state-success-primary)}
167
+ .dtb-field{display:flex;flex-direction:column;gap:6px}
168
+ .dtb-label{font-size:13px;font-weight:500;line-height:20px}
169
+ .dtb-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
170
+ .dtb-toggleRow{display:flex;align-items:center;justify-content:space-between;gap:20px}
171
+ .dtb-select{min-width:130px;padding:6px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,#444);background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary,inherit);color-scheme:dark}
172
+ .dtb-select option{background:#1f1f1f;color:#f2f2f2}
173
+ .dtb-select option:checked{background:#2b5278;color:#fff}
174
+ .dtb-toggleCopy{display:flex;flex-direction:column;gap:4px;min-width:0}
175
+ .dtb-chips{display:flex;flex-wrap:wrap;gap:6px;min-height:26px}
176
+ .dtb-chip{display:inline-flex;align-items:center;gap:4px;max-width:100%;height:26px;padding:0 4px 0 9px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);font-family:var(--ds-font-family-code);font-size:12px;line-height:24px}
177
+ .dtb-chipText{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
178
+ .dtb-chipRemove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:1;cursor:pointer}
179
+ .dtb-chipRemove:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
180
+ .dtb-chipRemove:disabled{cursor:default;opacity:.5}
181
+ .dtb-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:26px}
182
+ .dtb-addRow{display:flex;align-items:center;gap:8px}
183
+ .dtb-input{flex:1;min-width:0;height:32px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
184
+ .dtb-input::placeholder{color:var(--dsw-alias-label-tertiary)}
185
+ .dtb-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
186
+ .dtb-input:disabled{opacity:.6;cursor:not-allowed}
187
+ .dtb-footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:2px}
188
+ .dtb-footerActions{display:flex;align-items:center;gap:8px}
189
+ .dtb-message{min-height:20px;font-size:12px;line-height:20px}
190
+ .dtb-messageOk{color:var(--dsw-alias-state-success-primary)}
191
+ .dtb-messageError{color:var(--dsw-alias-state-error-primary)}
192
+ .dtb-bundle{display:flex;flex-direction:column;gap:12px}
193
+ .dtb-plugin{display:flex;flex-direction:column;gap:16px;padding-top:4px}
194
+ .dtb-pluginTop{display:flex;flex-direction:column;gap:8px}
195
+ .dtb-bundleMeta{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
196
+ .dtb-pluginGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
197
+ .dtb-stat{display:flex;flex-direction:column;gap:2px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
198
+ .dtb-statLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
199
+ .dtb-statValue{font-size:15px;line-height:22px;font-weight:600}
200
+ .dtb-pluginSection{display:flex;flex-direction:column;gap:10px}
201
+ .dtb-pluginSectionTitle{margin:0;font-size:13px;font-weight:600;line-height:20px}
202
+ .dtb-pluginControls{display:flex;flex-direction:column;gap:10px}
203
+ .dtb-pluginControl{display:flex;align-items:center;justify-content:space-between;gap:16px}
204
+ .dtb-pluginActions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
205
+ .dtb-notice{margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
206
+ .dtb-loading{padding:16px 0;color:var(--dsw-alias-label-tertiary);font-size:13px}
207
+ .dtb-note{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:17px}
208
+ @media (max-width:560px){.dtb-toggleRow{align-items:flex-start;flex-direction:column;gap:8px}.dtb-footer{align-items:stretch;flex-direction:column}.dtb-footerActions{justify-content:flex-end}}
209
+ `
210
+
211
+ /**
212
+ * Keep only non-empty strings from a Host section value.
213
+ * @param value - a section field that may be absent or malformed in a hand-edited patch.
214
+ * @returns a trimmed copy.
215
+ */
216
+ function stringArray(value) {
217
+ if (!Array.isArray(value)) return []
218
+ return value
219
+ .filter((item) => typeof item === 'string')
220
+ .map((item) => item.trim())
221
+ .filter((item) => item.length > 0)
222
+ }
223
+
224
+ /**
225
+ * Read the credential reference from a live settings value.
226
+ * @param value - settings section value.
227
+ * @returns the configured reference or the default.
228
+ */
229
+ function tokenRefOfValue(value) {
230
+ const ref = value?.tokenRef
231
+ return typeof ref === 'string' && ref.trim().length > 0 ? ref.trim() : DEFAULT_TOKEN_REF
232
+ }
233
+
234
+ /**
235
+ * Compare two access lists as ordered lists.
236
+ * @param left - first list.
237
+ * @param right - second list.
238
+ * @returns whether both hold the same entries in the same order.
239
+ */
240
+ function sameList(left, right) {
241
+ return left.length === right.length && left.every((item, index) => item === right[index])
242
+ }
243
+
244
+ /**
245
+ * Split pasted text into access entries and append the new ones.
246
+ * @param current - existing entries.
247
+ * @param raw - one or more ids/usernames separated by whitespace, commas, or semicolons.
248
+ * @returns a new list without case-insensitive duplicates.
249
+ */
250
+ function appendEntries(current, raw) {
251
+ const next = [...current]
252
+ for (const part of raw.split(/[\s,;]+/u)) {
253
+ const value = part.trim()
254
+ if (value.length === 0) continue
255
+ const key = value.toLowerCase()
256
+ if (!next.some((item) => item.toLowerCase() === key)) next.push(value)
257
+ }
258
+ return next
259
+ }
260
+
261
+ /**
262
+ * Read the token's presence and writability without ever receiving its value.
263
+ * @param ctx - client context carrying `remote.credentials`.
264
+ * @returns presence and writability.
265
+ */
266
+ async function readToken(ctx, ref) {
267
+ const response = await ctx.remote.credentials.describe([ref])
268
+ if (!response.ok) throw new Error(response.error.message)
269
+ const view = response.value[ref]
270
+ return {
271
+ configured: view?.configured === true,
272
+ writable: view?.writable !== false,
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Write the token through the credential seam and read back its presence.
278
+ * @param ctx - client context carrying `remote.credentials`.
279
+ * @param value - non-empty token literal.
280
+ * @returns presence and writability after the write.
281
+ */
282
+ async function saveToken(ctx, ref, value) {
283
+ const response = await ctx.remote.credentials.set(ref, value)
284
+ if (!response.ok) throw new Error(response.error.message)
285
+ return readToken(ctx, ref)
286
+ }
287
+
288
+ /**
289
+ * Remove the stored token; an environment-provided token stays in place.
290
+ * @param ctx - client context carrying `remote.credentials`.
291
+ * @returns presence and writability after the removal.
292
+ */
293
+ async function clearToken(ctx, ref) {
294
+ const response = await ctx.remote.credentials.unset(ref)
295
+ if (!response.ok) throw new Error(response.error.message)
296
+ return readToken(ctx, ref)
297
+ }
298
+
299
+ /**
300
+ * Page frame shared by the loading, unavailable, and loaded states.
301
+ * @param props - locale reader and page body.
302
+ * @returns the section shell.
303
+ */
304
+ function PageFrame(props) {
305
+ return h(
306
+ 'div',
307
+ { className: 'dtb-page' },
308
+ h('style', null, CSS),
309
+ h(
310
+ 'header',
311
+ { className: 'dtb-header' },
312
+ h('h2', { className: 'dtb-title' }, props.t('title')),
313
+ h('p', { className: 'dtb-description' }, props.t('description')),
314
+ ),
315
+ props.children,
316
+ )
317
+ }
318
+
319
+ /**
320
+ * Editable list of user ids, usernames, or chat ids.
321
+ * @param props - list value, change handler, labels, and disabled state.
322
+ * @returns chips plus an add row.
323
+ */
324
+ function ListEditor(props) {
325
+ const { t, label, hint, items, onChange, placeholder, emptyLabel, disabled } = props
326
+ const [draft, setDraft] = React.useState('')
327
+
328
+ const add = () => {
329
+ const next = appendEntries(items, draft)
330
+ setDraft('')
331
+ if (next.length !== items.length) onChange(next)
332
+ }
333
+
334
+ return h(
335
+ 'div',
336
+ { className: 'dtb-field' },
337
+ h('span', { className: 'dtb-label' }, label),
338
+ h('p', { className: 'dtb-hint' }, hint),
339
+ h(
340
+ 'div',
341
+ { className: 'dtb-chips' },
342
+ items.length === 0
343
+ ? h('span', { className: 'dtb-empty' }, emptyLabel)
344
+ : items.map((item, index) =>
345
+ h(
346
+ 'span',
347
+ { className: 'dtb-chip', key: `${item}-${index}` },
348
+ h('span', { className: 'dtb-chipText' }, item),
349
+ h(
350
+ 'button',
351
+ {
352
+ type: 'button',
353
+ className: 'dtb-chipRemove',
354
+ 'aria-label': `${t('remove')}: ${item}`,
355
+ disabled,
356
+ onClick: () => onChange(items.filter((_, itemIndex) => itemIndex !== index)),
357
+ },
358
+ '×',
359
+ ),
360
+ ),
361
+ ),
362
+ ),
363
+ h(
364
+ 'div',
365
+ { className: 'dtb-addRow' },
366
+ h('input', {
367
+ className: 'dtb-input',
368
+ type: 'text',
369
+ value: draft,
370
+ placeholder,
371
+ 'aria-label': label,
372
+ disabled,
373
+ onChange: (event) => setDraft(event.target.value),
374
+ onKeyDown: (event) => {
375
+ if (event.key !== 'Enter') return
376
+ event.preventDefault()
377
+ add()
378
+ },
379
+ }),
380
+ h(
381
+ Button,
382
+ { variant: 'outline', size: 'sm', disabled: disabled || draft.trim().length === 0, onClick: add },
383
+ t('add'),
384
+ ),
385
+ ),
386
+ )
387
+ }
388
+
389
+ /**
390
+ * The Telegram settings section.
391
+ * @param props - locale reader, the Host settings form, and credential operations.
392
+ * @returns the settings page.
393
+ */
394
+ function TelegramSection(props) {
395
+ const { t, form, token } = props
396
+ const [snapshot, setSnapshot] = React.useState(() => form.getSnapshot())
397
+ const [initialized, setInitialized] = React.useState(false)
398
+ const [allowedUsers, setAllowedUsers] = React.useState([])
399
+ const [allowedChats, setAllowedChats] = React.useState([])
400
+ const [enabled, setEnabled] = React.useState(true)
401
+ const [language, setLanguage] = React.useState('en')
402
+ const [showThinking, setShowThinking] = React.useState(true)
403
+ const [showToolCalls, setShowToolCalls] = React.useState(true)
404
+ const [tokenDraft, setTokenDraft] = React.useState('')
405
+ const [tokenState, setTokenState] = React.useState({
406
+ loading: true,
407
+ configured: false,
408
+ writable: false,
409
+ })
410
+ const [saving, setSaving] = React.useState(false)
411
+ const [error, setError] = React.useState(null)
412
+ const [notice, setNotice] = React.useState(null)
413
+
414
+ React.useEffect(() => {
415
+ const off = form.subscribe(() => setSnapshot(form.getSnapshot()))
416
+ return () => off()
417
+ }, [form])
418
+
419
+ React.useEffect(() => {
420
+ if (initialized || snapshot.status !== 'ready') return
421
+ const value = snapshot.value ?? {}
422
+ setAllowedUsers(stringArray(value.allowedUsers))
423
+ setAllowedChats(stringArray(value.allowedChats))
424
+ setEnabled(value.enabled !== false)
425
+ setLanguage(value.language === 'ru' ? 'ru' : 'en')
426
+ setShowThinking(value.showThinking !== false)
427
+ setShowToolCalls(value.showToolCalls !== false)
428
+ setInitialized(true)
429
+ }, [initialized, snapshot.status, snapshot.value])
430
+
431
+ const value = snapshot.value ?? {}
432
+ const tokenRef = tokenRefOfValue(value)
433
+
434
+ React.useEffect(() => {
435
+ let live = true
436
+ setTokenState((previous) => ({ ...previous, loading: true }))
437
+ token
438
+ .read(tokenRef)
439
+ .then((info) => {
440
+ if (live) setTokenState({ loading: false, ...info })
441
+ })
442
+ .catch((cause) => {
443
+ if (!live) return
444
+ setTokenState({ loading: false, configured: false, writable: false })
445
+ setError(cause?.message ?? String(cause))
446
+ })
447
+ return () => {
448
+ live = false
449
+ }
450
+ }, [token, tokenRef])
451
+
452
+ const runtimeLoaded = Object.hasOwn(value, 'restartRequest')
453
+ const baseUsers = stringArray(value.allowedUsers)
454
+ const baseChats = stringArray(value.allowedChats)
455
+ const baseEnabled = value.enabled !== false
456
+ const baseLanguage = value.language === 'ru' ? 'ru' : 'en'
457
+ const baseShowThinking = value.showThinking !== false
458
+ const baseShowToolCalls = value.showToolCalls !== false
459
+ const dirty =
460
+ initialized &&
461
+ (enabled !== baseEnabled ||
462
+ language !== baseLanguage ||
463
+ showThinking !== baseShowThinking ||
464
+ showToolCalls !== baseShowToolCalls ||
465
+ !sameList(allowedUsers, baseUsers) ||
466
+ !sameList(allowedChats, baseChats) ||
467
+ tokenDraft.trim().length > 0)
468
+ const writable = snapshot.writable === true
469
+ const tokenStateLabel = tokenState.loading
470
+ ? t('loading')
471
+ : tokenState.configured
472
+ ? t('tokenConfigured')
473
+ : t('tokenMissing')
474
+
475
+ const discard = () => {
476
+ setAllowedUsers(baseUsers)
477
+ setAllowedChats(baseChats)
478
+ setEnabled(baseEnabled)
479
+ setLanguage(baseLanguage)
480
+ setShowThinking(baseShowThinking)
481
+ setShowToolCalls(baseShowToolCalls)
482
+ setTokenDraft('')
483
+ setError(null)
484
+ setNotice(null)
485
+ }
486
+
487
+ const persist = async (withRestart) => {
488
+ if (saving) return
489
+ const trimmed = tokenDraft.trim()
490
+ if (trimmed.length > 0 && /\s/u.test(trimmed)) {
491
+ setError(t('invalidToken'))
492
+ setNotice(null)
493
+ return
494
+ }
495
+ setSaving(true)
496
+ setError(null)
497
+ setNotice(null)
498
+ try {
499
+ const operations = [
500
+ { op: 'set', path: ['enabled'], value: enabled },
501
+ { op: 'set', path: ['language'], value: language },
502
+ { op: 'set', path: ['showThinking'], value: showThinking },
503
+ { op: 'set', path: ['showToolCalls'], value: showToolCalls },
504
+ { op: 'set', path: ['allowedUsers'], value: allowedUsers },
505
+ { op: 'set', path: ['allowedChats'], value: allowedChats },
506
+ ]
507
+ if (withRestart) operations.push({ op: 'set', path: ['restartRequest'], value: Date.now() })
508
+ const accepted = await form.mutate(operations)
509
+ if (!accepted) throw new Error(withRestart ? t('restartFailed') : t('saveFailed'))
510
+ if (trimmed.length > 0) {
511
+ const info = await token.save(tokenRef, trimmed)
512
+ setTokenState({ loading: false, ...info })
513
+ setTokenDraft('')
514
+ }
515
+ setNotice(withRestart ? t('restartRequested') : t('saved'))
516
+ } catch (cause) {
517
+ setError(cause?.message ?? String(cause))
518
+ } finally {
519
+ setSaving(false)
520
+ }
521
+ }
522
+
523
+ const save = () => persist(false)
524
+ const restart = () => persist(true)
525
+
526
+ const removeToken = async () => {
527
+ if (saving || !tokenState.writable) return
528
+ if (typeof window !== 'undefined' && !window.confirm(t('removeTokenConfirm'))) return
529
+ setSaving(true)
530
+ setError(null)
531
+ setNotice(null)
532
+ try {
533
+ const info = await token.clear(tokenRef)
534
+ setTokenState({ loading: false, ...info })
535
+ setNotice(t('tokenRemoved'))
536
+ } catch (cause) {
537
+ setError(cause?.message ?? String(cause))
538
+ } finally {
539
+ setSaving(false)
540
+ }
541
+ }
542
+
543
+ if (snapshot.status === 'unavailable') {
544
+ return h(PageFrame, { t }, h('p', { className: 'dtb-notice' }, t('unavailable')))
545
+ }
546
+
547
+ if (snapshot.status !== 'ready' || !initialized) {
548
+ return h(PageFrame, { t }, h('p', { className: 'dtb-loading' }, t('loadingSettings')))
549
+ }
550
+
551
+ return h(
552
+ PageFrame,
553
+ { t },
554
+ writable ? null : h('p', { className: 'dtb-notice' }, t('readOnly')),
555
+ runtimeLoaded ? null : h('p', { className: 'dtb-notice' }, t('hostNeedsRestart')),
556
+ h(
557
+ 'section',
558
+ { className: 'dtb-card' },
559
+ h('h3', { className: 'dtb-cardTitle' }, t('botToken')),
560
+ h(SettingsSecretField, {
561
+ id: 'dsh-telegram-bot-token',
562
+ label: t('tokenValue'),
563
+ hint: tokenState.writable ? t('tokenHint') : t('tokenReadOnly'),
564
+ disabled: saving || tokenState.loading || !tokenState.writable,
565
+ text: tokenDraft,
566
+ configured: tokenState.configured,
567
+ stateLabel: tokenStateLabel,
568
+ onEdit: setTokenDraft,
569
+ }),
570
+ tokenState.configured && tokenState.writable
571
+ ? h(
572
+ 'div',
573
+ { className: 'dtb-addRow' },
574
+ h(
575
+ Button,
576
+ { variant: 'ghost', size: 'sm', disabled: saving, onClick: removeToken },
577
+ t('removeToken'),
578
+ ),
579
+ )
580
+ : null,
581
+ ),
582
+ h(
583
+ 'section',
584
+ { className: 'dtb-card' },
585
+ h('h3', { className: 'dtb-cardTitle' }, t('access')),
586
+ h(
587
+ 'div',
588
+ { className: 'dtb-toggleRow' },
589
+ h(
590
+ 'div',
591
+ { className: 'dtb-toggleCopy' },
592
+ h('span', { className: 'dtb-label' }, t('botLanguage')),
593
+ h('p', { className: 'dtb-hint' }, t('botLanguageHint')),
594
+ ),
595
+ h(
596
+ 'select',
597
+ {
598
+ className: 'dtb-select',
599
+ value: language,
600
+ disabled: saving || !writable,
601
+ onChange: (event) => setLanguage(event.target.value),
602
+ 'aria-label': t('botLanguage'),
603
+ },
604
+ h('option', { value: 'en' }, 'English'),
605
+ h('option', { value: 'ru' }, 'Русский'),
606
+ ),
607
+ ),
608
+ h(
609
+ 'div',
610
+ { className: 'dtb-toggleRow' },
611
+ h(
612
+ 'div',
613
+ { className: 'dtb-toggleCopy' },
614
+ h('span', { className: 'dtb-label' }, t('enabled')),
615
+ h('p', { className: 'dtb-hint' }, t('enabledHint')),
616
+ ),
617
+ h(Switch, {
618
+ checked: enabled,
619
+ label: t('enabled'),
620
+ disabled: saving || !writable,
621
+ onChange: setEnabled,
622
+ }),
623
+ ),
624
+ h(
625
+ 'div',
626
+ { className: 'dtb-toggleRow' },
627
+ h(
628
+ 'div',
629
+ { className: 'dtb-toggleCopy' },
630
+ h('span', { className: 'dtb-label' }, t('showThinking')),
631
+ h('p', { className: 'dtb-hint' }, t('showThinkingHint')),
632
+ ),
633
+ h(Switch, {
634
+ checked: showThinking,
635
+ label: t('showThinking'),
636
+ disabled: saving || !writable,
637
+ onChange: setShowThinking,
638
+ }),
639
+ ),
640
+ h(
641
+ 'div',
642
+ { className: 'dtb-toggleRow' },
643
+ h(
644
+ 'div',
645
+ { className: 'dtb-toggleCopy' },
646
+ h('span', { className: 'dtb-label' }, t('showToolCalls')),
647
+ h('p', { className: 'dtb-hint' }, t('showToolCallsHint')),
648
+ ),
649
+ h(Switch, {
650
+ checked: showToolCalls,
651
+ label: t('showToolCalls'),
652
+ disabled: saving || !writable,
653
+ onChange: setShowToolCalls,
654
+ }),
655
+ ),
656
+ h(ListEditor, {
657
+ t,
658
+ label: t('allowedUsers'),
659
+ hint: t('allowedUsersHint'),
660
+ items: allowedUsers,
661
+ onChange: setAllowedUsers,
662
+ placeholder: t('placeholderUsers'),
663
+ emptyLabel: t('emptyUsers'),
664
+ disabled: saving || !writable,
665
+ }),
666
+ h(ListEditor, {
667
+ t,
668
+ label: t('allowedChats'),
669
+ hint: t('allowedChatsHint'),
670
+ items: allowedChats,
671
+ onChange: setAllowedChats,
672
+ placeholder: t('placeholderChats'),
673
+ emptyLabel: t('emptyChats'),
674
+ disabled: saving || !writable,
675
+ }),
676
+ ),
677
+ h(
678
+ 'footer',
679
+ { className: 'dtb-footer' },
680
+ h(
681
+ 'div',
682
+ {
683
+ className: error ? 'dtb-message dtb-messageError' : notice ? 'dtb-message dtb-messageOk' : 'dtb-message',
684
+ role: 'status',
685
+ 'aria-live': 'polite',
686
+ },
687
+ error ?? notice ?? '',
688
+ ),
689
+ h(
690
+ 'div',
691
+ { className: 'dtb-footerActions' },
692
+ h(
693
+ Button,
694
+ { variant: 'outline', disabled: !dirty || saving, onClick: discard },
695
+ t('discard'),
696
+ ),
697
+ h(
698
+ Button,
699
+ { variant: 'outline', disabled: saving || !writable || !runtimeLoaded, onClick: restart },
700
+ t('restart'),
701
+ ),
702
+ h(
703
+ Button,
704
+ {
705
+ variant: 'primary',
706
+ disabled: !dirty || saving || !writable,
707
+ onClick: save,
708
+ },
709
+ saving ? t('saving') : t('save'),
710
+ ),
711
+ ),
712
+ ),
713
+ )
714
+ }
715
+
716
+
717
+ /**
718
+ * Compact controls on the Plugins page's bundle detail. The full allowlists
719
+ * stay in Settings → Telegram; this card owns the restart and token/status
720
+ * summary. The bundle switch itself is the plugin's on/off control.
721
+ * @param props - locale reader, settings form, credential operations, view.
722
+ * @returns the summary line or the quick controls.
723
+ */
724
+ function BundleSettings(props) {
725
+ const { t, view } = props
726
+ if (view === 'summary') {
727
+ return h('span', null, `${t('bundleSummary')} · ${t('pluginPageLocation')}`)
728
+ }
729
+ return h(
730
+ 'div',
731
+ { className: 'dtb-plugin' },
732
+ h('p', { className: 'dtb-description' }, t('pluginPageDescription')),
733
+ h(
734
+ 'p',
735
+ { className: 'dtb-notice' },
736
+ t('pluginConfigFiles'),
737
+ h('br'),
738
+ h('code', null, '~/.dsh/profiles/web/cordis.patch.yml'),
739
+ ` — ${t('pluginConfigSettings')}`,
740
+ h('br'),
741
+ h('code', null, '~/.dsh/.credentials.yaml'),
742
+ ` — ${t('pluginConfigToken')}`,
743
+ h('br'),
744
+ h('code', null, '~/.dsh/.env'),
745
+ ` — ${t('pluginConfigEnv')}`,
746
+ ),
747
+ )
748
+ }
749
+
750
+ /** Required client services. */
751
+ const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms']
752
+
753
+ /**
754
+ * Register the Telegram section while the Host serves this bundle's
755
+ * settings namespace.
756
+ * @param ctx - the browser plugin context.
757
+ */
758
+ function apply(ctx) {
759
+ const t = ctx.locale.bind(NS)
760
+ ctx.effect(() => {
761
+ const offEn = ctx.locale.register(NS, 'en', en)
762
+ const offRu = ctx.locale.register(NS, 'ru', ru)
763
+ return () => {
764
+ offRu()
765
+ offEn()
766
+ }
767
+ }, 'dsh-telegram-bot: dictionaries')
768
+ ctx.effect(() => {
769
+ // Russian needs a selectable language pack. If another plugin already
770
+ // registered it, keep that definition and only contribute our copy.
771
+ try {
772
+ return ctx.locale.addLanguage({ id: 'ru', label: 'Русский', fallback: 'en' })
773
+ } catch {
774
+ return () => {}
775
+ }
776
+ }, 'dsh-telegram-bot: russian language')
777
+
778
+ const form = ctx.configForms.get(NS)
779
+ const token = {
780
+ read: (ref) => readToken(ctx, ref),
781
+ save: (ref, value) => saveToken(ctx, ref, value),
782
+ clear: (ref) => clearToken(ctx, ref),
783
+ }
784
+
785
+ ctx.effect(
786
+ () =>
787
+ ctx.configForms.whileServed([NS], () =>
788
+ ctx.slots.inject('settings.section', () =>
789
+ ctx.slots.register(
790
+ {
791
+ name: 'settings.section',
792
+ id: 'telegram',
793
+ order: 50,
794
+ label: () => t('nav'),
795
+ inject: () => ({ t, form, token }),
796
+ },
797
+ TelegramSection,
798
+ ),
799
+ ),
800
+ ),
801
+ 'dsh-telegram-bot: settings page',
802
+ )
803
+
804
+ ctx.effect(
805
+ () =>
806
+ ctx.slots.inject('plugins.bundle.config', () =>
807
+ ctx.slots.register(
808
+ {
809
+ name: 'plugins.bundle.config',
810
+ key: 'dsh-telegram-bot',
811
+ locale: NS,
812
+ inject: () => ({ t, form, token }),
813
+ },
814
+ BundleSettings,
815
+ ),
816
+ ),
817
+ 'dsh-telegram-bot: bundle settings',
818
+ )
819
+ }
820
+
821
+ exports.NS = NS
822
+ exports.apply = apply
823
+ exports.inject = inject
824
+ return module.exports
825
+ },
826
+ })