dsh-telegram-connector 0.15.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,23 @@
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
+
3
21
  ## 0.15.1
4
22
 
5
23
  - Release-check fix: the `0.15.0` tag was cut without its COMPATIBILITY row and
package/COMPATIBILITY.md CHANGED
@@ -6,7 +6,8 @@ every release.
6
6
 
7
7
  | Connector | DSH | Status | Notes |
8
8
  |---|---|---|---|
9
- | 0.15.1 | `0.2.1-rc.5` | needs UAT | config page (Plugins → Configure) for bot token, allowed ids and admin id; standalone without the setup updater |
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 |
10
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 |
11
12
  | 0.14.0 | `0.2.1-rc.5` | needs UAT | one command surface: `/menu` card, `/plugins` subcommands, `/components`; aliases and `/man` removed |
12
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
@@ -154,7 +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.15.1
157
+ dsh plugin --profile web add dsh-telegram-connector@0.15.2
158
158
  ```
159
159
 
160
160
  The registry tarball already contains the built `lib/`, so there is no
@@ -168,7 +168,8 @@ On DSH `0.2.1-rc.5` or newer, open **Plugins** → **dsh-telegram-connector** an
168
168
  press **Configure** on the `dsh-telegram-connector` row. The page has three
169
169
  fields — **bot token**, **allowed user ids** and **admin user id** — and
170
170
  **Save** writes the row's config into the profile's user layer. No `.env` edit
171
- or restart is needed; the connector recomposes and starts polling.
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.
172
173
 
173
174
  That is the standalone path: the setup is not required, and the connector does
174
175
  not assume the setup's supervisor or updater. Commands that need them
@@ -184,7 +185,7 @@ cat >> "$DSH_HOME/profiles/web/pnpm-workspace.yaml" <<'YAML'
184
185
  allowBuilds:
185
186
  dsh-telegram-connector@https://github.com/vladtsvirko/dsh-telegram-connector.git: true
186
187
  YAML
187
- dsh plugin --profile web add github:vladtsvirko/dsh-telegram-connector#v0.15.1
188
+ dsh plugin --profile web add github:vladtsvirko/dsh-telegram-connector#v0.15.2
188
189
  ```
189
190
 
190
191
  That path runs the package's `prepare` build; pnpm ≥ 10 refuses it until the
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 CHANGED
@@ -2,9 +2,9 @@
2
2
  * Browser half: the configuration page for the `dsh-telegram-connector` row.
3
3
  *
4
4
  * DSH's Plugins page declares the `plugins.row.config` slot; this module
5
- * registers the row's page with the three answers a fresh standalone install
6
- * needs. The host half already reads the same keys from its Config schema; the
7
- * patch keeps the environment as the fallback, and a value saved here is the
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
8
  * profile's user layer, which wins over it.
9
9
  */
10
10
  window.__ModuleLoader__.load({
@@ -18,67 +18,99 @@ window.__ModuleLoader__.load({
18
18
  const h = React.createElement
19
19
 
20
20
  const KEY = 'dsh-telegram-connector#dsh-telegram-connector'
21
- const inputStyle = {
22
- boxSizing: 'border-box',
23
- width: '100%',
24
- padding: '6px 8px',
25
- borderRadius: 6,
26
- border: '1px solid var(--dsh-border, #d0d5dd)',
27
- background: 'var(--dsh-surface, #fff)',
28
- color: 'inherit',
29
- font: 'inherit',
30
- }
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
+ `
31
40
 
32
- function field(label, hint, input) {
41
+ function Field({ label, hint, children }) {
33
42
  return h(
34
43
  'label',
35
- { style: { display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 14 } },
36
- h('span', { style: { fontWeight: 600 } }, label),
37
- hint === undefined ? null : h('span', { style: { opacity: 0.7, fontSize: 12 } }, hint),
38
- input,
44
+ { className: 'field' },
45
+ h('span', { className: 'label' }, label),
46
+ hint === undefined ? null : h('span', { className: 'hint' }, hint),
47
+ children,
39
48
  )
40
49
  }
41
50
 
42
51
  function parseIds(text) {
43
- return text
44
- .split(/[\s,]+/)
45
- .map((part) => part.trim())
46
- .filter((part) => part.length > 0)
47
- .map((part) => Number(part))
48
- .filter((id) => Number.isFinite(id) && id > 0)
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
49
60
  }
50
61
 
51
62
  function TelegramConfigForm({ form }) {
52
- if (form === undefined) {
53
- return h('p', null, 'This plugin does not expose its configuration in this build.')
54
- }
55
- const state = form.state
56
- if (state.status === 'unavailable') {
57
- return h('p', null, 'This plugin is not loaded, so it cannot be configured right now.')
58
- }
59
- if (state.status === 'loading') {
60
- return h('p', null, 'Loading the current values…')
61
- }
62
- const value = state.value ?? {}
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) ?? {}
63
67
  const [botToken, setBotToken] = React.useState(String(value.botToken ?? ''))
64
- const [allowed, setAllowed] = React.useState(Array.isArray(value.allowedUserIds) ? value.allowedUserIds.join(', ') : '')
65
- const [admin, setAdmin] = React.useState(value.adminUserId === undefined ? '' : String(value.adminUserId))
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
+ )
66
74
  const [saving, setSaving] = React.useState(false)
67
75
  const [message, setMessage] = React.useState('')
68
76
 
77
+ const revision = state === undefined ? undefined : state.revision
69
78
  React.useEffect(() => {
70
- const next = state.value ?? {}
79
+ const next = state === undefined ? {} : (state.value ?? {})
71
80
  setBotToken(String(next.botToken ?? ''))
72
81
  setAllowed(Array.isArray(next.allowedUserIds) ? next.allowedUserIds.join(', ') : '')
73
82
  setAdmin(next.adminUserId === undefined ? '' : String(next.adminUserId))
74
- }, [state.revision])
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
+ }
75
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
76
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
+
77
104
  const save = async () => {
78
105
  const ids = parseIds(allowed)
79
- const adminId = admin.trim().length === 0 ? undefined : Number(admin.trim())
80
- if (adminId !== undefined && (!Number.isFinite(adminId) || adminId <= 0)) {
81
- setMessage('The admin user id must be a number, or empty to use the first allowed id.')
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.')
82
114
  return
83
115
  }
84
116
  setSaving(true)
@@ -92,7 +124,13 @@ window.__ModuleLoader__.load({
92
124
  : { op: 'set', path: ['adminUserId'], value: adminId },
93
125
  ]
94
126
  const ok = await form.mutate(ops, state.revision)
95
- setMessage(ok ? 'Saved. The connector reconnects with these values.' : 'The deployment did not accept these values; they were left for you to correct.')
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
+ }
96
134
  } catch (error) {
97
135
  setMessage(`Save failed: ${String(error)}`)
98
136
  } finally {
@@ -102,58 +140,88 @@ window.__ModuleLoader__.load({
102
140
 
103
141
  return h(
104
142
  'div',
105
- null,
106
- h('p', null, 'Connect the bot to Telegram. The token comes from @BotFather; user ids are numeric and come from @userinfobot.'),
107
- field(
108
- 'Bot token',
109
- 'For example 123456789:AA… . Keep it out of screenshots and commits.',
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
+ },
110
172
  h('input', {
111
173
  type: 'password',
174
+ className: 'input',
112
175
  value: botToken,
113
176
  disabled,
114
- placeholder: 'the token from @BotFather',
115
- style: inputStyle,
177
+ placeholder: '123456789:AA…',
116
178
  onChange: (event) => setBotToken(event.target.value),
117
179
  }),
118
180
  ),
119
- field(
120
- 'Allowed user ids',
121
- 'Comma- or space-separated numeric ids. Everyone in this list can talk to the agent and use the sessions.',
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
+ },
122
187
  h('input', {
123
188
  type: 'text',
189
+ className: 'input',
124
190
  value: allowed,
125
191
  disabled,
126
192
  placeholder: '142283532, 777000111',
127
- style: inputStyle,
128
193
  onChange: (event) => setAllowed(event.target.value),
129
194
  }),
130
195
  ),
131
- field(
132
- 'Admin user id',
133
- 'Optional. One allowed id that may update, restart, roll back and manage plugins; empty uses the first allowed id.',
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
+ },
134
202
  h('input', {
135
203
  type: 'text',
204
+ className: 'input',
136
205
  value: admin,
137
206
  disabled,
138
207
  placeholder: 'optional',
139
- style: inputStyle,
140
208
  onChange: (event) => setAdmin(event.target.value),
141
209
  }),
142
210
  ),
143
211
  h(
144
212
  'div',
145
- { style: { display: 'flex', alignItems: 'center', gap: 10 } },
213
+ { className: 'actions' },
146
214
  h(
147
215
  'button',
148
216
  {
149
217
  type: 'button',
218
+ className: 'primary',
150
219
  disabled,
151
220
  onClick: save,
152
- style: { padding: '6px 14px', borderRadius: 6, cursor: disabled ? 'default' : 'pointer' },
153
221
  },
154
222
  saving ? 'Saving…' : 'Save',
155
223
  ),
156
- message.length === 0 ? null : h('span', { style: { fontSize: 13 } }, message),
224
+ message.length === 0 ? null : h('span', { className: 'message' }, message),
157
225
  ),
158
226
  )
159
227
  }
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 () => {