dsh-telegram-connector 0.14.1 → 0.15.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.2
4
+
5
+ - **The Plugins-page configuration page now actually appears and saves.** The
6
+ three editable fields (`botToken`, `allowedUserIds`, `adminUserId`) are
7
+ `.volatile()`, so DSH projects the row's settings namespace and passes the
8
+ `form` object to `plugins.row.config`; without that the Configure page opened
9
+ with no form. The client bundle now declares the real
10
+ `@deepseek-ai/dsh-client-ui-plugin-manager` inject, runs every React hook
11
+ before the loading/unavailable returns, and uses the current
12
+ `--dsw-alias-*` theme tokens.
13
+ - **Live parameters.** A saved bot token updates the running `TelegramClient`
14
+ and starts polling if the connector was idle; allowed ids and the admin id
15
+ are read from the volatile config on every check. The connector now loads
16
+ with an empty token so the Configure page is reachable, and warns instead of
17
+ returning early.
18
+ - Tests cover the volatile config shape, the live token path and a rendered
19
+ ready snapshot.
20
+
21
+ ## 0.15.1
22
+
23
+ - Release-check fix: the `0.15.0` tag was cut without its COMPATIBILITY row and
24
+ was never published; this is the published release of the configuration page
25
+ described below.
26
+
27
+ ## 0.15.0
28
+
29
+ - **The connector has a configuration page.** On DSH `0.2.1-rc.5` or newer, the
30
+ Plugins page shows **Configure** on the `dsh-telegram-connector` row: bot
31
+ token, allowed user ids and admin user id, saved through the page's own form.
32
+ No `.env` edit or restart is needed; the connector recomposes and starts.
33
+ - **Standalone is a first-class mode.** The setup is optional: the connector
34
+ does not assume the setup supervisor or updater. The setup-specific commands
35
+ report the missing sidecar instead of failing; workspaces, sessions, topics,
36
+ plugins and model calls work on any DSH runtime.
37
+ - The browser half is plain JavaScript in the client-module format, copied to
38
+ `lib/client.js` by the build; the host half is unchanged.
39
+
3
40
  ## 0.14.1
4
41
 
5
42
  - **Component actions no longer restart the runtime.** The setup's
package/COMPATIBILITY.md CHANGED
@@ -6,6 +6,8 @@ every release.
6
6
 
7
7
  | Connector | DSH | Status | Notes |
8
8
  |---|---|---|---|
9
+ | 0.15.2 | `0.2.1-rc.5` | needs UAT | config page fixed: volatile fields expose the form, correct client inject, hooks run before loading returns, DSH theme tokens; saving a token starts polling without a reload |
10
+ | 0.15.1 | `0.2.1-rc.5` | previous | config page attempt; the form stayed hidden because the fields were not volatile and the client inject named a package that does not exist |
9
11
  | 0.14.1 | `0.2.1-rc.5` | needs UAT | component actions no longer restart the runtime; the tools load on the next start |
10
12
  | 0.14.0 | `0.2.1-rc.5` | needs UAT | one command surface: `/menu` card, `/plugins` subcommands, `/components`; aliases and `/man` removed |
11
13
  | 0.13.1 | `0.2.1-rc.5` | needs UAT | `/system status` probes the advertised web address, so a LAN-only bind no longer reports loopback as unreachable |
package/README.md CHANGED
@@ -141,11 +141,11 @@ Telegram:
141
141
  ```sh
142
142
  git clone https://github.com/vladtsvirko/dsh-tg-docker-setup
143
143
  cd dsh-tg-docker-setup
144
- ./install.sh # asks for mode, port, bind address, provider, key,
144
+ ./setup.sh # asks for port, bind address, provider, key,
145
145
  # bot token, allowed ids, admin id, git author
146
146
  ```
147
147
 
148
- `./install.sh --check` prints the whole plan and changes nothing. The setup
148
+ `./setup.sh --check` prints the whole plan and changes nothing. The setup
149
149
  pins a recommended connector tag in its `plugins.json`; the connector is
150
150
  versioned on its own and can move to a newer compatible version from the bot without
151
151
  a setup release. See [`docs/STANDALONE.md`](docs/STANDALONE.md) for the same
@@ -154,8 +154,7 @@ install without the setup.
154
154
  The connector is published to npm as an exact version:
155
155
 
156
156
  ```sh
157
- dsh plugin --profile web add dsh-telegram-connector@0.13.1
158
- export DSH_TELEGRAM_BOT_TOKEN='123456:ABC…'
157
+ dsh plugin --profile web add dsh-telegram-connector@0.15.2
159
158
  ```
160
159
 
161
160
  The registry tarball already contains the built `lib/`, so there is no
@@ -163,6 +162,22 @@ The registry tarball already contains the built `lib/`, so there is no
163
162
  the plugin. The setup's `plugins.json` uses the same spec and keeps the GitHub
164
163
  tag as a fallback when the registry is unreachable.
165
164
 
165
+ ### Configure in the UI
166
+
167
+ On DSH `0.2.1-rc.5` or newer, open **Plugins** → **dsh-telegram-connector** and
168
+ press **Configure** on the `dsh-telegram-connector` row. The page has three
169
+ fields — **bot token**, **allowed user ids** and **admin user id** — and
170
+ **Save** writes the row's config into the profile's user layer. No `.env` edit
171
+ or restart is needed; the connector reconnects with a saved token and starts
172
+ polling, and allowed/admin ids are read on each check.
173
+
174
+ That is the standalone path: the setup is not required, and the connector does
175
+ not assume the setup's supervisor or updater. Commands that need them
176
+ (`/system update setup`, `/components`, `/firewall allow`, restart/rollback)
177
+ report that the sidecar is unavailable instead of failing. Everything else —
178
+ workspaces, sessions, topics, plugins and model calls — works on any DSH
179
+ runtime.
180
+
166
181
  Installing from the source repository is still possible:
167
182
 
168
183
  ```sh
@@ -170,7 +185,7 @@ cat >> "$DSH_HOME/profiles/web/pnpm-workspace.yaml" <<'YAML'
170
185
  allowBuilds:
171
186
  dsh-telegram-connector@https://github.com/vladtsvirko/dsh-telegram-connector.git: true
172
187
  YAML
173
- dsh plugin --profile web add github:vladtsvirko/dsh-telegram-connector#v0.13.1
188
+ dsh plugin --profile web add github:vladtsvirko/dsh-telegram-connector#v0.15.2
174
189
  ```
175
190
 
176
191
  That path runs the package's `prepare` build; pnpm ≥ 10 refuses it until the
@@ -180,6 +195,9 @@ The package is a plugin bundle: `cordis.patch.yml` mounts one row.
180
195
 
181
196
  ## Configuration
182
197
 
198
+ The row config from the UI is the first source. The environment is the
199
+ fallback (the setup writes it; a standalone profile can export it instead):
200
+
183
201
  Environment:
184
202
 
185
203
  | Variable | Meaning |
package/docs/DESIGN.md CHANGED
@@ -316,7 +316,9 @@ and directories.
316
316
  Commit/Roll back, component Enable/Disable/Port, the update card's **Cancel** and Take
317
317
  over all check `adminUserId`. The allowlist only decides who may talk to the
318
318
  agent; a button that changes the deployment is an admin action.
319
- - The bot token comes from the environment, never from `cordis.patch.yml`.
319
+ - The bot token comes from the profile's Plugins config (the environment is the
320
+ fallback), never from `cordis.patch.yml`. The same applies to the allowlist
321
+ and admin id.
320
322
  - The bot is a group admin and can delete topics: `/dangerous cleanup` requires
321
323
  an explicit `confirm`, and `/forget` never deletes anything.
322
324
  - Plugin mutations are the highest-risk surface: a bundle's code and install
@@ -17,7 +17,7 @@ The same package runs in any DSH profile.
17
17
 
18
18
  ```sh
19
19
  # Published package: the tarball already contains lib/, so no build script.
20
- dsh plugin --profile web add dsh-telegram-connector@0.13.0
20
+ dsh plugin --profile web add dsh-telegram-connector@0.15.2
21
21
  ```
22
22
 
23
23
  From the source repository instead:
@@ -27,11 +27,16 @@ cat >> "$DSH_HOME/profiles/web/pnpm-workspace.yaml" <<'YAML'
27
27
  allowBuilds:
28
28
  dsh-telegram-connector@https://github.com/vladtsvirko/dsh-telegram-connector.git: true
29
29
  YAML
30
- dsh plugin --profile web add github:vladtsvirko/dsh-telegram-connector#v0.13.0
30
+ dsh plugin --profile web add github:vladtsvirko/dsh-telegram-connector#v0.15.2
31
31
  ```
32
32
 
33
- The bundle patch mounts the `dsh-telegram-connector` row and reads deployment
34
- values from the environment:
33
+ Open **Plugins** → **dsh-telegram-connector** → **Configure** and fill in the
34
+ bot token, allowed user ids and optional admin id. **Save** stores them in the
35
+ profile's user layer; a saved token starts polling without a runtime restart,
36
+ and the ids are read on every check. No environment file is required.
37
+
38
+ The bundle patch still reads deployment values from the environment as the
39
+ fallback:
35
40
 
36
41
  ```sh
37
42
  export DSH_TELEGRAM_BOT_TOKEN='123456:ABC…'
package/lib/client.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Browser half: the configuration page for the `dsh-telegram-connector` row.
3
+ *
4
+ * DSH's Plugins page declares the `plugins.row.config` slot; this module
5
+ * registers the row's page with the values a fresh standalone install needs.
6
+ * The host half reads the same volatile keys from its Config schema; the patch
7
+ * keeps the environment as the fallback, and a value saved here is the
8
+ * profile's user layer, which wins over it.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-telegram-connector',
12
+ factory: (require) => {
13
+ var module = { exports: {} }
14
+ var exports = module.exports
15
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
16
+
17
+ const React = require('react')
18
+ const h = React.createElement
19
+
20
+ const KEY = 'dsh-telegram-connector#dsh-telegram-connector'
21
+ const styles = `
22
+ .dsh-telegram-form { display: flex; flex-direction: column; gap: 16px; max-width: 560px; color: var(--dsw-alias-label-primary); font-size: 13px; }
23
+ .dsh-telegram-form .panel { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; background: var(--dsw-alias-bg-layer-2); }
24
+ .dsh-telegram-form .row { display: flex; align-items: center; gap: 6px; font-weight: 500; }
25
+ .dsh-telegram-form .dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); }
26
+ .dsh-telegram-form .dot.ok { background: var(--dsw-alias-state-success-primary); }
27
+ .dsh-telegram-form .muted { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.45; }
28
+ .dsh-telegram-form .field { display: flex; flex-direction: column; gap: 6px; }
29
+ .dsh-telegram-form .label { font-weight: 500; }
30
+ .dsh-telegram-form .hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
31
+ .dsh-telegram-form .input { box-sizing: border-box; width: 100%; min-height: 32px; padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; }
32
+ .dsh-telegram-form .input:focus { outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
33
+ .dsh-telegram-form .input:disabled { opacity: 0.6; }
34
+ .dsh-telegram-form .actions { display: flex; align-items: center; gap: 10px; }
35
+ .dsh-telegram-form .primary { min-height: 32px; padding: 0 14px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; }
36
+ .dsh-telegram-form .primary:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
37
+ .dsh-telegram-form .primary:disabled { opacity: 0.55; cursor: default; }
38
+ .dsh-telegram-form .message { color: var(--dsw-alias-label-secondary); font-size: 12px; }
39
+ `
40
+
41
+ function Field({ label, hint, children }) {
42
+ return h(
43
+ 'label',
44
+ { className: 'field' },
45
+ h('span', { className: 'label' }, label),
46
+ hint === undefined ? null : h('span', { className: 'hint' }, hint),
47
+ children,
48
+ )
49
+ }
50
+
51
+ function parseIds(text) {
52
+ const parts = text.split(/[\s,]+/).map((part) => part.trim()).filter((part) => part.length > 0)
53
+ const ids = []
54
+ for (const part of parts) {
55
+ const id = Number(part)
56
+ if (!Number.isInteger(id) || id <= 0) return undefined
57
+ ids.push(id)
58
+ }
59
+ return ids
60
+ }
61
+
62
+ function TelegramConfigForm({ form }) {
63
+ // Every hook runs before any early return: the page transitions through
64
+ // loading/unavailable snapshots and a conditional hook would crash React.
65
+ const state = form === undefined ? undefined : form.state
66
+ const value = (state === undefined ? undefined : state.value) ?? {}
67
+ const [botToken, setBotToken] = React.useState(String(value.botToken ?? ''))
68
+ const [allowed, setAllowed] = React.useState(
69
+ Array.isArray(value.allowedUserIds) ? value.allowedUserIds.join(', ') : '',
70
+ )
71
+ const [admin, setAdmin] = React.useState(
72
+ value.adminUserId === undefined ? '' : String(value.adminUserId),
73
+ )
74
+ const [saving, setSaving] = React.useState(false)
75
+ const [message, setMessage] = React.useState('')
76
+
77
+ const revision = state === undefined ? undefined : state.revision
78
+ React.useEffect(() => {
79
+ const next = state === undefined ? {} : (state.value ?? {})
80
+ setBotToken(String(next.botToken ?? ''))
81
+ setAllowed(Array.isArray(next.allowedUserIds) ? next.allowedUserIds.join(', ') : '')
82
+ setAdmin(next.adminUserId === undefined ? '' : String(next.adminUserId))
83
+ }, [revision])
84
+
85
+ if (state === undefined) {
86
+ return h('p', null, 'Loading the configuration…')
87
+ }
88
+ if (state.status === 'unavailable') {
89
+ return h('p', null, 'This plugin is not loaded, so it cannot be configured right now.')
90
+ }
91
+ if (state.status === 'loading') {
92
+ return h('p', null, 'Loading the current values…')
93
+ }
94
+
95
+ const savedToken = String(value.botToken ?? '')
96
+ const savedAllowed = Array.isArray(value.allowedUserIds) ? value.allowedUserIds : []
97
+ const savedAdmin = value.adminUserId
98
+ const tokenSet = savedToken.trim().length > 0
99
+ const configured = tokenSet && savedAllowed.length > 0
100
+ const disabled = !state.writable || saving
101
+ const saved = state.user ?? {}
102
+ const source = (key) => (Object.prototype.hasOwnProperty.call(saved, key) ? 'saved here' : 'environment fallback')
103
+
104
+ const save = async () => {
105
+ const ids = parseIds(allowed)
106
+ if (ids === undefined) {
107
+ setMessage('Allowed user ids must be positive integers, separated by commas or spaces.')
108
+ return
109
+ }
110
+ const adminText = admin.trim()
111
+ const adminId = adminText.length === 0 ? undefined : Number(adminText)
112
+ if (adminId !== undefined && (!Number.isInteger(adminId) || adminId <= 0)) {
113
+ setMessage('The admin user id must be a positive integer, or empty to use the first allowed id.')
114
+ return
115
+ }
116
+ setSaving(true)
117
+ setMessage('')
118
+ try {
119
+ const ops = [
120
+ { op: 'set', path: ['botToken'], value: botToken.trim() },
121
+ { op: 'set', path: ['allowedUserIds'], value: ids },
122
+ adminId === undefined
123
+ ? { op: 'unset', path: ['adminUserId'] }
124
+ : { op: 'set', path: ['adminUserId'], value: adminId },
125
+ ]
126
+ const ok = await form.mutate(ops, state.revision)
127
+ if (!ok) {
128
+ setMessage('The runtime did not accept these values; they were left for you to correct.')
129
+ } else if (botToken.trim().length === 0 || ids.length === 0) {
130
+ setMessage('Saved. Set a bot token and at least one allowed user id to start the connector.')
131
+ } else {
132
+ setMessage('Saved. The connector uses these values immediately.')
133
+ }
134
+ } catch (error) {
135
+ setMessage(`Save failed: ${String(error)}`)
136
+ } finally {
137
+ setSaving(false)
138
+ }
139
+ }
140
+
141
+ return h(
142
+ 'div',
143
+ { className: 'dsh-telegram-form' },
144
+ h('style', null, styles),
145
+ h(
146
+ 'div',
147
+ { className: 'panel' },
148
+ h(
149
+ 'div',
150
+ { className: 'row' },
151
+ h('span', { className: configured ? 'dot ok' : 'dot' }),
152
+ h('span', null, configured ? 'Configured' : 'Not configured yet'),
153
+ ),
154
+ h(
155
+ 'div',
156
+ { className: 'muted' },
157
+ `Bot token ${tokenSet ? 'set' : 'not set'} · ${savedAllowed.length} allowed user(s) · admin ${savedAdmin === undefined ? 'first allowed' : String(savedAdmin)}`,
158
+ ),
159
+ h(
160
+ 'div',
161
+ { className: 'muted' },
162
+ `Bot token: ${source('botToken')} · allowed ids: ${source('allowedUserIds')} · admin: ${source('adminUserId')}`,
163
+ ),
164
+ h('div', { className: 'muted' }, 'Saving writes this profile and applies without restarting the runtime.'),
165
+ ),
166
+ h(
167
+ Field,
168
+ {
169
+ label: 'Bot token',
170
+ hint: 'The token from @BotFather. It is stored in this profile; avoid screenshots and commits.',
171
+ },
172
+ h('input', {
173
+ type: 'password',
174
+ className: 'input',
175
+ value: botToken,
176
+ disabled,
177
+ placeholder: '123456789:AA…',
178
+ onChange: (event) => setBotToken(event.target.value),
179
+ }),
180
+ ),
181
+ h(
182
+ Field,
183
+ {
184
+ label: 'Allowed user ids',
185
+ hint: 'Numeric Telegram ids, comma or space separated. Everyone here can talk to the agent and use its sessions.',
186
+ },
187
+ h('input', {
188
+ type: 'text',
189
+ className: 'input',
190
+ value: allowed,
191
+ disabled,
192
+ placeholder: '142283532, 777000111',
193
+ onChange: (event) => setAllowed(event.target.value),
194
+ }),
195
+ ),
196
+ h(
197
+ Field,
198
+ {
199
+ label: 'Admin user id',
200
+ hint: 'Optional. One allowed id that may update, restart, roll back and manage plugins; empty uses the first allowed id.',
201
+ },
202
+ h('input', {
203
+ type: 'text',
204
+ className: 'input',
205
+ value: admin,
206
+ disabled,
207
+ placeholder: 'optional',
208
+ onChange: (event) => setAdmin(event.target.value),
209
+ }),
210
+ ),
211
+ h(
212
+ 'div',
213
+ { className: 'actions' },
214
+ h(
215
+ 'button',
216
+ {
217
+ type: 'button',
218
+ className: 'primary',
219
+ disabled,
220
+ onClick: save,
221
+ },
222
+ saving ? 'Saving…' : 'Save',
223
+ ),
224
+ message.length === 0 ? null : h('span', { className: 'message' }, message),
225
+ ),
226
+ )
227
+ }
228
+
229
+ const inject = ['slots']
230
+ function apply(ctx) {
231
+ ctx.slots.inject('plugins.row.config', () =>
232
+ ctx.slots.register(
233
+ { name: 'plugins.row.config', key: KEY },
234
+ ({ view, form }) => (view === 'summary' ? null : h(TelegramConfigForm, { form })),
235
+ ),
236
+ )
237
+ }
238
+
239
+ exports.NS = 'dsh-telegram-connector'
240
+ exports.apply = apply
241
+ exports.inject = inject
242
+ return module.exports
243
+ },
244
+ })
package/lib/index.d.ts CHANGED
@@ -10,54 +10,35 @@
10
10
  *
11
11
  * @module dsh-telegram-connector
12
12
  */
13
- import type { Context } from '@deepseek-ai/cordis';
13
+ import type { Context, Volatile } from '@deepseek-ai/cordis';
14
14
  import Schema from '@deepseek-ai/schemastery';
15
15
  export declare const name = "dsh-telegram-connector";
16
16
  export declare const inject: string[];
17
- export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
18
- botToken: Schema<string, string, "defined">;
19
- allowedUserIds: Schema<number[], number[], "defined">;
20
- adminUserId: Schema<number, number, "plain">;
21
- workspaceRoot: Schema<string, string, "defined">;
22
- provider: Schema<string, string, "plain">;
23
- model: Schema<string, string, "plain">;
24
- editIntervalMs: Schema<number, number, "defined">;
25
- pollTimeoutSeconds: Schema<number, number, "defined">;
26
- /** Deadline for one Bot API call; a hung call must not freeze a topic. */
27
- requestTimeoutSeconds: Schema<number, number, "defined">;
28
- downloadTimeoutSeconds: Schema<number, number, "defined">;
29
- reconcileIntervalSeconds: Schema<number, number, "defined">;
30
- processMode: Schema<"off" | "summary" | "live", "off" | "summary" | "live", "defined">;
31
- agentPreset: Schema<string, string, "plain">;
32
- reactionsEnabled: Schema<boolean, boolean, "defined">;
33
- readReaction: Schema<string, string, "defined">;
34
- streamingReaction: Schema<string, string, "defined">;
35
- stagedReaction: Schema<string, string, "defined">;
36
- }>>, Schemastery.ObjectT<NoInfer<{
37
- botToken: Schema<string, string, "defined">;
38
- allowedUserIds: Schema<number[], number[], "defined">;
39
- adminUserId: Schema<number, number, "plain">;
40
- workspaceRoot: Schema<string, string, "defined">;
41
- provider: Schema<string, string, "plain">;
42
- model: Schema<string, string, "plain">;
43
- editIntervalMs: Schema<number, number, "defined">;
44
- pollTimeoutSeconds: Schema<number, number, "defined">;
45
- /** Deadline for one Bot API call; a hung call must not freeze a topic. */
46
- requestTimeoutSeconds: Schema<number, number, "defined">;
47
- downloadTimeoutSeconds: Schema<number, number, "defined">;
48
- reconcileIntervalSeconds: Schema<number, number, "defined">;
49
- processMode: Schema<"off" | "summary" | "live", "off" | "summary" | "live", "defined">;
50
- agentPreset: Schema<string, string, "plain">;
51
- reactionsEnabled: Schema<boolean, boolean, "defined">;
52
- readReaction: Schema<string, string, "defined">;
53
- streamingReaction: Schema<string, string, "defined">;
54
- stagedReaction: Schema<string, string, "defined">;
55
- }>>, "plain">;
17
+ export interface ConnectorConfigInput {
18
+ botToken?: string;
19
+ allowedUserIds?: number[];
20
+ adminUserId?: number;
21
+ workspaceRoot?: string;
22
+ provider?: string;
23
+ model?: string;
24
+ editIntervalMs?: number;
25
+ pollTimeoutSeconds?: number;
26
+ requestTimeoutSeconds?: number;
27
+ downloadTimeoutSeconds?: number;
28
+ reconcileIntervalSeconds?: number;
29
+ processMode?: 'off' | 'summary' | 'live';
30
+ agentPreset?: string;
31
+ reactionsEnabled?: boolean;
32
+ readReaction?: string;
33
+ streamingReaction?: string;
34
+ stagedReaction?: string;
35
+ }
36
+ export declare const Config: Schema<ConnectorConfigInput, ConnectorConfig>;
56
37
  export interface ConnectorConfig {
57
- botToken: string;
58
- allowedUserIds: number[];
38
+ botToken: Volatile<string>;
39
+ allowedUserIds: Volatile<number[]>;
59
40
  /** The one account that may run update/restart/rollback/plugins commands. */
60
- adminUserId?: number;
41
+ adminUserId: Volatile<number | undefined>;
61
42
  workspaceRoot?: string;
62
43
  provider?: string;
63
44
  model?: string;
package/lib/index.js CHANGED
@@ -41,9 +41,9 @@ import { bindingsPath, findWorkspace, loadBindings, removeSession, saveBindings,
41
41
  export const name = 'dsh-telegram-connector';
42
42
  export const inject = ['agents', 'sessions'];
43
43
  export const Config = Schema.object({
44
- botToken: Schema.string().default(''),
45
- allowedUserIds: Schema.array(Schema.number()).default([]),
46
- adminUserId: Schema.number(),
44
+ botToken: Schema.string().default('').volatile(),
45
+ allowedUserIds: Schema.array(Schema.number()).default([]).volatile(),
46
+ adminUserId: Schema.number().volatile(),
47
47
  workspaceRoot: Schema.string().default(''),
48
48
  provider: Schema.string(),
49
49
  model: Schema.string(),
@@ -101,9 +101,13 @@ function truncate(text, limit) {
101
101
  return clean.length <= limit ? clean : `${clean.slice(0, limit - 1)}…`;
102
102
  }
103
103
  export function apply(ctx, config) {
104
- if (config.botToken.length === 0) {
105
- ctx.logger.warn('dsh-telegram-connector: botToken is empty; the connector will not start');
106
- return;
104
+ // The three editable fields are volatile: the Plugins page writes them and
105
+ // this row reads them through get(). Everything else stays ordinary config.
106
+ const botToken = () => config.botToken.get();
107
+ const allowedUserIds = () => config.allowedUserIds.get();
108
+ const adminUserId = () => config.adminUserId.get();
109
+ if (botToken().trim().length === 0) {
110
+ ctx.logger.warn('dsh-telegram-connector: botToken is empty; configure it on the Plugins page');
107
111
  }
108
112
  const agentPresetService = () => {
109
113
  const get = ctx.get;
@@ -112,14 +116,14 @@ export function apply(ctx, config) {
112
116
  const requestTimeoutMs = Math.max(5, config.requestTimeoutSeconds ?? 60) * 1000;
113
117
  const pollTimeoutMs = Math.max(5, config.pollTimeoutSeconds ?? 25) * 1000 + 30_000;
114
118
  const downloadTimeoutMs = Math.max(10, config.downloadTimeoutSeconds ?? 120) * 1000;
115
- const client = new TelegramClient(config.botToken, { requestTimeoutMs, pollTimeoutMs, downloadTimeoutMs });
119
+ const client = new TelegramClient(botToken(), { requestTimeoutMs, pollTimeoutMs, downloadTimeoutMs });
116
120
  const agents = ctx.agents;
117
121
  // Publisher bots are send-only; the main bot stays the only poller. Tokens come
118
122
  // from the environment so they never land in the profile patch.
119
123
  const publisherTokens = (process.env.DSH_TELEGRAM_PUBLISHER_TOKENS ?? '')
120
124
  .split(/[,\s]+/)
121
125
  .map((token) => token.trim())
122
- .filter((token) => token.length > 0 && token !== config.botToken);
126
+ .filter((token) => token.length > 0 && token !== botToken());
123
127
  const botHandles = [
124
128
  { label: 'main', client, main: true },
125
129
  ...publisherTokens.map((token, index) => ({
@@ -155,6 +159,10 @@ export function apply(ctx, config) {
155
159
  let bindings = { version: 2, workspaces: {}, sessions: {} };
156
160
  let offset = 0;
157
161
  let stopped = false;
162
+ /** True after start() finished its one-time setup. */
163
+ let started = false;
164
+ /** True while pollLoop owns getUpdates; a token saved later starts it once. */
165
+ let polling = false;
158
166
  let lastErrorAt = 0;
159
167
  let reconcileTimer;
160
168
  let reconciling = false;
@@ -169,12 +177,12 @@ export function apply(ctx, config) {
169
177
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
170
178
  const updaterToken = process.env.DSH_UPDATER_TOKEN ?? '';
171
179
  const updaterUrl = (process.env.DSH_UPDATER_URL ?? 'http://updater:8787').replace(/\/+$/, '');
172
- if (config.allowedUserIds.length === 0) {
180
+ if (allowedUserIds().length === 0) {
173
181
  logger.warn('dsh-telegram-connector: allowedUserIds is empty; every message will be refused');
174
182
  }
175
183
  /** Last error an agent reported, shown by `/system status`. */
176
184
  let lastAgentError;
177
- const isAllowed = (userId) => userId !== undefined && config.allowedUserIds.includes(userId);
185
+ const isAllowed = (userId) => userId !== undefined && allowedUserIds().includes(userId);
178
186
  /**
179
187
  * Who may run profile-global commands (update, restart, rollback, plugins).
180
188
  *
@@ -185,8 +193,8 @@ export function apply(ctx, config) {
185
193
  const isAdmin = (userId) => {
186
194
  if (userId === undefined)
187
195
  return false;
188
- if (config.adminUserId !== undefined)
189
- return userId === config.adminUserId;
196
+ if (adminUserId() !== undefined)
197
+ return userId === adminUserId();
190
198
  // No admin configured (an upgraded deployment, or one set up by hand): the
191
199
  // owner of the control chat is the admin, so an upgrade cannot lock anyone
192
200
  // out. In a private chat the chat id *is* the user id.
@@ -202,9 +210,9 @@ export function apply(ctx, config) {
202
210
  const sender = message.from?.id;
203
211
  if (isAdmin(sender))
204
212
  return true;
205
- const who = config.adminUserId === undefined
213
+ const who = adminUserId() === undefined
206
214
  ? 'the owner of this chat (`DSH_ADMIN_USER_ID` is not set)'
207
- : `admin ${String(config.adminUserId)}`;
215
+ : `admin ${String(adminUserId())}`;
208
216
  void reply(message, `only ${who} can do that — it changes the deployment for everyone.`);
209
217
  return false;
210
218
  };
@@ -1604,11 +1612,11 @@ export function apply(ctx, config) {
1604
1612
  ].join('\n'));
1605
1613
  };
1606
1614
  /** Who may run profile-global commands, and how that was decided. */
1607
- const adminLine = () => config.adminUserId === undefined
1615
+ const adminLine = () => adminUserId() === undefined
1608
1616
  ? bindings.controlChatId === undefined
1609
1617
  ? 'admin: any allowed user until the bot chat is used (set `DSH_ADMIN_USER_ID` to name one)'
1610
1618
  : 'admin: the owner of this chat (set `DSH_ADMIN_USER_ID` to name one explicitly)'
1611
- : `admin: ${String(config.adminUserId)}`;
1619
+ : `admin: ${String(adminUserId())}`;
1612
1620
  /** The provider key this deployment is supposed to have. */
1613
1621
  const providerKeyNames = () => {
1614
1622
  const declared = (process.env.DSH_PROVIDER_API_KEY_ENV ?? '').trim();
@@ -5284,6 +5292,14 @@ export function apply(ctx, config) {
5284
5292
  stderr(`${bot.label}: member of ${stats.present}/${total} bound group(s)`);
5285
5293
  }
5286
5294
  };
5295
+ const ensurePolling = () => {
5296
+ if (!started || polling || stopped || botToken().trim().length === 0)
5297
+ return;
5298
+ polling = true;
5299
+ void pollLoop().finally(() => {
5300
+ polling = false;
5301
+ });
5302
+ };
5287
5303
  const start = async () => {
5288
5304
  // A file that could not be read is set aside by the loader; say so in the
5289
5305
  // chat, once, instead of letting the bot answer "not attached" forever.
@@ -5353,7 +5369,13 @@ export function apply(ctx, config) {
5353
5369
  await sendTo(chat, 0, `the runtime's workspace registry did not list ${String(healed.restored)} workspace(s) / ${String(healed.attached)} session(s) that Telegram still had — they are back, and the web UI lists them again.`, 'control');
5354
5370
  })
5355
5371
  .catch((error) => stderr(`registry repair failed: ${String(error)}`));
5356
- void pollLoop();
5372
+ started = true;
5373
+ if (botToken().trim().length === 0) {
5374
+ stderr('not configured: set the bot token on the Plugins page; polling starts when it is saved');
5375
+ }
5376
+ else {
5377
+ ensurePolling();
5378
+ }
5357
5379
  void announceTrial();
5358
5380
  void flushBridgeTurns().catch((error) => stderr(`bridge flush failed: ${String(error)}`));
5359
5381
  void replayBridgeInputs().catch((error) => stderr(`bridge replay failed: ${String(error)}`));
@@ -5732,6 +5754,22 @@ export function apply(ctx, config) {
5732
5754
  return;
5733
5755
  }
5734
5756
  });
5757
+ // A volatile-only config commit updates the running fiber in place; it does
5758
+ // not reload this plugin. Follow the values that affect the long-lived bot:
5759
+ // a new token changes the client and starts polling if it was idle.
5760
+ ctx.on('loader/volatile-update', (paths) => {
5761
+ const touched = (field) => paths.some((path) => path[path.length - 1] === field);
5762
+ if (touched('botToken')) {
5763
+ const next = botToken().trim();
5764
+ client.setToken(next);
5765
+ stderr(next.length > 0 ? 'bot token updated; polling with the new token' : 'bot token cleared; polling will fail until one is set');
5766
+ ensurePolling();
5767
+ }
5768
+ if (touched('allowedUserIds'))
5769
+ stderr(`allowed users updated (${String(allowedUserIds().length)})`);
5770
+ if (touched('adminUserId'))
5771
+ stderr(`admin updated (${adminUserId() === undefined ? 'first allowed user' : String(adminUserId())})`);
5772
+ });
5735
5773
  ctx.effect(() => {
5736
5774
  void start();
5737
5775
  return () => {