dsh-tg-bridge 1.2.8 → 1.2.10

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/bridge.mjs CHANGED
@@ -22,6 +22,8 @@ import dns from 'node:dns';
22
22
  import net from 'node:net';
23
23
  dns.setDefaultResultOrder('ipv4first');
24
24
  net.setDefaultAutoSelectFamily(false);
25
+ import { noticeIsObsolete } from './queue-notice.mjs';
26
+ import { immediateKind } from './immediate.mjs';
25
27
  import { handleControl, CONTROL_COMMANDS } from './control.mjs';
26
28
  import { renderPrices } from './prices.mjs';
27
29
  import { t, resolveLang } from './i18n.mjs';
@@ -473,33 +475,13 @@ function commandOf(text) {
473
475
  return `/${match[1].toLowerCase().replace(/[.-]+/g, '')}`;
474
476
  }
475
477
 
476
- /**
477
- * Commands that must run *now*, not behind the turn they are about.
478
- *
479
- * Everything in a topic used to be queued, so `/stop` waited for the very turn
480
- * it was supposed to kill — on 2026-10-24 a 26-minute turn swallowed `/stop`
481
- * and the person got "ход уже идёт" instead of an interruption. These few
482
- * commands touch no turn state, so they can run concurrently with one.
483
- */
484
- const IMMEDIATE_COMMANDS = new Set([
485
- 'stop',
486
- 'logs',
487
- 'status',
488
- 'help',
489
- 'doctor',
490
- 'restart',
491
- ]);
492
-
493
478
  /** Canonical immediate command name for a message, or '' when it must queue. */
494
479
  function immediateCommandOf(text) {
495
480
  const trimmed = String(text ?? '').trim();
496
481
  if (!trimmed.startsWith('/')) return '';
497
- const head = trimmed.split(/\s+/)[0];
482
+ const [head, ...rest] = trimmed.split(/\s+/);
498
483
  const collapsed = commandOf(head).replace(/^\//, '');
499
- if (collapsed === 'apprestart' || collapsed === 'apprestartconfirm' || collapsed === 'restartconfirm') {
500
- return 'restart';
501
- }
502
- return IMMEDIATE_COMMANDS.has(collapsed) ? collapsed : '';
484
+ return immediateKind(collapsed, rest);
503
485
  }
504
486
 
505
487
  /**
@@ -1751,6 +1733,10 @@ async function main() {
1751
1733
  // handled inline and must stay silent about it.
1752
1734
  msg.text || msg.caption
1753
1735
  ? async () => {
1736
+ // Which turn was running when this message was queued: if that
1737
+ // value changes, the queued turn has started and the notice is
1738
+ // already obsolete.
1739
+ const turnWhenQueued = turnStartedAt.get(queueKey);
1754
1740
  await new Promise((resolve) => setTimeout(resolve, Number(config.busyNoticeMs ?? 45000)));
1755
1741
  if (!queues.has(queueKey)) return; // already finished
1756
1742
  // One notice per topic: a second queued message must not add a
@@ -1767,8 +1753,11 @@ async function main() {
1767
1753
  thread,
1768
1754
  );
1769
1755
  if (messageId) busyNotices.set(queueKey, { chatId: msg.chat.id, messageId });
1770
- // The turn may have started while the notice was in flight.
1771
- if (!queues.has(queueKey)) await clearBusyNotice(queueKey);
1756
+ // The queued turn may have started while the notice was in flight;
1757
+ // in that case nothing else will delete it until the turn ends.
1758
+ if (noticeIsObsolete(turnWhenQueued, turnStartedAt.get(queueKey), queues.has(queueKey))) {
1759
+ await clearBusyNotice(queueKey);
1760
+ }
1772
1761
  }
1773
1762
  : undefined,
1774
1763
  );
package/immediate.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // Which control commands may run while a turn is still working?
2
+ //
3
+ // Everything in a topic used to be queued, so `/stop` waited for the very turn
4
+ // it was supposed to kill. The rule here is not "important commands", it is
5
+ // "commands that neither read nor write the state of the running turn".
6
+ //
7
+ // Groups:
8
+ // * always immediate — read-only reports and turn control;
9
+ // * immediate only without arguments — the bare form reports, the form with
10
+ // an argument changes state (`/soul <text>`, `/lang en`);
11
+ // * `/plugins` without an action — the list; with add/remove/disable/enable
12
+ // it installs plugins, which must queue like any other mutation;
13
+ // * everything else queues, because it changes the session the turn is using
14
+ // (`/model`, `/new`, `/resume`, `/cd`, `/send`, `/seed`, `/plugin`).
15
+
16
+ const PLUGIN_ACTIONS = new Set(['add', 'remove', 'disable', 'enable']);
17
+
18
+ const ALWAYS = new Set([
19
+ // Turn control.
20
+ 'stop',
21
+ 'restart',
22
+ // Reports: they read profiles, sessions, skills and the price table, but
23
+ // touch neither the queue nor the session of the running turn.
24
+ 'status',
25
+ 'logs',
26
+ 'help',
27
+ 'doctor',
28
+ 'models',
29
+ 'sessions',
30
+ 'skills',
31
+ 'prices',
32
+ ]);
33
+
34
+ const BARE_ONLY = new Set([
35
+ 'soul', // `/soul` reads AGENTS.md, `/soul <text>` rewrites it
36
+ 'lang', // `/lang` reports the current language, `/lang en` switches it
37
+ ]);
38
+
39
+ /**
40
+ * Canonical immediate command name, or '' when the message must queue.
41
+ * `name` is the command without the slash and without a bot mention,
42
+ * `args` is everything after it, already split.
43
+ */
44
+ export function immediateKind(name, args = []) {
45
+ const command = String(name ?? '').toLowerCase();
46
+ if (command === 'apprestart' || command === 'apprestartconfirm' || command === 'restartconfirm') {
47
+ return 'restart';
48
+ }
49
+ if (ALWAYS.has(command)) return command;
50
+ if (command === 'plugins') {
51
+ const [action = ''] = args;
52
+ return PLUGIN_ACTIONS.has(String(action).toLowerCase()) ? '' : 'plugins';
53
+ }
54
+ if (BARE_ONLY.has(command) && args.length === 0) return command;
55
+ return '';
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tg-bridge",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
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": "bridge.mjs",
@@ -12,7 +12,8 @@
12
12
  "setup": "node setup.mjs",
13
13
  "check": "node setup.mjs --check",
14
14
  "menu": "node bot-menu.mjs",
15
- "doctor": "node bridge-kick.mjs"
15
+ "doctor": "node bridge-kick.mjs",
16
+ "test": "node --test"
16
17
  },
17
18
  "engines": {
18
19
  "node": ">=20"
@@ -43,6 +44,8 @@
43
44
  "README.md",
44
45
  "LICENSE",
45
46
  "config.example.json",
46
- "patches/"
47
+ "patches/",
48
+ "queue-notice.mjs",
49
+ "immediate.mjs"
47
50
  ]
48
51
  }
@@ -0,0 +1,10 @@
1
+ // When is a "your message is queued" notice already obsolete?
2
+ //
3
+ // It was written for a specific running turn (`turnWhenQueued`). It must go as
4
+ // soon as that turn is no longer the one in front: either the queue drained, or
5
+ // the queued turn started (its start time differs). Kept in its own module so
6
+ // the rule can be tested without starting the bridge or talking to Telegram.
7
+
8
+ export function noticeIsObsolete(turnWhenQueued, turnNow, stillQueued) {
9
+ return !stillQueued || turnNow !== turnWhenQueued;
10
+ }