@sympoies/dsh-telegram 0.0.0-bootstrap.0 → 0.6.1

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.
Files changed (194) hide show
  1. package/LICENSE +1 -2
  2. package/NOTICE +19 -0
  3. package/README.md +588 -2
  4. package/cordis.patch.yml +14 -0
  5. package/lib/access.d.ts +76 -0
  6. package/lib/access.d.ts.map +1 -0
  7. package/lib/access.js +137 -0
  8. package/lib/access.js.map +1 -0
  9. package/lib/client.js +835 -0
  10. package/lib/commands.d.ts +51 -0
  11. package/lib/commands.d.ts.map +1 -0
  12. package/lib/commands.js +83 -0
  13. package/lib/commands.js.map +1 -0
  14. package/lib/config.d.ts +125 -0
  15. package/lib/config.d.ts.map +1 -0
  16. package/lib/config.js +97 -0
  17. package/lib/config.js.map +1 -0
  18. package/lib/diagnostics.d.ts +58 -0
  19. package/lib/diagnostics.d.ts.map +1 -0
  20. package/lib/diagnostics.js +59 -0
  21. package/lib/diagnostics.js.map +1 -0
  22. package/lib/failures.d.ts +71 -0
  23. package/lib/failures.d.ts.map +1 -0
  24. package/lib/failures.js +139 -0
  25. package/lib/failures.js.map +1 -0
  26. package/lib/harness/host.d.ts +106 -0
  27. package/lib/harness/host.d.ts.map +1 -0
  28. package/lib/harness/host.js +142 -0
  29. package/lib/harness/host.js.map +1 -0
  30. package/lib/harness/message.d.ts +45 -0
  31. package/lib/harness/message.d.ts.map +1 -0
  32. package/lib/harness/message.js +28 -0
  33. package/lib/harness/message.js.map +1 -0
  34. package/lib/harness/model-selection.d.ts +82 -0
  35. package/lib/harness/model-selection.d.ts.map +1 -0
  36. package/lib/harness/model-selection.js +103 -0
  37. package/lib/harness/model-selection.js.map +1 -0
  38. package/lib/harness/questions-seam.d.ts +34 -0
  39. package/lib/harness/questions-seam.d.ts.map +1 -0
  40. package/lib/harness/questions-seam.js +45 -0
  41. package/lib/harness/questions-seam.js.map +1 -0
  42. package/lib/harness/types.d.ts +108 -0
  43. package/lib/harness/types.d.ts.map +1 -0
  44. package/lib/harness/types.js +23 -0
  45. package/lib/harness/types.js.map +1 -0
  46. package/lib/index.d.ts +65 -0
  47. package/lib/index.d.ts.map +1 -0
  48. package/lib/index.js +977 -0
  49. package/lib/index.js.map +1 -0
  50. package/lib/interact/approvals.d.ts +59 -0
  51. package/lib/interact/approvals.d.ts.map +1 -0
  52. package/lib/interact/approvals.js +132 -0
  53. package/lib/interact/approvals.js.map +1 -0
  54. package/lib/interact/pending.d.ts +56 -0
  55. package/lib/interact/pending.d.ts.map +1 -0
  56. package/lib/interact/pending.js +89 -0
  57. package/lib/interact/pending.js.map +1 -0
  58. package/lib/interact/questions.d.ts +100 -0
  59. package/lib/interact/questions.d.ts.map +1 -0
  60. package/lib/interact/questions.js +255 -0
  61. package/lib/interact/questions.js.map +1 -0
  62. package/lib/interact/surface.d.ts +33 -0
  63. package/lib/interact/surface.d.ts.map +1 -0
  64. package/lib/interact/surface.js +39 -0
  65. package/lib/interact/surface.js.map +1 -0
  66. package/lib/interact/text-capture.d.ts +38 -0
  67. package/lib/interact/text-capture.d.ts.map +1 -0
  68. package/lib/interact/text-capture.js +75 -0
  69. package/lib/interact/text-capture.js.map +1 -0
  70. package/lib/media/collect.d.ts +157 -0
  71. package/lib/media/collect.d.ts.map +1 -0
  72. package/lib/media/collect.js +235 -0
  73. package/lib/media/collect.js.map +1 -0
  74. package/lib/media/extractor.d.ts +117 -0
  75. package/lib/media/extractor.d.ts.map +1 -0
  76. package/lib/media/extractor.js +207 -0
  77. package/lib/media/extractor.js.map +1 -0
  78. package/lib/media/intake.d.ts +57 -0
  79. package/lib/media/intake.d.ts.map +1 -0
  80. package/lib/media/intake.js +115 -0
  81. package/lib/media/intake.js.map +1 -0
  82. package/lib/media/limits.d.ts +69 -0
  83. package/lib/media/limits.d.ts.map +1 -0
  84. package/lib/media/limits.js +97 -0
  85. package/lib/media/limits.js.map +1 -0
  86. package/lib/media/ocr.d.ts +66 -0
  87. package/lib/media/ocr.d.ts.map +1 -0
  88. package/lib/media/ocr.js +131 -0
  89. package/lib/media/ocr.js.map +1 -0
  90. package/lib/media/screenshot.d.ts +57 -0
  91. package/lib/media/screenshot.d.ts.map +1 -0
  92. package/lib/media/screenshot.js +101 -0
  93. package/lib/media/screenshot.js.map +1 -0
  94. package/lib/media/vision.d.ts +60 -0
  95. package/lib/media/vision.d.ts.map +1 -0
  96. package/lib/media/vision.js +74 -0
  97. package/lib/media/vision.js.map +1 -0
  98. package/lib/render/clamp.d.ts +34 -0
  99. package/lib/render/clamp.d.ts.map +1 -0
  100. package/lib/render/clamp.js +65 -0
  101. package/lib/render/clamp.js.map +1 -0
  102. package/lib/render/escape.d.ts +15 -0
  103. package/lib/render/escape.d.ts.map +1 -0
  104. package/lib/render/escape.js +17 -0
  105. package/lib/render/escape.js.map +1 -0
  106. package/lib/reply/activity.d.ts +31 -0
  107. package/lib/reply/activity.d.ts.map +1 -0
  108. package/lib/reply/activity.js +88 -0
  109. package/lib/reply/activity.js.map +1 -0
  110. package/lib/reply/rich-stream.d.ts +173 -0
  111. package/lib/reply/rich-stream.d.ts.map +1 -0
  112. package/lib/reply/rich-stream.js +286 -0
  113. package/lib/reply/rich-stream.js.map +1 -0
  114. package/lib/reply/split-markdown.d.ts +21 -0
  115. package/lib/reply/split-markdown.d.ts.map +1 -0
  116. package/lib/reply/split-markdown.js +87 -0
  117. package/lib/reply/split-markdown.js.map +1 -0
  118. package/lib/reply/turn-bridge.d.ts +143 -0
  119. package/lib/reply/turn-bridge.d.ts.map +1 -0
  120. package/lib/reply/turn-bridge.js +182 -0
  121. package/lib/reply/turn-bridge.js.map +1 -0
  122. package/lib/router.d.ts +415 -0
  123. package/lib/router.d.ts.map +1 -0
  124. package/lib/router.js +663 -0
  125. package/lib/router.js.map +1 -0
  126. package/lib/secrets.d.ts +30 -0
  127. package/lib/secrets.d.ts.map +1 -0
  128. package/lib/secrets.js +51 -0
  129. package/lib/secrets.js.map +1 -0
  130. package/lib/session/bindings.d.ts +81 -0
  131. package/lib/session/bindings.d.ts.map +1 -0
  132. package/lib/session/bindings.js +170 -0
  133. package/lib/session/bindings.js.map +1 -0
  134. package/lib/session/history.d.ts +50 -0
  135. package/lib/session/history.d.ts.map +1 -0
  136. package/lib/session/history.js +127 -0
  137. package/lib/session/history.js.map +1 -0
  138. package/lib/session/models.d.ts +99 -0
  139. package/lib/session/models.d.ts.map +1 -0
  140. package/lib/session/models.js +123 -0
  141. package/lib/session/models.js.map +1 -0
  142. package/lib/session/permission.d.ts +83 -0
  143. package/lib/session/permission.d.ts.map +1 -0
  144. package/lib/session/permission.js +107 -0
  145. package/lib/session/permission.js.map +1 -0
  146. package/lib/session/picker.d.ts +49 -0
  147. package/lib/session/picker.d.ts.map +1 -0
  148. package/lib/session/picker.js +107 -0
  149. package/lib/session/picker.js.map +1 -0
  150. package/lib/session/preferences.d.ts +55 -0
  151. package/lib/session/preferences.d.ts.map +1 -0
  152. package/lib/session/preferences.js +102 -0
  153. package/lib/session/preferences.js.map +1 -0
  154. package/lib/session/recovery.d.ts +66 -0
  155. package/lib/session/recovery.d.ts.map +1 -0
  156. package/lib/session/recovery.js +112 -0
  157. package/lib/session/recovery.js.map +1 -0
  158. package/lib/session/runner.d.ts +223 -0
  159. package/lib/session/runner.d.ts.map +1 -0
  160. package/lib/session/runner.js +260 -0
  161. package/lib/session/runner.js.map +1 -0
  162. package/lib/session/workspaces.d.ts +28 -0
  163. package/lib/session/workspaces.d.ts.map +1 -0
  164. package/lib/session/workspaces.js +53 -0
  165. package/lib/session/workspaces.js.map +1 -0
  166. package/lib/telegram/addressing.d.ts +39 -0
  167. package/lib/telegram/addressing.d.ts.map +1 -0
  168. package/lib/telegram/addressing.js +81 -0
  169. package/lib/telegram/addressing.js.map +1 -0
  170. package/lib/telegram/albums.d.ts +46 -0
  171. package/lib/telegram/albums.d.ts.map +1 -0
  172. package/lib/telegram/albums.js +104 -0
  173. package/lib/telegram/albums.js.map +1 -0
  174. package/lib/telegram/api.d.ts +238 -0
  175. package/lib/telegram/api.d.ts.map +1 -0
  176. package/lib/telegram/api.js +518 -0
  177. package/lib/telegram/api.js.map +1 -0
  178. package/lib/telegram/poller.d.ts +59 -0
  179. package/lib/telegram/poller.d.ts.map +1 -0
  180. package/lib/telegram/poller.js +94 -0
  181. package/lib/telegram/poller.js.map +1 -0
  182. package/lib/telegram/types.d.ts +130 -0
  183. package/lib/telegram/types.d.ts.map +1 -0
  184. package/lib/telegram/types.js +9 -0
  185. package/lib/telegram/types.js.map +1 -0
  186. package/lib/telegram/typing.d.ts +47 -0
  187. package/lib/telegram/typing.d.ts.map +1 -0
  188. package/lib/telegram/typing.js +91 -0
  189. package/lib/telegram/typing.js.map +1 -0
  190. package/lib/versions.d.ts +63 -0
  191. package/lib/versions.d.ts.map +1 -0
  192. package/lib/versions.js +135 -0
  193. package/lib/versions.js.map +1 -0
  194. package/package.json +94 -10
package/lib/index.js ADDED
@@ -0,0 +1,977 @@
1
+ /**
2
+ * dsh-telegram — a Telegram front end for DeepSeek Harness.
3
+ *
4
+ * Two things this plugin does that a plain channel bridge does not:
5
+ *
6
+ * 1. **The agent's markdown arrives as markdown.** Replies are rendered to
7
+ * Telegram HTML and sent with `parse_mode`, so bold is bold and a code
8
+ * block is a code block — including while the answer is still streaming.
9
+ * 2. **Questions and approvals can be answered from the chat.** The plugin
10
+ * registers a `ctx.userQuestions` provider and answers the
11
+ * `approval/request` waterfall, so `ask_user_question` and a tool that
12
+ * needs consent both become buttons in Telegram instead of a stall that
13
+ * only a browser can clear.
14
+ *
15
+ * Everything here is wiring. The behaviour lives in the modules below, each
16
+ * testable without a harness; this file is the single place that touches the
17
+ * live `ctx`.
18
+ */
19
+ import { homedir } from 'node:os';
20
+ import { stat } from 'node:fs/promises';
21
+ import { readFileSync, realpathSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { randomUUID } from 'node:crypto';
24
+ import { AccessPolicy } from './access.js';
25
+ import { commandMenu } from './commands.js';
26
+ import { StatusFile, describeError } from './diagnostics.js';
27
+ import { SecretRegistry } from './secrets.js';
28
+ import { Config } from './config.js';
29
+ import { TelegramApprovalAnswerer } from './interact/approvals.js';
30
+ import { PendingRegistry } from './interact/pending.js';
31
+ import { TelegramQuestionProvider } from './interact/questions.js';
32
+ import { telegramSurface } from './interact/surface.js';
33
+ import { TextCapture } from './interact/text-capture.js';
34
+ import { TurnBridge } from './reply/turn-bridge.js';
35
+ import { canStreamTo } from './reply/rich-stream.js';
36
+ import { UpdateRouter } from './router.js';
37
+ import { escapeHtml } from './render/escape.js';
38
+ import { BindingStore } from './session/bindings.js';
39
+ import { RecoveryOffer } from './session/recovery.js';
40
+ import { SessionRunner } from './session/runner.js';
41
+ import { PermissionControl, matchPreset } from './session/permission.js';
42
+ import { PHOTO_LIMIT_BYTES, Screenshotter } from './media/screenshot.js';
43
+ import { FailureLog, recordingLogger } from './failures.js';
44
+ import { VersionCheck } from './versions.js';
45
+ import { ChatHistory } from './session/history.js';
46
+ import { SessionPicker } from './session/picker.js';
47
+ import { isUsableDirectory, resolveDirectory } from './session/workspaces.js';
48
+ import { ChatPreferences } from './session/preferences.js';
49
+ import { effortsFor, formatRoute, listCatalog, matchEffort, matchRoute } from './session/models.js';
50
+ import { TelegramApi, TelegramApiError } from './telegram/api.js';
51
+ import { UpdatePoller } from './telegram/poller.js';
52
+ import { createAgentHost } from './harness/host.js';
53
+ import { TypingIndicator } from './telegram/typing.js';
54
+ import { VisionExtractor } from './media/extractor.js';
55
+ import { OcrReader } from './media/ocr.js';
56
+ import { MediaCollector } from './media/collect.js';
57
+ import { VisionCheck, acceptsImages } from './media/vision.js';
58
+ import { buildUserMessage } from './harness/message.js';
59
+ import { installModelSelection, parseRoute } from './harness/model-selection.js';
60
+ import { installQuestionProvider } from './harness/questions-seam.js';
61
+ export { Config };
62
+ /** Cordis plugin name; the package name, which is also the module id. */
63
+ export const name = '@sympoies/dsh-telegram';
64
+ /**
65
+ * Settings namespace this plugin owns. The browser half binds the same string,
66
+ * which is the only thing pairing the two halves together.
67
+ */
68
+ export const SETTINGS_NAMESPACE = 'telegram';
69
+ /**
70
+ * Hard requirements only. Cordis reads this as a flat list of service names —
71
+ * an object form would be read as services literally named after its keys — so
72
+ * the interactive seams are bound inside `apply` with `ctx.inject()` instead.
73
+ * That is what lets the plugin load on a profile that provides neither.
74
+ */
75
+ export const inject = ['agents', 'credentials'];
76
+ /**
77
+ * Load the plugin.
78
+ *
79
+ * @param ctx - the cordis context.
80
+ * @param config - resolved plugin configuration.
81
+ */
82
+ export function apply(ctx, config) {
83
+ // Wrapped rather than replaced: whatever sink the deployment composed still
84
+ // gets everything, and this keeps a copy of the parts worth looking back at.
85
+ // Several profiles compose no sink at all, which is how this plugin's faults
86
+ // came to be found by noticing odd behaviour in a chat rather than by
87
+ // reading a log.
88
+ // One registry for the plugin's lifetime, created first because everything
89
+ // written after it — files, log lines, chat messages — passes through it.
90
+ const secrets = new SecretRegistry();
91
+ const failures = new FailureLog({
92
+ file: join(dataDirectory(), 'failures.json'),
93
+ redact: secrets.redactor(),
94
+ });
95
+ const logger = recordingLogger(ctx.logger('dsh-telegram'), failures);
96
+ let live = config;
97
+ let abort;
98
+ const status = new StatusFile(join(dataDirectory(), 'status.json'), secrets.redactor());
99
+ /** (Re)open the connection under the configuration standing right now. */
100
+ const run = () => {
101
+ abort?.abort();
102
+ abort = new AbortController();
103
+ const signal = abort.signal;
104
+ void start(ctx, live, logger, signal, status, secrets, failures).catch((error) => {
105
+ if (signal.aborted)
106
+ return;
107
+ logger.error('[dsh-telegram] failed to start', error);
108
+ void status.publish('failed', { detail: describeError(error) });
109
+ });
110
+ };
111
+ ctx.effect(() => {
112
+ run();
113
+ return () => abort?.abort();
114
+ }, 'dsh-telegram: connection');
115
+ // Registering the namespace is what puts this plugin in front of a
116
+ // configuration UI at all, and it happens even when the plugin is disabled —
117
+ // otherwise the one screen that could re-enable it would have nothing to
118
+ // show.
119
+ //
120
+ // Bound through ctx.inject rather than read with ctx.get: a settings provider
121
+ // that composes after this entry would leave a get() empty, and the page
122
+ // would show an empty namespace forever. A profile with no provider never
123
+ // runs this, and the composed config stays the only source.
124
+ ctx.inject(['settings'], (scope) => {
125
+ const settings = scope.get('settings');
126
+ if (!settings)
127
+ return;
128
+ scope.effect(() => {
129
+ // A configuration surface that cannot bind must never take the bot
130
+ // offline with it — the connection is the point, the settings page is a
131
+ // convenience — so a failed registration is reported and stepped over.
132
+ let bound;
133
+ try {
134
+ bound = settings.register(SETTINGS_NAMESPACE, Config, { base: config });
135
+ }
136
+ catch (error) {
137
+ logger.error('[dsh-telegram] could not register the settings namespace', error);
138
+ void status.publish('failed', {
139
+ detail: `settings namespace unavailable: ${describeError(error)}`,
140
+ });
141
+ return () => undefined;
142
+ }
143
+ // The resolved value may already differ from the composed one — a user
144
+ // document was loaded before this ran — so adopt it before watching.
145
+ // Reconnecting only when it actually differs matters: with no user
146
+ // overrides the resolved value IS the composed one, and reopening then
147
+ // would cost every boot a second connection for nothing.
148
+ const resolved = bound.get();
149
+ if (resolved !== undefined && !sameJson(resolved, live)) {
150
+ live = resolved;
151
+ run();
152
+ }
153
+ // Every setting here shapes the connection — the token it opens, who may
154
+ // use it, how replies are streamed — so a change reopens it rather than
155
+ // leaving half the new configuration unapplied until the next boot. The
156
+ // cost is one aborted long poll.
157
+ return bound.watch((next) => {
158
+ live = next;
159
+ logger.info('[dsh-telegram] configuration changed; reconnecting');
160
+ run();
161
+ });
162
+ }, 'dsh-telegram: settings namespace');
163
+ });
164
+ }
165
+ /**
166
+ * Bring the whole plugin up: resolve the token, wire the pieces, and poll.
167
+ *
168
+ * An unconfigured token is not an error — a profile may carry this plugin
169
+ * without a bot yet. It logs how to configure one and stays idle.
170
+ */
171
+ async function start(ctx, config, logger, signal, status, secrets, failures) {
172
+ if (!config.enabled) {
173
+ logger.info('[dsh-telegram] disabled; not connecting');
174
+ await status.publish('idle', { detail: 'disabled in configuration' });
175
+ return;
176
+ }
177
+ await status.publish('connecting');
178
+ const token = await resolveToken(ctx, config.tokenRef);
179
+ if (!token) {
180
+ const detail = `credential "${config.tokenRef}" is not set. ` +
181
+ 'Create a bot with @BotFather and store its token under that reference.';
182
+ logger.warn(`[dsh-telegram] ${detail} The bot is idle.`);
183
+ await status.publish('idle', { detail });
184
+ return;
185
+ }
186
+ secrets.protect(token);
187
+ const api = new TelegramApi({
188
+ token,
189
+ baseUrl: config.baseUrl,
190
+ timeoutMs: config.timeoutMs,
191
+ });
192
+ const me = await openConnection(api, config, logger, signal, status);
193
+ if (!me)
194
+ return;
195
+ const home = dataDirectory();
196
+ const bindings = await BindingStore.open(join(home, 'bindings.json'));
197
+ const claimCode = randomUUID().slice(0, 8);
198
+ const claimCodeFile = join(home, 'claim-code.txt');
199
+ const access = await AccessPolicy.open(join(home, 'owner.json'), {
200
+ allowFrom: config.allowFrom,
201
+ claimCode,
202
+ claimCodeFile,
203
+ });
204
+ announceAccess(access, config, claimCode, claimCodeFile, me.username, logger);
205
+ // Published once per connection rather than per message: the menu is a
206
+ // property of the bot, not of a chat, and Telegram remembers it.
207
+ void api.setMyCommands(commandMenu(access.owner() === undefined && config.allowFrom.length === 0));
208
+ const surface = telegramSurface(api);
209
+ const pending = new PendingRegistry();
210
+ const textCapture = new TextCapture();
211
+ const targetOf = (sessionId) => bindings.forSession(sessionId);
212
+ const questions = new TelegramQuestionProvider({
213
+ surface,
214
+ pending,
215
+ targetOf,
216
+ readText: (target, abortSignal) => textCapture.next(target, abortSignal),
217
+ });
218
+ const approvals = new TelegramApprovalAnswerer({ surface, pending, targetOf });
219
+ const recovery = new RecoveryOffer({ surface, pending, targetOf, reset: (target) => runner.reset(target) }, logger);
220
+ // One indicator for the whole plugin: the router's hold over reading an
221
+ // attachment and the bridge's hold over the turn it starts overlap, and both
222
+ // must let go before the chat stops typing.
223
+ const typing = new TypingIndicator({ chat: api });
224
+ const turns = new TurnBridge({
225
+ chat: api,
226
+ targetOf,
227
+ typing,
228
+ canDraft: (chat) => canStreamTo(chat.chatId, config.streaming.enabled),
229
+ throttleMs: config.streaming.throttleMs,
230
+ onFailure: (sessionId, failure) => void recovery.offer(sessionId, failure),
231
+ logger,
232
+ });
233
+ // Without this the agent joins no preset, and almost every model-facing tool
234
+ // — bash, the editor, grep, skills, subagents — is registered into a
235
+ // preset's scope layer rather than the host's. A Telegram agent then reached
236
+ // the model with only the globally registered web tools, and answered "I
237
+ // cannot run shell commands from this session".
238
+ const presets = ctx.get('agentPresets');
239
+ if (!presets) {
240
+ logger.info('[dsh-telegram] no agent preset roster; using the host composition alone');
241
+ }
242
+ const host = createAgentHost({
243
+ agents: ctx.agents,
244
+ message: buildUserMessage,
245
+ selectModel: () => selectModel(ctx, logger),
246
+ installSelection: installModelSelection,
247
+ ...(presets ? { presets } : {}),
248
+ // Read late, so a preset chosen in the settings document reaches the next
249
+ // conversation rather than the next restart.
250
+ presetId: () => config.agentPreset || undefined,
251
+ logger,
252
+ });
253
+ const cwd = config.cwd || process.cwd();
254
+ // Kept apart from the bindings deliberately: a binding dies with `/new`,
255
+ // while the directory a person chose belongs to the chat and must outlive it.
256
+ const workspaces = await ChatPreferences.open(join(home, 'workspaces.json'), {
257
+ accept: isUsableDirectory,
258
+ });
259
+ const cwdFor = (target) => workspaces.forChat(target) ?? cwd;
260
+ // Shares the host with the runner so a reading runs on the same harness the
261
+ // conversation does — but never in the conversation's own session.
262
+ // Never assumed: tesseract ships with no operating system this runs on, so
263
+ // its absence is the ordinary case. Probed once, and simply reads nothing
264
+ // where it is missing.
265
+ const ocr = config.media.ocr.enabled
266
+ ? new OcrReader({ languages: config.media.ocr.languages, logger })
267
+ : undefined;
268
+ const extractor = new VisionExtractor({
269
+ host,
270
+ cwd,
271
+ visionModel: () => parseRoute(config.media.visionModel),
272
+ ...(ocr ? { fallback: ocr } : {}),
273
+ ...(attachmentStore(ctx) ? { attachments: attachmentStore(ctx) } : {}),
274
+ logger,
275
+ });
276
+ // The deployment's default is chosen for the surface the operator sits in
277
+ // front of. Telegram is not that surface, so it may choose its own.
278
+ /**
279
+ * The image reader a conversation chose.
280
+ *
281
+ * `off` is stored as a value rather than as an absence, because a
282
+ * conversation that wants no reader has to outrank the deployment's own
283
+ * setting — and "nothing stored" already means "follow the deployment".
284
+ */
285
+ const chosenVision = await ChatPreferences.open(join(home, 'vision.json'), {
286
+ accept: (value) => value === VISION_OFF || parseRoute(value) !== undefined,
287
+ });
288
+ /** The model this conversation reads images with, if any. */
289
+ const visionRouteFor = (target) => {
290
+ const chosen = chosenVision.forChat(target);
291
+ if (chosen === VISION_OFF)
292
+ return undefined;
293
+ return parseRoute(chosen ?? config.media.visionModel);
294
+ };
295
+ const chosenPermissions = await ChatPreferences.open(join(home, 'permissions.json'), {
296
+ accept: (value) => value.trim() !== '',
297
+ });
298
+ const permission = new PermissionControl({
299
+ ...(ctx.get('permissionPresets') ? { presets: ctx.get('permissionPresets') } : {}),
300
+ ...(ctx.get('sessions') ? { sessions: ctx.get('sessions') } : {}),
301
+ // A conversation's own choice outranks the plugin's setting, which in turn
302
+ // outranks the deployment's default.
303
+ preset: (target) => chosenPermissions.forChat(target) ?? config.permissionPreset ?? undefined,
304
+ logger,
305
+ });
306
+ // Durable per chat, so a model chosen from a phone survives `/new` and a
307
+ // restart. Validated on read as well as on write: a model configured
308
+ // yesterday may be gone today.
309
+ const chosenModels = await ChatPreferences.open(join(home, 'models.json'), {
310
+ accept: (value) => parseRoute(value) !== undefined,
311
+ });
312
+ const chosenEfforts = await ChatPreferences.open(join(home, 'efforts.json'), {
313
+ accept: (value) => value.trim() !== '',
314
+ });
315
+ /**
316
+ * The route a conversation's next step should run on.
317
+ *
318
+ * An effort alone still needs a route to carry it, so it falls back to the
319
+ * deployment's own model rather than being silently dropped.
320
+ */
321
+ const chosenRoute = (target) => {
322
+ const model = parseRoute(chosenModels.forChat(target));
323
+ const effort = chosenEfforts.forChat(target);
324
+ const base = model ?? (effort === undefined ? undefined : selectModel(ctx, logger));
325
+ if (!base)
326
+ return undefined;
327
+ return effort === undefined ? base : { ...base, reasoningEffort: effort };
328
+ };
329
+ // The llm service knows which models declare image input. Read before the
330
+ // predicate below, which closes over it.
331
+ const catalog = ctx.get('llm');
332
+ /**
333
+ * Whether the model a conversation runs on reads images itself.
334
+ *
335
+ * One question, two consumers: it decides whether an attachment is refused,
336
+ * and whether the picture is read elsewhere before the conversation sees it.
337
+ * Both used to judge the deployment default, which stopped being right the
338
+ * moment a model that can see became selectable per conversation.
339
+ */
340
+ const modelSees = async (target) => {
341
+ if (!catalog)
342
+ return false;
343
+ const route = chosenRoute(target) ?? selectModel(ctx, logger);
344
+ if (!route)
345
+ return false;
346
+ try {
347
+ const info = await catalog.resolveModelInfo(route.provider, route.model);
348
+ return acceptsImages(info);
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ };
354
+ const history = await ChatHistory.open(join(home, 'history.json'));
355
+ const runner = new SessionRunner({
356
+ host,
357
+ bindings,
358
+ cwdFor,
359
+ chosenRoute,
360
+ modelSees,
361
+ permission,
362
+ history,
363
+ extractor,
364
+ visionRoute: visionRouteFor,
365
+ logger,
366
+ });
367
+ // The llm service knows which models declare image input; without it the
368
+ // check is skipped and the provider stays the authority.
369
+ const vision = catalog
370
+ ? new VisionCheck(catalog, () =>
371
+ // The model an image would ACTUALLY run on, not the conversation's
372
+ // default: judging the default would refuse every image the moment a
373
+ // vision model was configured, which is the one case it exists for.
374
+ parseRoute(config.media.visionModel) ?? selectModel(ctx, logger))
375
+ : undefined;
376
+ const media = config.media.enabled
377
+ ? new MediaCollector({
378
+ source: api,
379
+ ...(vision ? { vision } : {}),
380
+ // Absent on a deployment with no attachment seam; images are then
381
+ // declined with a reason rather than silently dropped.
382
+ ...(attachmentStore(ctx) ? { attachments: attachmentStore(ctx) } : {}),
383
+ maxBytes: config.media.maxBytes,
384
+ maxTextChars: config.media.maxTextChars,
385
+ ...(ocr ? { canReadWithoutModel: () => ocr.available() } : {}),
386
+ redact: secrets.redactor(),
387
+ logger,
388
+ })
389
+ : undefined;
390
+ const sessionPicker = new SessionPicker({
391
+ surface,
392
+ pending,
393
+ history,
394
+ currentSession: (target) => bindings.forChat(target)?.sessionId,
395
+ adopt: (target, sessionId) => runner.adopt(target, sessionId),
396
+ logger,
397
+ });
398
+ const startedAt = Date.now();
399
+ /**
400
+ * Which harness services this deployment composed.
401
+ *
402
+ * The single most useful line in `/diag`: an absent seam explains a whole
403
+ * class of "why does it not do that" without anyone having to guess. A
404
+ * missing `agentPresets` is why Telegram agents once reached the model with
405
+ * almost no tools, and nothing said so anywhere.
406
+ */
407
+ const seamReport = () => ['agents', 'agentPresets', 'permissionPresets', 'sessions', 'llm', 'attachments', 'userQuestions'].map((name) => ({ name, present: ctx.get(name) !== undefined }));
408
+ // Answers "am I behind?" without any of the risk of acting on it: updating
409
+ // the harness needs a restart, and restarting from inside it kills the
410
+ // process answering you.
411
+ const versions = new VersionCheck();
412
+ const screenshotter = new Screenshotter({ logger });
413
+ if (config.screenshot.enabled && !screenshotter.available) {
414
+ logger.warn(`[dsh-telegram] /screenshot is on but ${process.platform} has no capture tool`);
415
+ }
416
+ const router = new UpdateRouter({
417
+ chat: api,
418
+ access,
419
+ questions,
420
+ approvals,
421
+ recovery,
422
+ modelSees,
423
+ ...(catalog
424
+ ? {
425
+ vision: {
426
+ describe: (target) => {
427
+ if (chosenVision.forChat(target) === VISION_OFF)
428
+ return 'nothing — the conversation itself';
429
+ return formatRoute(visionRouteFor(target)) === 'the deployment default'
430
+ ? 'nothing configured'
431
+ : formatRoute(visionRouteFor(target));
432
+ },
433
+ origin: (target) => chosenVision.forChat(target) === undefined
434
+ ? undefined
435
+ : `this chat; the deployment default is ${config.media.visionModel || 'nothing configured'}`,
436
+ async choose(target, input) {
437
+ const providers = await listCatalog(catalog).catch(() => []);
438
+ const matched = matchRoute(input, providers);
439
+ if (matched.kind !== 'route')
440
+ return matched;
441
+ const route = `${matched.route.provider}/${matched.route.model}`;
442
+ await chosenVision.set(target, route);
443
+ return { kind: 'route', route };
444
+ },
445
+ disable: (target) => chosenVision.set(target, VISION_OFF),
446
+ clear: (target) => chosenVision.clear(target),
447
+ },
448
+ }
449
+ : {}),
450
+ sessions: sessionPicker,
451
+ typing,
452
+ diagnostics: {
453
+ async report() {
454
+ const uptime = Math.floor((Date.now() - startedAt) / 1000);
455
+ return {
456
+ status: [
457
+ { label: 'Bot', value: me.username ? `@${me.username}` : String(me.id) },
458
+ { label: 'Plugin', value: await describeVersion(versions, PACKAGE_NAME, PLUGIN_VERSION) },
459
+ {
460
+ label: 'Harness',
461
+ value: await describeVersion(versions, '@deepseek-ai/dsh', harnessVersion()),
462
+ },
463
+ { label: 'Uptime', value: formatUptime(uptime) },
464
+ { label: 'Streaming', value: config.streaming.enabled ? 'on' : 'off' },
465
+ { label: 'Attachments', value: config.media.enabled ? 'on' : 'off' },
466
+ { label: 'Screenshots', value: config.screenshot.enabled ? 'on' : 'off' },
467
+ {
468
+ label: 'OCR fallback',
469
+ value: ocr === undefined
470
+ ? 'off'
471
+ : (await ocr.available())
472
+ ? `tesseract, ${config.media.ocr.languages}`
473
+ : 'on, but tesseract is not installed',
474
+ },
475
+ { label: 'Groups', value: config.requireMentionInGroups ? 'mention required' : 'open' },
476
+ ],
477
+ seams: seamReport(),
478
+ failures: failures.recent(),
479
+ };
480
+ },
481
+ },
482
+ ...(config.screenshot.enabled
483
+ ? {
484
+ screen: {
485
+ async send(target) {
486
+ const shot = await screenshotter.take();
487
+ if (shot.kind === 'unsupported') {
488
+ return `Screenshots are not supported on ${shot.platform} yet.`;
489
+ }
490
+ if (shot.kind === 'failed') {
491
+ return shot.reason === 'the harness has no Screen Recording permission'
492
+ ? 'macOS has not given the harness Screen Recording permission. ' +
493
+ 'Grant it in System Settings → Privacy & Security → Screen Recording, ' +
494
+ 'then restart the harness.'
495
+ : `The screenshot failed: ${shot.reason}`;
496
+ }
497
+ // Over the photo limit it goes as a document, which Telegram
498
+ // takes up to 50 MB. A large display's PNG routinely is.
499
+ const asPhoto = shot.data.length <= PHOTO_LIMIT_BYTES;
500
+ await api.uploadFile(asPhoto ? 'sendPhoto' : 'sendDocument', asPhoto ? 'photo' : 'document', {
501
+ chatId: target.chatId,
502
+ data: shot.data,
503
+ filename: shot.filename,
504
+ contentType: 'image/png',
505
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
506
+ });
507
+ return undefined;
508
+ },
509
+ },
510
+ }
511
+ : {}),
512
+ ...(permission.available
513
+ ? {
514
+ permission: {
515
+ describe: (target) => chosenPermissions.forChat(target) ?? (config.permissionPreset || 'the deployment default'),
516
+ origin: (target) => chosenPermissions.forChat(target) === undefined
517
+ ? undefined
518
+ : `this chat; otherwise ${config.permissionPreset || 'the deployment default'}`,
519
+ options: () => permission.names,
520
+ async choose(target, input) {
521
+ const matched = matchPreset(input, permission.names);
522
+ if (matched === undefined)
523
+ return undefined;
524
+ await chosenPermissions.set(target, matched);
525
+ // Applied to the session in flight too: the reason to tighten it
526
+ // is usually the turn about to run, not the next conversation.
527
+ const live = bindings.forChat(target)?.sessionId;
528
+ if (live !== undefined)
529
+ permission.apply(target, live);
530
+ return matched;
531
+ },
532
+ async clear(target) {
533
+ await chosenPermissions.clear(target);
534
+ const live = bindings.forChat(target)?.sessionId;
535
+ if (live !== undefined)
536
+ permission.apply(target, live);
537
+ },
538
+ },
539
+ }
540
+ : {}),
541
+ ...(catalog
542
+ ? {
543
+ effort: {
544
+ describe: (target) => chosenEfforts.forChat(target) ?? "the model's own default",
545
+ origin: (target) => chosenEfforts.forChat(target) === undefined
546
+ ? undefined
547
+ : "this chat; otherwise the model's own default",
548
+ model: (target) => formatRoute(chosenRoute(target) ?? selectModel(ctx, logger)),
549
+ async options(target) {
550
+ const reasoning = await effortsFor(catalog, chosenRoute(target) ?? selectModel(ctx, logger));
551
+ return (reasoning?.efforts ?? []).map((option) => option.id);
552
+ },
553
+ async choose(target, input) {
554
+ const reasoning = await effortsFor(catalog, chosenRoute(target) ?? selectModel(ctx, logger));
555
+ const matched = matchEffort(input, reasoning?.efforts ?? []);
556
+ if (matched === undefined)
557
+ return undefined;
558
+ await chosenEfforts.set(target, matched);
559
+ return matched;
560
+ },
561
+ clear: (target) => chosenEfforts.clear(target),
562
+ },
563
+ models: buildModelControl({
564
+ catalog: catalog,
565
+ store: chosenModels,
566
+ chosen: chosenRoute,
567
+ stored: (target) => chosenModels.forChat(target),
568
+ fallback: () => selectModel(ctx, logger),
569
+ }),
570
+ }
571
+ : {}),
572
+ workspace: {
573
+ current: cwdFor,
574
+ resolve: (input, current) => resolveDirectory(input, current, homedir()),
575
+ inspect: inspectDirectory,
576
+ set: (target, directory) => workspaces.set(target, directory),
577
+ },
578
+ textCapture,
579
+ runner,
580
+ ...(me.username ? { botUsername: me.username } : {}),
581
+ botId: me.id,
582
+ requireAddressing: config.requireMentionInGroups,
583
+ ...(media ? { media } : {}),
584
+ redact: secrets.redactor(),
585
+ logger,
586
+ });
587
+ const teardown = [
588
+ subscribeToTurns(ctx, turns, extractor),
589
+ installQuestions(ctx, questions, logger),
590
+ installApprovals(ctx, approvals),
591
+ ];
592
+ signal.addEventListener('abort', () => {
593
+ for (const dispose of teardown)
594
+ dispose();
595
+ pending.dispose();
596
+ router.dispose();
597
+ void failures.flush();
598
+ failures.dispose();
599
+ typing.dispose();
600
+ extractor.dispose();
601
+ textCapture.dispose();
602
+ recovery.dispose();
603
+ void turns.dispose();
604
+ }, { once: true });
605
+ const poller = new UpdatePoller({
606
+ source: api,
607
+ onUpdate: (update) => router.handle(update),
608
+ longPollSeconds: config.longPollSeconds,
609
+ baseDelayMs: config.reconnect.baseDelayMs,
610
+ maxDelayMs: config.reconnect.maxDelayMs,
611
+ onConnected: () => {
612
+ logger.info('[dsh-telegram] listening for messages');
613
+ void status.publish('connected', { bot: me.username ?? String(me.id) });
614
+ },
615
+ logger,
616
+ });
617
+ await poller.run(signal);
618
+ }
619
+ /**
620
+ * Verify the token and take the polling path, retrying until it works.
621
+ *
622
+ * The receive loop already survives a dropped network; startup did not, and
623
+ * that asymmetry is what turns a momentary failure — a rate limit after a
624
+ * quick restart, a DNS blip, a laptop still waking — into a bot that stays
625
+ * silent until someone restarts the harness. Only a rejected token is final:
626
+ * retrying that would hammer Telegram forever with a credential that cannot
627
+ * become valid on its own.
628
+ *
629
+ * @returns the bot's identity, or undefined when the caller aborted.
630
+ */
631
+ async function openConnection(api, config, logger, signal, status) {
632
+ for (let attempt = 1; !signal.aborted; attempt += 1) {
633
+ try {
634
+ const me = await api.getMe();
635
+ // getUpdates and webhooks are mutually exclusive; take the polling path.
636
+ await api.deleteWebhook().catch((error) => {
637
+ logger.warn('[dsh-telegram] could not clear an existing webhook', error);
638
+ });
639
+ logger.info(`[dsh-telegram] connected as @${me.username ?? me.id}`);
640
+ await status.publish('connected', { bot: me.username ?? String(me.id) });
641
+ return me;
642
+ }
643
+ catch (error) {
644
+ if (signal.aborted)
645
+ return undefined;
646
+ if (error instanceof TelegramApiError && error.isAuthFailure) {
647
+ const detail = `the bot token was rejected: ${error.description ?? 'unauthorized'}`;
648
+ logger.error(`[dsh-telegram] ${detail}`);
649
+ await status.publish('failed', { detail });
650
+ return undefined;
651
+ }
652
+ const delay = Math.min(config.reconnect.baseDelayMs * 2 ** Math.min(attempt - 1, 8), config.reconnect.maxDelayMs);
653
+ logger.warn(`[dsh-telegram] could not connect; retrying in ${delay}ms`, error);
654
+ await status.publish('connecting', {
655
+ detail: `attempt ${attempt} failed: ${describeError(error)}`,
656
+ });
657
+ await sleep(delay, signal);
658
+ }
659
+ }
660
+ return undefined;
661
+ }
662
+ /** Cancellable pause between connection attempts. */
663
+ function sleep(ms, signal) {
664
+ return new Promise((resolve) => {
665
+ if (signal.aborted)
666
+ return resolve();
667
+ const timer = setTimeout(() => {
668
+ signal.removeEventListener('abort', onAbort);
669
+ resolve();
670
+ }, ms);
671
+ const onAbort = () => {
672
+ clearTimeout(timer);
673
+ resolve();
674
+ };
675
+ signal.addEventListener('abort', onAbort, { once: true });
676
+ });
677
+ }
678
+ /**
679
+ * Stream every Telegram-bound session's turns into its chat.
680
+ *
681
+ * The same feed carries the throwaway sessions images are read in, and those
682
+ * are claimed by the extractor first: they belong to no chat, and letting the
683
+ * bridge see them would put a reading's own turn — and its failures — in front
684
+ * of whoever sent the picture.
685
+ */
686
+ function subscribeToTurns(ctx, turns, extractor) {
687
+ return ctx.on('session/event', ((session, event) => {
688
+ const sessionId = String(session.id);
689
+ if (extractor.handle(sessionId, event))
690
+ return;
691
+ void turns.handle(sessionId, event);
692
+ }));
693
+ }
694
+ /**
695
+ * Install the questions provider, chaining any incumbent. Absent on a profile
696
+ * that does not load the seam, in which case questions simply stay a web-only
697
+ * feature rather than breaking the plugin.
698
+ */
699
+ function installQuestions(ctx, provider, logger) {
700
+ const fiber = ctx.inject(['userQuestions'], (scope) => {
701
+ const service = scope.get('userQuestions');
702
+ if (!service)
703
+ return;
704
+ scope.effect(() => {
705
+ const seam = installQuestionProvider(service, (previous) => {
706
+ if (previous)
707
+ provider.setFallback(previous);
708
+ return provider;
709
+ }, logger);
710
+ return () => seam.restore();
711
+ }, 'dsh-telegram: user-questions provider');
712
+ });
713
+ return () => void fiber.dispose();
714
+ }
715
+ /**
716
+ * Answer the approval waterfall for Telegram sessions.
717
+ *
718
+ * Registered with `prepend` so it is offered the request before the web UI's
719
+ * listener, which claims any pending approval in the session regardless of
720
+ * where the conversation is happening.
721
+ */
722
+ function installApprovals(ctx, answerer) {
723
+ const fiber = ctx.inject(['approval'], (scope) => {
724
+ scope.on('approval/request', (async (request, next) => {
725
+ const outcome = await answerer.decide(request);
726
+ return outcome ?? (await next());
727
+ }), true);
728
+ });
729
+ return () => void fiber.dispose();
730
+ }
731
+ /**
732
+ * The deployment's current model route.
733
+ *
734
+ * Read through `ctx.get` at creation time rather than injected: the service is
735
+ * optional in principle, and reading late means a default changed in Settings
736
+ * applies to the next conversation without a reconnect.
737
+ */
738
+ function selectModel(ctx, logger) {
739
+ const service = ctx.get('agentDefaultModel');
740
+ if (!service) {
741
+ logger.warn('[dsh-telegram] no agentDefaultModel service: agents will be created without a model route, ' +
742
+ 'and every turn will fail while assembling its prompt');
743
+ return undefined;
744
+ }
745
+ try {
746
+ const selection = service.currentSelection();
747
+ if (selection)
748
+ return { provider: selection.provider, model: selection.model };
749
+ }
750
+ catch (error) {
751
+ logger.warn('[dsh-telegram] could not read the default model selection', error);
752
+ }
753
+ return undefined;
754
+ }
755
+ /**
756
+ * The harness attachment seam, when this deployment mounts one.
757
+ *
758
+ * Read late rather than injected: a profile without it should still run the
759
+ * bot, declining images with a reason instead of failing to load.
760
+ */
761
+ function attachmentStore(ctx) {
762
+ return ctx.get('attachments');
763
+ }
764
+ /**
765
+ * The `/model` seam, over the harness catalog and the durable choice.
766
+ *
767
+ * Assembled here rather than inside the router so the router stays testable
768
+ * without a provider catalog, and so the rendering — which is Telegram HTML —
769
+ * sits beside the other message building.
770
+ */
771
+ function buildModelControl(options) {
772
+ return {
773
+ describe: (target) => formatRoute(options.chosen(target) ?? options.fallback()),
774
+ origin: (target) => options.stored(target) === undefined
775
+ ? undefined
776
+ : `this chat; the deployment default is ${formatRoute(options.fallback())}`,
777
+ async list() {
778
+ let providers;
779
+ try {
780
+ providers = await listCatalog(options.catalog);
781
+ }
782
+ catch {
783
+ return 'Could not read the configured models.';
784
+ }
785
+ if (providers.length === 0)
786
+ return 'No models are configured. Add one in Settings → Models.';
787
+ return providers
788
+ .map((provider) => {
789
+ const rows = provider.models
790
+ .map((model) => `• <code>${escapeHtml(`${provider.id}/${model.id}`)}</code>`)
791
+ .join('\n');
792
+ return `<b>${escapeHtml(provider.name ?? provider.id)}</b>\n${rows}`;
793
+ })
794
+ .join('\n\n');
795
+ },
796
+ async choose(target, input) {
797
+ const providers = await listCatalog(options.catalog).catch(() => []);
798
+ const matched = matchRoute(input, providers);
799
+ if (matched.kind !== 'route')
800
+ return matched;
801
+ const route = `${matched.route.provider}/${matched.route.model}`;
802
+ await options.store.set(target, route);
803
+ return { kind: 'route', route };
804
+ },
805
+ clear: (target) => options.store.clear(target),
806
+ };
807
+ }
808
+ /**
809
+ * The harness's own version, read from the process that is running it.
810
+ *
811
+ * Derived rather than hard-coded: `process.argv[1]` is the script node was
812
+ * given, which for the harness is its own bin — so walking up from there finds
813
+ * the manifest the CLI's own `--version` reads. A plugin cannot simply import
814
+ * `@deepseek-ai/dsh`: under pnpm's isolated layout it resolves only its own
815
+ * declared dependencies, and the harness is the host's.
816
+ *
817
+ * @returns the version, or undefined when it cannot be worked out.
818
+ */
819
+ function harnessVersion() {
820
+ const entry = process.argv[1];
821
+ if (entry === undefined)
822
+ return undefined;
823
+ try {
824
+ let directory = dirname(realpathSync(entry));
825
+ // Bounded: a walk that finds nothing must end, and no sane layout puts the
826
+ // manifest further up than this.
827
+ for (let depth = 0; depth < 5; depth += 1) {
828
+ try {
829
+ const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
830
+ // Checked by name, so a nested manifest cannot be mistaken for it.
831
+ if (manifest.name === '@deepseek-ai/dsh')
832
+ return manifest.version;
833
+ }
834
+ catch {
835
+ // No manifest at this level; keep walking.
836
+ }
837
+ const parent = dirname(directory);
838
+ if (parent === directory)
839
+ break;
840
+ directory = parent;
841
+ }
842
+ }
843
+ catch {
844
+ return undefined;
845
+ }
846
+ return undefined;
847
+ }
848
+ /**
849
+ * This plugin's version, for a report that has to say which build is running.
850
+ *
851
+ * Read from the manifest rather than hard-coded, so it cannot drift from the
852
+ * package it was published as.
853
+ */
854
+ const { version: PLUGIN_VERSION, name: PACKAGE_NAME } = (() => {
855
+ try {
856
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
857
+ return {
858
+ version: manifest.version ?? 'unknown',
859
+ name: manifest.name ?? '@sympoies/dsh-telegram',
860
+ };
861
+ }
862
+ catch {
863
+ return { version: 'unknown', name: '@sympoies/dsh-telegram' };
864
+ }
865
+ })();
866
+ /**
867
+ * One version line: what is installed, and whether anything newer is published.
868
+ *
869
+ * Says nothing about the registry when it could not be reached, rather than
870
+ * claiming a version is current on the strength of a failed request.
871
+ */
872
+ async function describeVersion(versions, name, installed) {
873
+ if (installed === undefined)
874
+ return 'unknown';
875
+ const report = await versions.check(name, installed);
876
+ if (report.latest === undefined)
877
+ return installed;
878
+ return report.behind ? `${installed} → ${report.latest} available` : `${installed} (latest)`;
879
+ }
880
+ /** Stored where a conversation wants no image reader at all. */
881
+ const VISION_OFF = 'off';
882
+ /** Seconds as something readable at a glance. */
883
+ function formatUptime(seconds) {
884
+ if (seconds < 60)
885
+ return `${seconds}s`;
886
+ if (seconds < 3600)
887
+ return `${Math.floor(seconds / 60)}m`;
888
+ const hours = Math.floor(seconds / 3600);
889
+ return hours < 24 ? `${hours}h ${Math.floor((seconds % 3600) / 60)}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`;
890
+ }
891
+ /**
892
+ * What is actually at a path the user named.
893
+ *
894
+ * Four answers rather than a boolean: "no such directory" and "that is a file"
895
+ * are different mistakes, and a permission failure is neither — telling them
896
+ * apart is the difference between a message someone can act on and one they
897
+ * have to guess at.
898
+ *
899
+ * @param directory - an absolute path.
900
+ */
901
+ async function inspectDirectory(directory) {
902
+ try {
903
+ return (await stat(directory)).isDirectory() ? 'directory' : 'file';
904
+ }
905
+ catch (error) {
906
+ const code = error.code;
907
+ if (code === 'ENOENT' || code === 'ENOTDIR')
908
+ return 'missing';
909
+ return 'denied';
910
+ }
911
+ }
912
+ /** Resolve the bot token through the harness credential seam. */
913
+ async function resolveToken(ctx, ref) {
914
+ try {
915
+ const resolved = await ctx.credentials.resolve(ref);
916
+ const value = resolved?.value;
917
+ return value && value !== '' ? value : undefined;
918
+ }
919
+ catch {
920
+ return undefined;
921
+ }
922
+ }
923
+ /**
924
+ * Deep equality over JSON-shaped configuration.
925
+ *
926
+ * Written out rather than done with `JSON.stringify`, whose answer depends on
927
+ * key order — two resolutions of the same schema need not agree on it, and a
928
+ * false difference here costs a needless reconnection.
929
+ */
930
+ function sameJson(a, b) {
931
+ if (a === b)
932
+ return true;
933
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
934
+ return false;
935
+ if (Array.isArray(a) || Array.isArray(b)) {
936
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
937
+ return false;
938
+ return a.every((item, index) => sameJson(item, b[index]));
939
+ }
940
+ const left = a;
941
+ const right = b;
942
+ const keys = Object.keys(left);
943
+ if (keys.length !== Object.keys(right).length)
944
+ return false;
945
+ return keys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
946
+ }
947
+ /**
948
+ * Where this plugin keeps its bindings and ownership record.
949
+ *
950
+ * Deliberately not derived from the package name: renaming the package must
951
+ * not orphan a bot's ownership record or the conversations bound to it.
952
+ */
953
+ function dataDirectory() {
954
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
955
+ return join(home, 'dsh-telegram');
956
+ }
957
+ /**
958
+ * Tell the operator, on their own console, how to reach the bot. The claim
959
+ * code is printed here and nowhere else — it must never travel over Telegram,
960
+ * because anyone who can read it can take the bot.
961
+ */
962
+ function announceAccess(access, config, claimCode, claimCodeFile, username, logger) {
963
+ if (config.allowFrom.length > 0) {
964
+ logger.info(`[dsh-telegram] allowing ${config.allowFrom.length} configured user id(s)`);
965
+ return;
966
+ }
967
+ const owner = access.owner();
968
+ if (owner !== undefined) {
969
+ logger.info(`[dsh-telegram] owned by Telegram user ${owner}`);
970
+ return;
971
+ }
972
+ const handle = username ? `@${username}` : 'your bot';
973
+ logger.warn(`[dsh-telegram] this bot has no owner yet. Message ${handle} with:\n\n /claim ${claimCode}\n\n` +
974
+ `Until then it answers nobody. The code changes on every restart, and is\n` +
975
+ `also readable at ${claimCodeFile} in case this log is not.`);
976
+ }
977
+ //# sourceMappingURL=index.js.map