dsh-tg-bridge 1.5.2 → 1.5.4

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/README.md CHANGED
@@ -92,6 +92,15 @@ What `init` does:
92
92
  `init` flags: `--cwd <path>` (default `~/Projects`), `--voice` (enable
93
93
  voice transcription), `--dry-run` (show what would be written, change nothing).
94
94
 
95
+ Optional provider settings live in `config.json`:
96
+
97
+ | Key | Meaning |
98
+ |---|---|
99
+ | `providerKeys` | extra env var names to pass to a turn, read from `~/.dsh/.credentials.yaml`; default `[]` |
100
+ | `pricesUrl` | public model page for `/prices`; default `""` uses models.dev only |
101
+ | `vision.patch` | overlay for photo turns; default `""` (photos are text-only until set) |
102
+ | `fallbackModels` | free-model fallbacks; default `[]` |
103
+
95
104
  Get your id from [@userinfobot](https://t.me/userinfobot), the token from
96
105
  [@BotFather](https://t.me/BotFather).
97
106
 
@@ -106,9 +115,11 @@ Three things without which the bot will stay silent in a group or work only half
106
115
  3. **Pin Messages** permission for the bot. Only needed to pin the first
107
116
  message of a topic; without it the bridge keeps working but will not pin.
108
117
 
109
- You do not need to set up the command menu by hand: the bridge installs it on start,
110
- in Russian, English and Chinese at once. The full settings table, permissions, checks and
111
- common errors are in [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md).
118
+ You do not need to set up the command menu by hand: `init` and `setup` install it
119
+ in Russian, English and Chinese at once. The menu is not refreshed by a plain
120
+ bridge restart, so after adding commands run `dsh-tg menu`. The full settings
121
+ table, permissions, checks and common errors are in
122
+ [docs/TELEGRAM-SETUP.md](docs/TELEGRAM-SETUP.md).
112
123
 
113
124
  **Autostart** is installed by the setup: `launchd` on macOS, `systemd --user` on
114
125
  Linux. On Windows `setup` registers the `DSH-TG-Bridge` task, which runs the
@@ -216,9 +227,9 @@ node ~/.dsh/tg-bridge/say.mjs --thought "checking why the watchdog fired twice"
216
227
  ```
217
228
 
218
229
  The note is appended to `~/.dsh/tg-bridge/thoughts/<chat>_<thread>.jsonl`; the progress
219
- message shows the last two as `💭` lines. Inside a turn the chat and thread come from
220
- `DSH_TG_CHAT` / `DSH_TG_THREAD`, so the command needs no ids. `--thought` never sends a
221
- Telegram message on its own.
230
+ message shows the last two as `💭` lines. The command needs no ids: it falls back to the
231
+ newest turn's thought file, because tool shells do not inherit the bridge environment.
232
+ `--thought` never sends a Telegram message on its own.
222
233
 
223
234
  ### Goals and tasks
224
235
 
package/bridge.mjs CHANGED
@@ -259,6 +259,30 @@ function readSecret(name) {
259
259
  return m ? m[1] : '';
260
260
  }
261
261
 
262
+ /**
263
+ * Env vars for the provider keys this install needs.
264
+ *
265
+ * A headless turn inherits the bridge's environment, and the credentials
266
+ * service only resolves providers the app knows about. Extra providers are
267
+ * therefore declared per install in `config.json` as
268
+ * `"providerKeys": ["MY_PROVIDER_API_KEY"]`, and each name is read from
269
+ * `~/.dsh/.credentials.yaml` under the same name. Nothing is hardcoded: an
270
+ * install that runs on the app's own providers sets an empty list and gets
271
+ * exactly the inherited environment.
272
+ */
273
+ function providerKeys(cfg) {
274
+ const out = {};
275
+ const names = Array.isArray(cfg.providerKeys) ? cfg.providerKeys : [];
276
+ for (const name of names) {
277
+ if (!/^[A-Z][A-Z0-9_]*$/.test(String(name))) continue;
278
+ try {
279
+ const value = readSecret(name) || (process.env[name] || '').trim();
280
+ if (value) out[name] = value;
281
+ } catch {}
282
+ }
283
+ return out;
284
+ }
285
+
262
286
  /**
263
287
  * The bot token can be stored under three names: the plain one that `dsh-tg
264
288
  * init` writes, the suffixed one DSH generates when the secret is added through
@@ -740,13 +764,10 @@ function runTurn(task, sessionId, key = '', onEvent = null, extraArgs = [], cwd
740
764
  DSH_TG_CHAT: String(key).split(':')[0] || '',
741
765
  DSH_TG_THREAD: String(key).split(':')[1] || '0',
742
766
  ELECTRON_RUN_AS_NODE: '1',
743
- UNOROUTER_API_KEY: readSecret('UNOROUTER_API_KEY'),
744
- // Free fallback providers: their keys live next to the main one and are
745
- // passed explicitly, because the credentials service only resolves the
746
- // providers it knows about.
747
- TAYNIK_API_KEY: readSecret('TAYNIK_API_KEY'),
748
- NEURALDEEP_API_KEY: readSecret('NEURALDEEP_API_KEY'),
749
- IKHDEV_PRO_API_KEY: readSecret('IKHDEV_PRO_API_KEY'),
767
+ // Provider keys the profile cannot resolve on its own. Configured per
768
+ // install (`providerKeys`, see config.example.json); a key listed in
769
+ // `~/.dsh/.credentials.yaml` is passed by its own name.
770
+ ...providerKeys(config),
750
771
  },
751
772
  windowsHide: true,
752
773
  });
@@ -1248,7 +1269,14 @@ ${t(lang, 'help.body')}`,
1248
1269
  seedTopic: ensureTopicSeed,
1249
1270
  prices: (filter) => {
1250
1271
  const route = modelOf(config.profile || 'headless');
1251
- return renderPrices(filter, { model: route.model, provider: route.provider, lang });
1272
+ // A provider page is optional: without `pricesUrl` the answer comes from
1273
+ // the public models.dev catalog, so no install is tied to one vendor.
1274
+ return renderPrices(filter, {
1275
+ model: route.model,
1276
+ provider: route.provider,
1277
+ pageUrl: config.pricesUrl || '',
1278
+ lang,
1279
+ });
1252
1280
  },
1253
1281
  reply: (out) => send(chatId, out, threadId),
1254
1282
  stopTurn: (k) => {
package/cli.mjs CHANGED
@@ -37,15 +37,19 @@ const RUNTIME_FILES = [
37
37
  */
38
38
  function syncRuntime() {
39
39
  if (resolve(DIR) === resolve(RUNTIME)) return;
40
- mkdirSync(join(RUNTIME, 'patches'), { recursive: true });
41
40
  for (const file of RUNTIME_FILES) {
42
41
  if (existsSync(join(DIR, file))) copyFileSync(join(DIR, file), join(RUNTIME, file));
43
42
  }
44
- try {
45
- for (const patch of readdirSync(join(DIR, 'patches'))) {
46
- copyFileSync(join(DIR, 'patches', patch), join(RUNTIME, 'patches', patch));
47
- }
48
- } catch {}
43
+ // Model overlays ship in `patches/` of the package. A checkout without that
44
+ // directory is valid: fallbacks and vision are then simply not configured.
45
+ if (existsSync(join(DIR, 'patches'))) {
46
+ mkdirSync(join(RUNTIME, 'patches'), { recursive: true });
47
+ try {
48
+ for (const patch of readdirSync(join(DIR, 'patches'))) {
49
+ copyFileSync(join(DIR, 'patches', patch), join(RUNTIME, 'patches', patch));
50
+ }
51
+ } catch {}
52
+ }
49
53
  console.log(` код: скопирован из ${DIR} в ${RUNTIME}`);
50
54
  }
51
55
 
@@ -146,7 +150,9 @@ function writeConfig({ userId, cwd, voice }, dryRun) {
146
150
  // Turns run from the user's project directory, so these must be absolute:
147
151
  // a relative "./transcribe.py" silently disables voice and vision.
148
152
  voice: { ...example.voice, enabled: Boolean(voice), script: join(RUNTIME, 'transcribe.py') },
149
- vision: { ...example.vision, patch: join(RUNTIME, 'patches', 'vision.yml') },
153
+ // Vision is opt-in: without a patch the bridge logs that photos cannot be
154
+ // read instead of forcing one provider's model onto every install.
155
+ vision: { ...example.vision, patch: example.vision?.patch ? join(RUNTIME, 'patches', 'vision.yml') : '' },
150
156
  };
151
157
  delete config._comment;
152
158
  if (dryRun) {
@@ -3,6 +3,8 @@
3
3
  "allowedUserIds": [],
4
4
  "profile": "headless",
5
5
  "cwd": "",
6
+ "providerKeys": [],
7
+ "pricesUrl": "",
6
8
  "turnIdleMs": 120000,
7
9
  "turnTimeoutMs": 0,
8
10
  "busyNoticeMs": 45000,
@@ -12,23 +14,7 @@
12
14
  "pinTopicSeed": true,
13
15
  "plainText": false,
14
16
  "servers": {},
15
- "fallbackModels": [
16
- {
17
- "model": "glm-5.3-flash:free",
18
- "patch": "./patches/model-glm-5.3-flash_free.yml",
19
- "free": true
20
- },
21
- {
22
- "model": "deepseek-v4.1-flash:free",
23
- "patch": "./patches/model-deepseek-v4.1-flash_free.yml",
24
- "free": true
25
- },
26
- {
27
- "model": "kimi-k3:free",
28
- "patch": "./patches/model-kimi-k3_free.yml",
29
- "free": true
30
- }
31
- ],
17
+ "fallbackModels": [],
32
18
  "voice": {
33
19
  "enabled": false,
34
20
  "python": "",
@@ -41,7 +27,7 @@
41
27
  "echo": true
42
28
  },
43
29
  "vision": {
44
- "patch": "./patches/vision.yml",
45
- "model": "gemini-3.8-flash"
30
+ "patch": "",
31
+ "model": ""
46
32
  }
47
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tg-bridge",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "Telegram bridge for DeepSeek Harness: text, voice, photos and project-per-topic sessions without opening the desktop app",
5
5
  "type": "module",
6
6
  "main": "plugin-bridge.mjs",
@@ -58,8 +58,7 @@
58
58
  "README.md",
59
59
  "LICENSE",
60
60
  "config.example.json",
61
- "patches/",
62
- "queue-notice.mjs",
61
+ "queue-notice.mjs",
63
62
  "immediate.mjs",
64
63
  "say.mjs",
65
64
  "steer-queue.mjs",
package/prices.mjs CHANGED
@@ -1,12 +1,10 @@
1
- // prices.mjs — unorouter model prices without the metrics plugin.
1
+ // prices.mjs — model prices without a provider-specific plugin.
2
2
  //
3
- // The plugin `dsh-unorouter-metrics` never mounted, so `/prices` had nothing to
4
- // read. This module fetches the public models page directly, parses the table
5
- // and renders a short answer: what the current route costs, and the cheapest
6
- // models that still fit the context floor. Fallback is models.dev, which has
7
- // no uptime data but is stable JSON.
3
+ // `/prices` reads the public models.dev catalog by default. An install that
4
+ // wants live prices from its own provider can point `pricesUrl` at that
5
+ // provider's public model page; the parser is generic, so no vendor is baked
6
+ // into the code.
8
7
 
9
- const MODELS_PAGE = 'https://unorouter.com/en/models';
10
8
  const MODELS_DEV = 'https://models.dev/api.json';
11
9
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
12
10
 
@@ -128,7 +126,7 @@ const WORDS = {
128
126
 
129
127
  /**
130
128
  * Find a model by id. Providers prefix ids differently (`openai/gpt-4o`,
131
- * `gpt-4o`, `unorouter/gpt-4o:free`), so exact match first, then the shortest
129
+ * `gpt-4o`, `vendor/gpt-4o:free`), so exact match first, then the shortest
132
130
  * id that contains the wanted token.
133
131
  */
134
132
  export function findModel(models, wanted) {
@@ -142,31 +140,33 @@ export function findModel(models, wanted) {
142
140
  }
143
141
 
144
142
  /**
145
- * Render `/prices [filter]` from every catalog we can reach, so the answer does
146
- * not depend on one provider: the unorouter page carries live prices for its
147
- * own routes, models.dev covers the rest of the market. Both are merged; the
148
- * current model is looked up in the merged list.
143
+ * Render `/prices [filter]` from the public market catalog, plus an optional
144
+ * provider page configured as `pricesUrl`. Neither source is required: if one
145
+ * is down the other still answers, and with no `pricesUrl` only models.dev is
146
+ * queried.
149
147
  * @param {string} filter optional text to match model ids
150
- * @param {{ model?: string, provider?: string, lang?: string }} options
148
+ * @param {{ model?: string, provider?: string, pageUrl?: string, lang?: string }} options
151
149
  */
152
150
  export async function renderPrices(filter = '', options = {}) {
153
151
  const lang = WORDS[options.lang] ? options.lang : 'en';
154
152
  const words = WORDS[lang];
155
- const [page, dev] = await Promise.allSettled([
156
- fetchText(MODELS_PAGE),
157
- fetchText(MODELS_DEV, 30000),
158
- ]);
153
+ const pageUrl = String(options.pageUrl || '').trim();
154
+ const jobs = [fetchText(MODELS_DEV, 30000)];
155
+ if (pageUrl) jobs.unshift(fetchText(pageUrl));
156
+ const results = await Promise.allSettled(jobs);
157
+ const page = pageUrl ? results[0] : null;
158
+ const dev = pageUrl ? results[1] : results[0];
159
159
 
160
160
  let models = [];
161
161
  const sources = [];
162
- if (page.status === 'fulfilled') {
162
+ if (page?.status === 'fulfilled') {
163
163
  const parsed = parseModelsPage(page.value);
164
164
  if (parsed.length > 0) {
165
165
  models.push(...parsed);
166
- sources.push('unorouter');
166
+ try { sources.push(new URL(pageUrl).hostname.replace(/^www\./i, '')); } catch { sources.push('provider'); }
167
167
  }
168
168
  }
169
- if (dev.status === 'fulfilled') {
169
+ if (dev?.status === 'fulfilled') {
170
170
  try {
171
171
  const parsed = parseModelsDev(JSON.parse(dev.value));
172
172
  if (parsed.length > 0) {
@@ -176,9 +176,9 @@ export async function renderPrices(filter = '', options = {}) {
176
176
  } catch {}
177
177
  }
178
178
  if (models.length === 0) {
179
- const reason = dev.status === 'rejected'
179
+ const reason = dev?.status === 'rejected'
180
180
  ? dev.reason?.message
181
- : page.status === 'rejected'
181
+ : page?.status === 'rejected'
182
182
  ? page.reason?.message
183
183
  : 'catalogs parsed to 0 models';
184
184
  return `${words.fail}: ${reason}`;
package/say.mjs CHANGED
@@ -7,14 +7,14 @@
7
7
  //
8
8
  // node say.mjs --steer <job-id> "text"
9
9
  // node say.mjs --chat <chat-id> [--thread <id>] "text"
10
- // node say.mjs --thought "short note" (chat/thread come from DSH_TG_CHAT/DSH_TG_THREAD)
10
+ // node say.mjs --thought "short note" (no ids needed)
11
11
  //
12
12
  // `--thought` does not send a message: it appends to the turn's thought file,
13
13
  // and the live progress message shows the last two notes as 💭 lines.
14
14
  //
15
15
  // With `--steer` the chat/thread come from the job file and an ack marker is
16
16
  // written, which suppresses the bridge's fallback line.
17
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
 
@@ -63,6 +63,21 @@ function parse(argv) {
63
63
  return out;
64
64
  }
65
65
 
66
+ function activeThoughtFile() {
67
+ try {
68
+ const files = readdirSync(THOUGHTS)
69
+ .filter((name) => name.endsWith('.jsonl'))
70
+ .map((name) => ({ name, mtime: statSync(join(THOUGHTS, name)).mtimeMs }))
71
+ .sort((a, b) => b.mtime - a.mtime);
72
+ if (!files.length) return null;
73
+ const [chat, thread] = files[0].name.replace(/\.jsonl$/, '').split('_');
74
+ if (!Number(chat)) return null;
75
+ return { chatId: Number(chat), threadId: Number(thread) || 0 };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
66
81
  async function main() {
67
82
  const args = parse(process.argv.slice(2));
68
83
  if (!args.text) usage();
@@ -70,6 +85,15 @@ async function main() {
70
85
  let chatId = args.chatId || Number(process.env.DSH_TG_CHAT || 0);
71
86
  let threadId = args.threadId || Number(process.env.DSH_TG_THREAD || 0);
72
87
  let jobPath = '';
88
+ // Tool shells do not inherit the bridge's env, so fall back to the thought
89
+ // file the bridge touched at the start of the newest turn.
90
+ if (!chatId && args.thought) {
91
+ const active = activeThoughtFile();
92
+ if (active) {
93
+ chatId = active.chatId;
94
+ threadId = active.threadId;
95
+ }
96
+ }
73
97
  if (args.steer) {
74
98
  const found = jobOf(args.steer);
75
99
  if (!found) {
@@ -1,4 +0,0 @@
1
- - id: agent-default-model
2
- config:
3
- provider: unorouter
4
- model: deepseek-v4.1-flash:free
@@ -1,4 +0,0 @@
1
- - id: agent-default-model
2
- config:
3
- provider: unorouter
4
- model: glm-5.3-flash:free
@@ -1,4 +0,0 @@
1
- - id: agent-default-model
2
- config:
3
- provider: unorouter
4
- model: kimi-k3:free
@@ -1,10 +0,0 @@
1
- # Vision overlay for photo turns.
2
- #
3
- # The default headless route (deepseek-v4.1-flash-1017) declares no image input,
4
- # so `read_image` fails with "model does not declare image input". This overlay
5
- # is applied ONLY to turns that carry a picture: it points the agent at a
6
- # vision-capable model from the same provider, leaving text turns untouched.
7
- - id: agent-default-model
8
- config:
9
- provider: unorouter
10
- model: gemini-3.8-flash