switchroom 0.16.11 → 0.16.13

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.
@@ -51674,8 +51674,8 @@ import { existsSync, readFileSync } from "node:fs";
51674
51674
  import { dirname, join } from "node:path";
51675
51675
 
51676
51676
  // src/build-info.ts
51677
- var VERSION = "0.16.11";
51678
- var COMMIT_SHA = "7846a496";
51677
+ var VERSION = "0.16.13";
51678
+ var COMMIT_SHA = "d4f3ac38";
51679
51679
 
51680
51680
  // src/cli/resolve-version.ts
51681
51681
  function readPackageVersion() {
@@ -77783,6 +77783,13 @@ function extractBearerToken(req) {
77783
77783
  if (idx >= 0 && idx + 1 < parts.length)
77784
77784
  return parts[idx + 1];
77785
77785
  }
77786
+ const url = new URL(req.url);
77787
+ const queryToken = url.searchParams.get("token");
77788
+ if (queryToken)
77789
+ return queryToken;
77790
+ const hermesHeader = req.headers.get("X-Hermes-Session-Token");
77791
+ if (hermesHeader)
77792
+ return hermesHeader;
77786
77793
  return null;
77787
77794
  }
77788
77795
  function checkAuth(req, token, server) {
@@ -22587,7 +22587,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22587
22587
  import { dirname as dirname4, join as join2 } from "node:path";
22588
22588
 
22589
22589
  // src/build-info.ts
22590
- var VERSION = "0.16.11";
22590
+ var VERSION = "0.16.13";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.11",
3
+ "version": "0.16.13",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50062,7 +50062,8 @@ function buildObligationRepresentInbound(o, now) {
50062
50062
  meta: {
50063
50063
  source: "obligation_represent",
50064
50064
  origin_turn_id: o.originTurnId,
50065
- represent_count: String(o.representCount + 1)
50065
+ represent_count: String(o.representCount + 1),
50066
+ chat_id: o.chatId
50066
50067
  }
50067
50068
  };
50068
50069
  }
@@ -56063,11 +56064,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56063
56064
  }
56064
56065
 
56065
56066
  // ../src/build-info.ts
56066
- var VERSION = "0.16.11";
56067
- var COMMIT_SHA = "7846a496";
56068
- var COMMIT_DATE = "2026-06-28T09:51:30Z";
56069
- var LATEST_PR = 2630;
56070
- var COMMITS_AHEAD_OF_TAG = 0;
56067
+ var VERSION = "0.16.13";
56068
+ var COMMIT_SHA = "d4f3ac38";
56069
+ var COMMIT_DATE = "2026-06-28T21:45:50+10:00";
56070
+ var LATEST_PR = null;
56071
+ var COMMITS_AHEAD_OF_TAG = 2;
56071
56072
 
56072
56073
  // gateway/boot-version.ts
56073
56074
  function formatRelativeAgo(iso) {
@@ -58111,9 +58112,11 @@ var progressUpdateLastSent = new Map;
58111
58112
  var progressUpdateTurnCount = new Map;
58112
58113
  var currentTurn = null;
58113
58114
  var currentTurnMap = new CurrentTurnMap;
58115
+ var lastActiveTurnChatId;
58114
58116
  function setCurrentTurn(turn, key) {
58115
58117
  currentTurnMap.set(turn, key);
58116
58118
  currentTurn = currentTurnMap.get();
58119
+ lastActiveTurnChatId = turn.sessionChatId;
58117
58120
  }
58118
58121
  function endCurrentTurnForKey(turn, key) {
58119
58122
  const ended = currentTurnMap.endTurnForKey(turn, key);
@@ -60795,9 +60798,22 @@ function redactOutboundText(text2, site) {
60795
60798
  }
60796
60799
  async function executeReply(args) {
60797
60800
  const turn = currentTurn;
60798
- const chat_id = String(args.chat_id ?? "");
60799
- if (!chat_id)
60801
+ const _rawChatId = String(args.chat_id ?? "");
60802
+ if (!_rawChatId)
60800
60803
  throw new Error("reply: chat_id is required");
60804
+ const chat_id = (() => {
60805
+ const _a = loadAccess();
60806
+ if (_a.allowFrom.includes(_rawChatId) || _rawChatId in _a.groups)
60807
+ return _rawChatId;
60808
+ const fallbackChatId = turn?.sessionChatId ?? lastActiveTurnChatId;
60809
+ if (fallbackChatId && (_a.allowFrom.includes(fallbackChatId) || (fallbackChatId in _a.groups))) {
60810
+ const tier = turn == null ? "last-known" : "active";
60811
+ process.stderr.write(`telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) \u2014 ` + `routing to ${tier} turn chat "${fallbackChatId}"
60812
+ `);
60813
+ return fallbackChatId;
60814
+ }
60815
+ return _rawChatId;
60816
+ })();
60801
60817
  const rawText = args.text;
60802
60818
  if (rawText == null || rawText === "")
60803
60819
  throw new Error("reply: text is required and cannot be empty");
@@ -2216,6 +2216,13 @@ type CurrentTurn = {
2216
2216
  // is never written and this is exactly the old singleton.
2217
2217
  let currentTurn: CurrentTurn | null = null
2218
2218
  const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
2219
+ // Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
2220
+ // this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
2221
+ // the Bug B fallback in executeReply route to the correct chat even when the
2222
+ // model calls reply AFTER the silence poke has nulled currentTurn (Bug D).
2223
+ // Safe in single-tenant gateways: the fallback only fires when the raw chat_id
2224
+ // fails the allowlist, and this value was itself validated at inbound receipt.
2225
+ let lastActiveTurnChatId: string | undefined
2219
2226
 
2220
2227
  /**
2221
2228
  * Set the live turn for `key`. Flag-branches in ONE place (inside the map):
@@ -2227,6 +2234,7 @@ const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
2227
2234
  function setCurrentTurn(turn: CurrentTurn, key: string): void {
2228
2235
  currentTurnMap.set(turn, key)
2229
2236
  currentTurn = currentTurnMap.get() // mirror most-recent-set (== `turn`)
2237
+ lastActiveTurnChatId = turn.sessionChatId
2230
2238
  }
2231
2239
 
2232
2240
  /**
@@ -7972,8 +7980,31 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
7972
7980
  // module-scope currentTurn, which a future refactor could let roll over
7973
7981
  // mid-call.
7974
7982
  const turn = currentTurn
7975
- const chat_id = String(args.chat_id ?? '')
7976
- if (!chat_id) throw new Error('reply: chat_id is required')
7983
+ const _rawChatId = String(args.chat_id ?? '')
7984
+ if (!_rawChatId) throw new Error('reply: chat_id is required')
7985
+ // Non-Claude models (e.g. Gemini via LiteLLM sr-* routing) sometimes pass a
7986
+ // chat_id that is not in the allowlist — either an int/string mismatch, or
7987
+ // the model echoing the wrong identifier from context. When the raw value
7988
+ // fails the allowlist check, fall back to the active (or last-known) turn's
7989
+ // validated sessionChatId so the reply still lands correctly.
7990
+ // Tier 1: live turn (currentTurn was non-null at reply entry).
7991
+ // Tier 2: last-known turn (survives silence poke — Bug D fix; currentTurn is
7992
+ // null because clearTurnStarted fired ≥5 min of model silence, but the model
7993
+ // finally called reply after the poke).
7994
+ const chat_id = (() => {
7995
+ const _a = loadAccess()
7996
+ if (_a.allowFrom.includes(_rawChatId) || _rawChatId in _a.groups) return _rawChatId
7997
+ const fallbackChatId = turn?.sessionChatId ?? lastActiveTurnChatId
7998
+ if (fallbackChatId && (_a.allowFrom.includes(fallbackChatId) || fallbackChatId in _a.groups)) {
7999
+ const tier = turn == null ? 'last-known' : 'active'
8000
+ process.stderr.write(
8001
+ `telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
8002
+ `routing to ${tier} turn chat "${fallbackChatId}"\n`,
8003
+ )
8004
+ return fallbackChatId
8005
+ }
8006
+ return _rawChatId // let assertAllowedChat below throw the human-readable error
8007
+ })()
7977
8008
  const rawText = args.text as string | undefined
7978
8009
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
7979
8010
  let text = repairEscapedWhitespace(rawText)
@@ -381,6 +381,7 @@ export function buildObligationRepresentInbound(o: Obligation, now: number): Inb
381
381
  source: 'obligation_represent',
382
382
  origin_turn_id: o.originTurnId,
383
383
  represent_count: String(o.representCount + 1),
384
+ chat_id: o.chatId,
384
385
  },
385
386
  }
386
387
  }
@@ -184,6 +184,7 @@ describe("buildObligationRepresentInbound", () => {
184
184
  expect(m.meta.origin_turn_id).toBe("-100123:3#715");
185
185
  expect(m.meta.source).toBe("obligation_represent"); // synthetic → not tracked, no new obligation
186
186
  expect(m.meta.represent_count).toBe("1");
187
+ expect(m.meta.chat_id).toBe("-100123"); // Bug C fix: chat_id in meta drives the <channel> tag
187
188
  expect(m.text).toContain("do the Meta report");
188
189
  expect(m.text).toMatch(/answer it now|reply tool/i);
189
190
  });
@@ -59,10 +59,13 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
59
59
  const openrouterHeader = flat.find(
60
60
  (b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
61
61
  );
62
- // Prefer a fast non-reasoning model for the E2E test; reasoning models
63
- // (deepseek-r1, o1, o3) take 2-5 min per response and hit the silence poke.
62
+ // Prefer deepseek-v3 (non-thinking, consistently fast) for the E2E test.
63
+ // gemini-2.5-flash may run in thinking mode via OpenRouter (5+ min latency),
64
+ // reasoning models (deepseek-r1, o1, o3) also take 2-5 min and hit the
65
+ // silence poke — both are OK now (Bug D fallback), but slow tests are painful.
64
66
  const srButton =
65
- flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /flash/.test(b.callbackData)) ??
67
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /deepseek-v3/.test(b.callbackData)) ??
68
+ flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && /flash/.test(b.callbackData) && !/thinking/.test(b.callbackData)) ??
66
69
  flat.find((b) => b.callbackData?.startsWith("mdl:sr:") && !/r1|o1|o3|thinking/.test(b.callbackData)) ??
67
70
  flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
68
71
 
@@ -93,10 +96,12 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
93
96
  expect((kbAfter ?? []).flat().length, "menu keeps buttons after sr-* tap").toBeGreaterThan(0);
94
97
 
95
98
  // ── 3. Send a quick message to generate a LiteLLM-routed turn ──
96
- // Use 120s fast models (gemini-flash, deepseek-v3) respond in <10s,
97
- // but the request still has to go through the model switch inject + proxy.
99
+ // 480s budget: fast models (deepseek-v3) reply in <10s; Gemini 2.5 Flash
100
+ // may run thinking mode via OpenRouter and take 5+ min. The silence poke
101
+ // fires at 300s, which is OK — the Bug D fallback (lastActiveTurnChatId)
102
+ // routes the reply even when currentTurn was nulled by the poke.
98
103
  await sc.sendDM("Just reply with the word OK.");
99
- await sc.expectMessage(/ok/i, { from: "bot", timeout: 120_000 });
104
+ await sc.expectMessage(/ok/i, { from: "bot", timeout: 480_000 });
100
105
 
101
106
  // ── 4. LiteLLM spend attribution ────────────────────────────────
102
107
  if (spendBefore >= 0) {
@@ -126,7 +131,7 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
126
131
  await sc.tearDown();
127
132
  }
128
133
  },
129
- 210_000,
134
+ 660_000,
130
135
  );
131
136
 
132
137
  it(