switchroom 0.14.48 → 0.14.49
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/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +142 -73
- package/telegram-plugin/gateway/boot-card-msgid.ts +70 -0
- package/telegram-plugin/gateway/boot-card.ts +81 -14
- package/telegram-plugin/gateway/gateway.ts +23 -1
- package/telegram-plugin/tests/boot-card-edit-in-place.test.ts +139 -0
- package/telegram-plugin/tests/boot-card-msgid.test.ts +88 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49462,8 +49462,8 @@ var {
|
|
|
49462
49462
|
} = import__.default;
|
|
49463
49463
|
|
|
49464
49464
|
// src/build-info.ts
|
|
49465
|
-
var VERSION = "0.14.
|
|
49466
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.49";
|
|
49466
|
+
var COMMIT_SHA = "df84be56";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -29493,6 +29493,37 @@ function persistSnapshot(path, snapshot) {
|
|
|
29493
29493
|
}
|
|
29494
29494
|
var init_config_snapshot = () => {};
|
|
29495
29495
|
|
|
29496
|
+
// gateway/boot-card-msgid.ts
|
|
29497
|
+
import { readFileSync as readFileSync27, writeFileSync as writeFileSync17 } from "node:fs";
|
|
29498
|
+
function bootCardChatKey(chatId, threadId) {
|
|
29499
|
+
return `${chatId}:${threadId ?? ""}`;
|
|
29500
|
+
}
|
|
29501
|
+
function readStore(path) {
|
|
29502
|
+
try {
|
|
29503
|
+
const parsed = JSON.parse(readFileSync27(path, "utf8"));
|
|
29504
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
29505
|
+
return parsed;
|
|
29506
|
+
}
|
|
29507
|
+
} catch {}
|
|
29508
|
+
return {};
|
|
29509
|
+
}
|
|
29510
|
+
function loadBootCardMsgId(path, chatKey3) {
|
|
29511
|
+
const id = readStore(path)[chatKey3];
|
|
29512
|
+
return typeof id === "number" && Number.isFinite(id) && id > 0 ? id : null;
|
|
29513
|
+
}
|
|
29514
|
+
function saveBootCardMsgId(path, chatKey3, messageId) {
|
|
29515
|
+
if (!(Number.isFinite(messageId) && messageId > 0))
|
|
29516
|
+
return;
|
|
29517
|
+
try {
|
|
29518
|
+
const store2 = readStore(path);
|
|
29519
|
+
if (store2[chatKey3] === messageId)
|
|
29520
|
+
return;
|
|
29521
|
+
store2[chatKey3] = messageId;
|
|
29522
|
+
writeFileSync17(path, JSON.stringify(store2), "utf8");
|
|
29523
|
+
} catch {}
|
|
29524
|
+
}
|
|
29525
|
+
var init_boot_card_msgid = () => {};
|
|
29526
|
+
|
|
29496
29527
|
// gateway/boot-card.ts
|
|
29497
29528
|
var exports_boot_card = {};
|
|
29498
29529
|
__export(exports_boot_card, {
|
|
@@ -29640,22 +29671,46 @@ async function startBootCard(chatId, threadId, bot, opts, ackMessageId, log) {
|
|
|
29640
29671
|
...opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}
|
|
29641
29672
|
});
|
|
29642
29673
|
const silentBootCard = true;
|
|
29643
|
-
|
|
29644
|
-
|
|
29645
|
-
|
|
29646
|
-
|
|
29647
|
-
|
|
29648
|
-
|
|
29649
|
-
|
|
29650
|
-
|
|
29651
|
-
|
|
29652
|
-
|
|
29653
|
-
|
|
29674
|
+
const chatKey3 = bootCardChatKey(chatId, threadId);
|
|
29675
|
+
const reuseId = ackMessageId == null && opts.bootCardStatePath != null && bot.editMessageTextStrict != null ? loadBootCardMsgId(opts.bootCardStatePath, chatKey3) : null;
|
|
29676
|
+
let messageId = -1;
|
|
29677
|
+
if (reuseId != null && bot.editMessageTextStrict != null) {
|
|
29678
|
+
try {
|
|
29679
|
+
const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
|
|
29680
|
+
parse_mode: "HTML",
|
|
29681
|
+
link_preview_options: { is_disabled: true },
|
|
29682
|
+
...threadId != null ? { message_thread_id: threadId } : {}
|
|
29683
|
+
});
|
|
29684
|
+
if (outcome === "edited") {
|
|
29685
|
+
messageId = reuseId;
|
|
29686
|
+
logger2(`telegram gateway: boot-card: reused msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? "-"} reason_detail=${opts.restartReasonDetail ?? "-"} edit_in_place=true notify=none
|
|
29654
29687
|
`);
|
|
29655
|
-
|
|
29656
|
-
|
|
29688
|
+
}
|
|
29689
|
+
} catch (err) {
|
|
29690
|
+
logger2(`telegram gateway: boot-card: edit-in-place probe failed (${err?.message ?? String(err)}) \u2014 sending fresh
|
|
29657
29691
|
`);
|
|
29658
|
-
|
|
29692
|
+
}
|
|
29693
|
+
}
|
|
29694
|
+
if (messageId < 0) {
|
|
29695
|
+
try {
|
|
29696
|
+
const sent = await bot.sendMessage(chatId, ackText, {
|
|
29697
|
+
parse_mode: "HTML",
|
|
29698
|
+
link_preview_options: { is_disabled: true },
|
|
29699
|
+
...threadId != null ? { message_thread_id: threadId } : {},
|
|
29700
|
+
...ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {},
|
|
29701
|
+
...silentBootCard ? { disable_notification: true } : {}
|
|
29702
|
+
});
|
|
29703
|
+
messageId = sent.message_id;
|
|
29704
|
+
logger2(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? "-"} reason_detail=${opts.restartReasonDetail ?? "-"} silent=${silentBootCard}
|
|
29705
|
+
`);
|
|
29706
|
+
} catch (err) {
|
|
29707
|
+
logger2(`telegram gateway: boot-card: failed to post ack: ${err?.message ?? String(err)}
|
|
29708
|
+
`);
|
|
29709
|
+
return { messageId: -1, complete: () => {} };
|
|
29710
|
+
}
|
|
29711
|
+
}
|
|
29712
|
+
if (opts.bootCardStatePath != null && messageId > 0) {
|
|
29713
|
+
saveBootCardMsgId(opts.bootCardStatePath, chatKey3, messageId);
|
|
29659
29714
|
}
|
|
29660
29715
|
const liveWindowMs = opts.agentLiveWindowMs ?? AGENT_LIVE_WINDOW_MS;
|
|
29661
29716
|
setTimeoutFn(() => {
|
|
@@ -29822,6 +29877,7 @@ var init_boot_card = __esm(() => {
|
|
|
29822
29877
|
init_boot_probes();
|
|
29823
29878
|
init_boot_issue_cache();
|
|
29824
29879
|
init_config_snapshot();
|
|
29880
|
+
init_boot_card_msgid();
|
|
29825
29881
|
init_loader();
|
|
29826
29882
|
init_merge();
|
|
29827
29883
|
DOT = {
|
|
@@ -30211,8 +30267,8 @@ var init_flock = () => {};
|
|
|
30211
30267
|
// ../src/vault/vault.ts
|
|
30212
30268
|
import { randomBytes as randomBytes5, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
|
|
30213
30269
|
import {
|
|
30214
|
-
readFileSync as
|
|
30215
|
-
writeFileSync as
|
|
30270
|
+
readFileSync as readFileSync35,
|
|
30271
|
+
writeFileSync as writeFileSync23,
|
|
30216
30272
|
existsSync as existsSync36,
|
|
30217
30273
|
renameSync as renameSync11,
|
|
30218
30274
|
mkdirSync as mkdirSync23,
|
|
@@ -30256,7 +30312,7 @@ function openVault(passphrase, vaultPath) {
|
|
|
30256
30312
|
}
|
|
30257
30313
|
let vaultFile;
|
|
30258
30314
|
try {
|
|
30259
|
-
vaultFile = JSON.parse(
|
|
30315
|
+
vaultFile = JSON.parse(readFileSync35(vaultPath, "utf8"));
|
|
30260
30316
|
} catch {
|
|
30261
30317
|
throw new VaultError(`Failed to read vault file: ${vaultPath}`);
|
|
30262
30318
|
}
|
|
@@ -30637,7 +30693,7 @@ __export(exports_tmux, {
|
|
|
30637
30693
|
captureAgentPane: () => captureAgentPane
|
|
30638
30694
|
});
|
|
30639
30695
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
30640
|
-
import { mkdirSync as mkdirSync25, readdirSync as readdirSync6, statSync as statSync12, unlinkSync as unlinkSync13, writeFileSync as
|
|
30696
|
+
import { mkdirSync as mkdirSync25, readdirSync as readdirSync6, statSync as statSync12, unlinkSync as unlinkSync13, writeFileSync as writeFileSync24 } from "node:fs";
|
|
30641
30697
|
import { resolve as resolve7 } from "node:path";
|
|
30642
30698
|
function captureAgentPane(opts) {
|
|
30643
30699
|
const { agentName: agentName3, agentDir, reason } = opts;
|
|
@@ -30683,7 +30739,7 @@ function captureAgentPane(opts) {
|
|
|
30683
30739
|
` + `
|
|
30684
30740
|
`;
|
|
30685
30741
|
try {
|
|
30686
|
-
|
|
30742
|
+
writeFileSync24(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
|
|
30687
30743
|
mode: 420
|
|
30688
30744
|
});
|
|
30689
30745
|
} catch (err) {
|
|
@@ -31259,8 +31315,8 @@ var import_runner2 = __toESM(require_mod3(), 1);
|
|
|
31259
31315
|
import { randomBytes as randomBytes6 } from "crypto";
|
|
31260
31316
|
import { execFileSync as execFileSync5, execSync as execSync2, spawn as spawn2 } from "child_process";
|
|
31261
31317
|
import {
|
|
31262
|
-
readFileSync as
|
|
31263
|
-
writeFileSync as
|
|
31318
|
+
readFileSync as readFileSync36,
|
|
31319
|
+
writeFileSync as writeFileSync25,
|
|
31264
31320
|
mkdirSync as mkdirSync26,
|
|
31265
31321
|
readdirSync as readdirSync7,
|
|
31266
31322
|
rmSync as rmSync4,
|
|
@@ -50439,7 +50495,7 @@ function determineRestartReason(opts) {
|
|
|
50439
50495
|
init_boot_card();
|
|
50440
50496
|
|
|
50441
50497
|
// gateway/update-announce.ts
|
|
50442
|
-
import { existsSync as existsSync29, mkdirSync as mkdirSync17, openSync as openSync3, closeSync as closeSync3, readFileSync as
|
|
50498
|
+
import { existsSync as existsSync29, mkdirSync as mkdirSync17, openSync as openSync3, closeSync as closeSync3, readFileSync as readFileSync28 } from "node:fs";
|
|
50443
50499
|
import { join as join26 } from "node:path";
|
|
50444
50500
|
import { homedir as homedir12 } from "node:os";
|
|
50445
50501
|
|
|
@@ -50554,7 +50610,7 @@ var DEFAULT_LOOKBACK_MS = 10 * 60 * 1000;
|
|
|
50554
50610
|
function readLastTerminalUpdateAudit(opts = {}) {
|
|
50555
50611
|
const path = opts.auditLogPath ?? defaultAuditLogPath();
|
|
50556
50612
|
const exists = opts.exists ?? existsSync29;
|
|
50557
|
-
const readFile = opts.readFile ?? ((p) =>
|
|
50613
|
+
const readFile = opts.readFile ?? ((p) => readFileSync28(p, "utf-8"));
|
|
50558
50614
|
if (!exists(path))
|
|
50559
50615
|
return null;
|
|
50560
50616
|
let raw;
|
|
@@ -50642,7 +50698,7 @@ function maybeRenderUpdateAnnouncement(opts = {}) {
|
|
|
50642
50698
|
}
|
|
50643
50699
|
|
|
50644
50700
|
// issues-card.ts
|
|
50645
|
-
import { readFileSync as
|
|
50701
|
+
import { readFileSync as readFileSync29, writeFileSync as writeFileSync18 } from "node:fs";
|
|
50646
50702
|
var SEVERITY_EMOJI = {
|
|
50647
50703
|
info: "\u2139\ufe0f",
|
|
50648
50704
|
warn: "\u26a0\ufe0f",
|
|
@@ -50734,7 +50790,7 @@ function extractRetryAfterSecs2(err) {
|
|
|
50734
50790
|
var COOLDOWN_JITTER_MS2 = 500;
|
|
50735
50791
|
function readPersistedMessageId(path, log) {
|
|
50736
50792
|
try {
|
|
50737
|
-
const raw =
|
|
50793
|
+
const raw = readFileSync29(path, "utf8");
|
|
50738
50794
|
const parsed = JSON.parse(raw);
|
|
50739
50795
|
const v = parsed.messageId;
|
|
50740
50796
|
if (typeof v === "number" && Number.isInteger(v) && v > 0)
|
|
@@ -50750,7 +50806,7 @@ function readPersistedMessageId(path, log) {
|
|
|
50750
50806
|
}
|
|
50751
50807
|
function writePersistedMessageId(path, messageId, log) {
|
|
50752
50808
|
try {
|
|
50753
|
-
|
|
50809
|
+
writeFileSync18(path, JSON.stringify({ messageId }) + `
|
|
50754
50810
|
`, { mode: 384 });
|
|
50755
50811
|
} catch (err) {
|
|
50756
50812
|
log(`issues-card: persist write failed (${err.message})`);
|
|
@@ -50853,11 +50909,11 @@ import {
|
|
|
50853
50909
|
mkdirSync as mkdirSync18,
|
|
50854
50910
|
openSync as openSync4,
|
|
50855
50911
|
readdirSync as readdirSync5,
|
|
50856
|
-
readFileSync as
|
|
50912
|
+
readFileSync as readFileSync30,
|
|
50857
50913
|
renameSync as renameSync10,
|
|
50858
50914
|
statSync as statSync7,
|
|
50859
50915
|
unlinkSync as unlinkSync10,
|
|
50860
|
-
writeFileSync as
|
|
50916
|
+
writeFileSync as writeFileSync19,
|
|
50861
50917
|
writeSync
|
|
50862
50918
|
} from "node:fs";
|
|
50863
50919
|
import { join as join27 } from "node:path";
|
|
@@ -50884,7 +50940,7 @@ function readAll(stateDir) {
|
|
|
50884
50940
|
return [];
|
|
50885
50941
|
let raw;
|
|
50886
50942
|
try {
|
|
50887
|
-
raw =
|
|
50943
|
+
raw = readFileSync30(path, "utf-8");
|
|
50888
50944
|
} catch {
|
|
50889
50945
|
return [];
|
|
50890
50946
|
}
|
|
@@ -50940,7 +50996,7 @@ function writeAll(stateDir, events) {
|
|
|
50940
50996
|
const body = events.length === 0 ? "" : events.map((e) => JSON.stringify(e)).join(`
|
|
50941
50997
|
`) + `
|
|
50942
50998
|
`;
|
|
50943
|
-
|
|
50999
|
+
writeFileSync19(tmp, body, "utf-8");
|
|
50944
51000
|
renameSync10(tmp, path);
|
|
50945
51001
|
}
|
|
50946
51002
|
var ORPHAN_TMP_TTL_MS = 60000;
|
|
@@ -51003,7 +51059,7 @@ function withLock(stateDir, fn) {
|
|
|
51003
51059
|
function tryStealStaleLock(lockPath) {
|
|
51004
51060
|
let pidStr;
|
|
51005
51061
|
try {
|
|
51006
|
-
pidStr =
|
|
51062
|
+
pidStr = readFileSync30(lockPath, "utf-8").trim();
|
|
51007
51063
|
} catch {
|
|
51008
51064
|
return true;
|
|
51009
51065
|
}
|
|
@@ -51781,7 +51837,7 @@ function extractFlowItems(line) {
|
|
|
51781
51837
|
}
|
|
51782
51838
|
|
|
51783
51839
|
// credits-watch.ts
|
|
51784
|
-
import { readFileSync as
|
|
51840
|
+
import { readFileSync as readFileSync31, writeFileSync as writeFileSync20, existsSync as existsSync32, mkdirSync as mkdirSync19 } from "fs";
|
|
51785
51841
|
import { join as join29 } from "path";
|
|
51786
51842
|
var STATE_FILE = "credits-watch.json";
|
|
51787
51843
|
var FATAL_REASONS = new Set([
|
|
@@ -51799,7 +51855,7 @@ function readClaudeJsonOverage(claudeConfigDir) {
|
|
|
51799
51855
|
return null;
|
|
51800
51856
|
let raw;
|
|
51801
51857
|
try {
|
|
51802
|
-
raw =
|
|
51858
|
+
raw = readFileSync31(path, "utf-8");
|
|
51803
51859
|
} catch {
|
|
51804
51860
|
return null;
|
|
51805
51861
|
}
|
|
@@ -51883,7 +51939,7 @@ function loadCreditState(stateDir) {
|
|
|
51883
51939
|
if (!existsSync32(path))
|
|
51884
51940
|
return emptyCreditState();
|
|
51885
51941
|
try {
|
|
51886
|
-
const raw =
|
|
51942
|
+
const raw = readFileSync31(path, "utf-8");
|
|
51887
51943
|
const parsed = JSON.parse(raw);
|
|
51888
51944
|
if (parsed && typeof parsed === "object" && (parsed.lastNotifiedReason === null || typeof parsed.lastNotifiedReason === "string") && typeof parsed.lastNotifiedAt === "number" && Number.isFinite(parsed.lastNotifiedAt)) {
|
|
51889
51945
|
return {
|
|
@@ -51897,12 +51953,12 @@ function loadCreditState(stateDir) {
|
|
|
51897
51953
|
function saveCreditState(stateDir, state4) {
|
|
51898
51954
|
mkdirSync19(stateDir, { recursive: true });
|
|
51899
51955
|
const path = join29(stateDir, STATE_FILE);
|
|
51900
|
-
|
|
51956
|
+
writeFileSync20(path, JSON.stringify(state4, null, 2) + `
|
|
51901
51957
|
`, { mode: 384 });
|
|
51902
51958
|
}
|
|
51903
51959
|
|
|
51904
51960
|
// quota-watch.ts
|
|
51905
|
-
import { readFileSync as
|
|
51961
|
+
import { readFileSync as readFileSync32, writeFileSync as writeFileSync21, existsSync as existsSync33, mkdirSync as mkdirSync20 } from "fs";
|
|
51906
51962
|
import { join as join30 } from "path";
|
|
51907
51963
|
var STATE_FILE2 = "quota-watch.json";
|
|
51908
51964
|
function emptyQuotaWatchState() {
|
|
@@ -51997,7 +52053,7 @@ function loadQuotaWatchState(stateDir) {
|
|
|
51997
52053
|
if (!existsSync33(path))
|
|
51998
52054
|
return emptyQuotaWatchState();
|
|
51999
52055
|
try {
|
|
52000
|
-
const raw =
|
|
52056
|
+
const raw = readFileSync32(path, "utf-8");
|
|
52001
52057
|
const parsed = JSON.parse(raw);
|
|
52002
52058
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
52003
52059
|
return emptyQuotaWatchState();
|
|
@@ -52016,7 +52072,7 @@ function loadQuotaWatchState(stateDir) {
|
|
|
52016
52072
|
function saveQuotaWatchState(stateDir, state4) {
|
|
52017
52073
|
mkdirSync20(stateDir, { recursive: true });
|
|
52018
52074
|
const path = join30(stateDir, STATE_FILE2);
|
|
52019
|
-
|
|
52075
|
+
writeFileSync21(path, JSON.stringify(state4, null, 2) + `
|
|
52020
52076
|
`, { mode: 384 });
|
|
52021
52077
|
}
|
|
52022
52078
|
function patchQuotaWatchState(current, accountLabel, accountState) {
|
|
@@ -52032,18 +52088,18 @@ import {
|
|
|
52032
52088
|
existsSync as existsSync34,
|
|
52033
52089
|
mkdirSync as mkdirSync21,
|
|
52034
52090
|
openSync as openSync5,
|
|
52035
|
-
readFileSync as
|
|
52091
|
+
readFileSync as readFileSync33,
|
|
52036
52092
|
statSync as statSync9,
|
|
52037
52093
|
unlinkSync as unlinkSync11,
|
|
52038
52094
|
utimesSync as utimesSync2,
|
|
52039
|
-
writeFileSync as
|
|
52095
|
+
writeFileSync as writeFileSync22
|
|
52040
52096
|
} from "node:fs";
|
|
52041
52097
|
import { join as join31 } from "node:path";
|
|
52042
52098
|
var TURN_ACTIVE_MARKER_FILE2 = "turn-active.json";
|
|
52043
52099
|
function writeTurnActiveMarker(stateDir, marker) {
|
|
52044
52100
|
try {
|
|
52045
52101
|
mkdirSync21(stateDir, { recursive: true });
|
|
52046
|
-
|
|
52102
|
+
writeFileSync22(join31(stateDir, TURN_ACTIVE_MARKER_FILE2), JSON.stringify(marker, null, 2) + `
|
|
52047
52103
|
`, { mode: 384 });
|
|
52048
52104
|
} catch {}
|
|
52049
52105
|
}
|
|
@@ -52080,7 +52136,7 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52080
52136
|
return false;
|
|
52081
52137
|
let payload = null;
|
|
52082
52138
|
try {
|
|
52083
|
-
payload =
|
|
52139
|
+
payload = readFileSync33(path, "utf8");
|
|
52084
52140
|
} catch {}
|
|
52085
52141
|
unlinkSync11(path);
|
|
52086
52142
|
if (opts.onRemove) {
|
|
@@ -52099,10 +52155,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52099
52155
|
}
|
|
52100
52156
|
|
|
52101
52157
|
// ../src/build-info.ts
|
|
52102
|
-
var VERSION = "0.14.
|
|
52103
|
-
var COMMIT_SHA = "
|
|
52104
|
-
var COMMIT_DATE = "2026-06-
|
|
52105
|
-
var LATEST_PR =
|
|
52158
|
+
var VERSION = "0.14.49";
|
|
52159
|
+
var COMMIT_SHA = "df84be56";
|
|
52160
|
+
var COMMIT_DATE = "2026-06-03T09:45:46Z";
|
|
52161
|
+
var LATEST_PR = 2123;
|
|
52106
52162
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52107
52163
|
|
|
52108
52164
|
// gateway/boot-version.ts
|
|
@@ -52849,7 +52905,7 @@ function formatBootVersion() {
|
|
|
52849
52905
|
}
|
|
52850
52906
|
try {
|
|
52851
52907
|
chmodSync6(ENV_FILE, 384);
|
|
52852
|
-
for (const line of
|
|
52908
|
+
for (const line of readFileSync36(ENV_FILE, "utf8").split(`
|
|
52853
52909
|
`)) {
|
|
52854
52910
|
const m = line.match(/^(\w+)=(.*)$/);
|
|
52855
52911
|
if (m && process.env[m[1]] === undefined)
|
|
@@ -52902,7 +52958,7 @@ installTgPostLogger(bot);
|
|
|
52902
52958
|
var _rawSendMessageDraft = bot.api.raw.sendMessageDraft;
|
|
52903
52959
|
var GRAMMY_VERSION = (() => {
|
|
52904
52960
|
try {
|
|
52905
|
-
const raw =
|
|
52961
|
+
const raw = readFileSync36(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
|
|
52906
52962
|
return JSON.parse(raw).version ?? "unknown";
|
|
52907
52963
|
} catch {
|
|
52908
52964
|
return "unknown";
|
|
@@ -52994,7 +53050,7 @@ function assertSendable(f) {
|
|
|
52994
53050
|
}
|
|
52995
53051
|
function readAccessFile() {
|
|
52996
53052
|
try {
|
|
52997
|
-
const raw =
|
|
53053
|
+
const raw = readFileSync36(ACCESS_FILE, "utf8");
|
|
52998
53054
|
const parsed = JSON.parse(raw);
|
|
52999
53055
|
const allowFrom = validateStringArray("allowFrom", parsed.allowFrom ?? []);
|
|
53000
53056
|
const groups = {};
|
|
@@ -53064,7 +53120,7 @@ function saveAccess(a) {
|
|
|
53064
53120
|
return;
|
|
53065
53121
|
mkdirSync26(STATE_DIR, { recursive: true, mode: 448 });
|
|
53066
53122
|
const tmp = ACCESS_FILE + ".tmp";
|
|
53067
|
-
|
|
53123
|
+
writeFileSync25(tmp, JSON.stringify(a, null, 2) + `
|
|
53068
53124
|
`, { mode: 384 });
|
|
53069
53125
|
renameSync12(tmp, ACCESS_FILE);
|
|
53070
53126
|
}
|
|
@@ -53105,7 +53161,7 @@ try {
|
|
|
53105
53161
|
const st = statSync13(markerPath);
|
|
53106
53162
|
markerAgeMs = Date.now() - st.mtimeMs;
|
|
53107
53163
|
try {
|
|
53108
|
-
const payload = JSON.parse(
|
|
53164
|
+
const payload = JSON.parse(readFileSync36(markerPath, "utf8"));
|
|
53109
53165
|
if (typeof payload.turnKey === "string" && payload.turnKey.length > 0) {
|
|
53110
53166
|
markerTurnKey = payload.turnKey;
|
|
53111
53167
|
}
|
|
@@ -53176,7 +53232,7 @@ try {
|
|
|
53176
53232
|
pending2.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending2.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`
|
|
53177
53233
|
];
|
|
53178
53234
|
const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`;
|
|
53179
|
-
|
|
53235
|
+
writeFileSync25(pendingEnvTmp, lines.join(`
|
|
53180
53236
|
`) + `
|
|
53181
53237
|
`, { mode: 384 });
|
|
53182
53238
|
renameSync12(pendingEnvTmp, pendingEnvPath);
|
|
@@ -53798,7 +53854,18 @@ function wrapBootCardApi(threadId) {
|
|
|
53798
53854
|
const sent = await robustApiCall(() => lockedBot.api.sendMessage(cid, text, sendOpts), opts(cid));
|
|
53799
53855
|
return sent;
|
|
53800
53856
|
},
|
|
53801
|
-
editMessageText: (cid, mid, text, editOpts) => robustApiCall(() => lockedBot.api.editMessageText(cid, mid, text, editOpts), opts(cid))
|
|
53857
|
+
editMessageText: (cid, mid, text, editOpts) => robustApiCall(() => lockedBot.api.editMessageText(cid, mid, text, editOpts), opts(cid)),
|
|
53858
|
+
editMessageTextStrict: async (cid, mid, text, editOpts) => {
|
|
53859
|
+
try {
|
|
53860
|
+
await lockedBot.api.editMessageText(cid, mid, text, editOpts);
|
|
53861
|
+
return "edited";
|
|
53862
|
+
} catch (err) {
|
|
53863
|
+
const desc = err instanceof import_grammy9.GrammyError ? err.description : err instanceof Error ? err.message : String(err);
|
|
53864
|
+
if (typeof desc === "string" && desc.toLowerCase().includes("not modified"))
|
|
53865
|
+
return "edited";
|
|
53866
|
+
return "gone";
|
|
53867
|
+
}
|
|
53868
|
+
}
|
|
53802
53869
|
};
|
|
53803
53870
|
}
|
|
53804
53871
|
function wrapIssuesCardApi(threadId) {
|
|
@@ -54496,8 +54563,8 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
|
|
|
54496
54563
|
path: join35(STATE_DIR, "inbound-spool.jsonl"),
|
|
54497
54564
|
fs: {
|
|
54498
54565
|
appendFileSync: (p, d) => appendFileSync5(p, d),
|
|
54499
|
-
readFileSync: (p) =>
|
|
54500
|
-
writeFileSync: (p, d) =>
|
|
54566
|
+
readFileSync: (p) => readFileSync36(p, "utf8"),
|
|
54567
|
+
writeFileSync: (p, d) => writeFileSync25(p, d),
|
|
54501
54568
|
renameSync: (a, b) => renameSync12(a, b),
|
|
54502
54569
|
existsSync: (p) => existsSync38(p),
|
|
54503
54570
|
statSizeSync: (p) => statSync13(p).size
|
|
@@ -54642,6 +54709,7 @@ var ipcServer = createIpcServer({
|
|
|
54642
54709
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === "1",
|
|
54643
54710
|
dockerMode: process.env.SWITCHROOM_RUNTIME === "docker",
|
|
54644
54711
|
configSnapshotPath: join35(resolvedAgentDirForCard, ".config-snapshot.json"),
|
|
54712
|
+
bootCardStatePath: join35(resolvedAgentDirForCard, ".boot-card-msgid.json"),
|
|
54645
54713
|
...updateOutcomeLine ? { updateOutcomeLine } : {}
|
|
54646
54714
|
}, ackMsgId).then((handle) => {
|
|
54647
54715
|
activeBootCard = handle;
|
|
@@ -56081,7 +56149,7 @@ async function publishToTelegraph(text, shortName, authorName) {
|
|
|
56081
56149
|
let account = null;
|
|
56082
56150
|
try {
|
|
56083
56151
|
if (existsSync38(accountPath)) {
|
|
56084
|
-
const raw =
|
|
56152
|
+
const raw = readFileSync36(accountPath, "utf-8");
|
|
56085
56153
|
const parsed = JSON.parse(raw);
|
|
56086
56154
|
if (parsed.shortName && parsed.accessToken) {
|
|
56087
56155
|
account = parsed;
|
|
@@ -56101,7 +56169,7 @@ async function publishToTelegraph(text, shortName, authorName) {
|
|
|
56101
56169
|
account = created.value;
|
|
56102
56170
|
try {
|
|
56103
56171
|
mkdirSync26(STATE_DIR, { recursive: true, mode: 448 });
|
|
56104
|
-
|
|
56172
|
+
writeFileSync25(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
|
|
56105
56173
|
} catch (err) {
|
|
56106
56174
|
process.stderr.write(`telegram gateway: telegraph cache write failed: ${err.message}
|
|
56107
56175
|
`);
|
|
@@ -56575,7 +56643,7 @@ async function executeDownloadAttachment(args) {
|
|
|
56575
56643
|
});
|
|
56576
56644
|
mkdirSync26(INBOX_DIR, { recursive: true, mode: 448 });
|
|
56577
56645
|
assertInsideInbox(INBOX_DIR, dlPath);
|
|
56578
|
-
|
|
56646
|
+
writeFileSync25(dlPath, buf, { mode: 384 });
|
|
56579
56647
|
return { content: [{ type: "text", text: dlPath }] };
|
|
56580
56648
|
}
|
|
56581
56649
|
async function executeEditMessage(args) {
|
|
@@ -58464,7 +58532,7 @@ function writeRestartMarker(marker) {
|
|
|
58464
58532
|
if (!p)
|
|
58465
58533
|
return;
|
|
58466
58534
|
try {
|
|
58467
|
-
|
|
58535
|
+
writeFileSync25(p, JSON.stringify(marker));
|
|
58468
58536
|
lastPlannedRestartAt = Date.now();
|
|
58469
58537
|
process.stderr.write(`telegram gateway: restart-marker: write chat_id=${marker.chat_id} thread_id=${marker.thread_id ?? "-"} ack=${marker.ack_message_id ?? "-"} path=${p}
|
|
58470
58538
|
`);
|
|
@@ -58483,7 +58551,7 @@ function readRestartMarker() {
|
|
|
58483
58551
|
if (!p)
|
|
58484
58552
|
return null;
|
|
58485
58553
|
try {
|
|
58486
|
-
return JSON.parse(
|
|
58554
|
+
return JSON.parse(readFileSync36(p, "utf8"));
|
|
58487
58555
|
} catch {
|
|
58488
58556
|
return null;
|
|
58489
58557
|
}
|
|
@@ -58620,7 +58688,7 @@ function spawnSwitchroomDetached(args, onFailure) {
|
|
|
58620
58688
|
try {
|
|
58621
58689
|
mkdirSync26(STATE_DIR, { recursive: true });
|
|
58622
58690
|
outFd = openSync8(logPath, "a");
|
|
58623
|
-
|
|
58691
|
+
writeFileSync25(logPath, `
|
|
58624
58692
|
[${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(" ")}
|
|
58625
58693
|
`, { flag: "a" });
|
|
58626
58694
|
} catch {}
|
|
@@ -58646,7 +58714,7 @@ function spawnSwitchroomDetached(args, onFailure) {
|
|
|
58646
58714
|
return;
|
|
58647
58715
|
let tail = "";
|
|
58648
58716
|
try {
|
|
58649
|
-
const full =
|
|
58717
|
+
const full = readFileSync36(logPath, "utf8");
|
|
58650
58718
|
tail = full.split(`
|
|
58651
58719
|
`).slice(-30).join(`
|
|
58652
58720
|
`).trim();
|
|
@@ -58991,7 +59059,7 @@ function readRecentDenialsForAgent(agentName3, windowMs, limit) {
|
|
|
58991
59059
|
const auditPath = join35(homedir14(), ".switchroom", "vault-audit.log");
|
|
58992
59060
|
if (!existsSync38(auditPath))
|
|
58993
59061
|
return [];
|
|
58994
|
-
const raw =
|
|
59062
|
+
const raw = readFileSync36(auditPath, "utf8");
|
|
58995
59063
|
return recentDenialsFromAuditLog(raw, { agentName: agentName3, windowMs, limit });
|
|
58996
59064
|
} catch {
|
|
58997
59065
|
return [];
|
|
@@ -59042,7 +59110,7 @@ async function buildAgentMetadata(agentName3) {
|
|
|
59042
59110
|
try {
|
|
59043
59111
|
const agentDir = resolveAgentDirFromEnv();
|
|
59044
59112
|
if (agentDir) {
|
|
59045
|
-
const raw =
|
|
59113
|
+
const raw = readFileSync36(join35(agentDir, ".claude", ".claude.json"), "utf8");
|
|
59046
59114
|
claudeJson = JSON.parse(raw);
|
|
59047
59115
|
}
|
|
59048
59116
|
} catch {}
|
|
@@ -59314,7 +59382,7 @@ async function handleNewOrResetCommand(ctx, kind) {
|
|
|
59314
59382
|
writeRestartMarker({ chat_id: chatId, thread_id: threadId ?? null, ack_message_id: ackId, ts: Date.now() });
|
|
59315
59383
|
if (agentDir != null) {
|
|
59316
59384
|
try {
|
|
59317
|
-
|
|
59385
|
+
writeFileSync25(join35(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
|
|
59318
59386
|
`, "utf8");
|
|
59319
59387
|
} catch (err) {
|
|
59320
59388
|
process.stderr.write(`telegram gateway: failed to write force-fresh marker: ${err}
|
|
@@ -59678,8 +59746,8 @@ bot.command("interrupt", async (ctx) => {
|
|
|
59678
59746
|
await runSwitchroomCommand(ctx, ["agent", "interrupt", name], `interrupt ${name}`);
|
|
59679
59747
|
});
|
|
59680
59748
|
var lockoutOps = {
|
|
59681
|
-
readFileSync: (p, enc) =>
|
|
59682
|
-
writeFileSync: (p, data, opts) =>
|
|
59749
|
+
readFileSync: (p, enc) => readFileSync36(p, enc),
|
|
59750
|
+
writeFileSync: (p, data, opts) => writeFileSync25(p, data, opts),
|
|
59683
59751
|
existsSync: (p) => existsSync38(p),
|
|
59684
59752
|
mkdirSync: (p, opts) => mkdirSync26(p, opts),
|
|
59685
59753
|
joinPath: (...parts) => join35(...parts)
|
|
@@ -60054,7 +60122,7 @@ async function handleVaultRecentDenialCallback(ctx, data) {
|
|
|
60054
60122
|
const tokenPath = join35(homedir14(), ".switchroom", "agents", agentName3, ".vault-token");
|
|
60055
60123
|
try {
|
|
60056
60124
|
mkdirSync26(join35(homedir14(), ".switchroom", "agents", agentName3), { recursive: true });
|
|
60057
|
-
|
|
60125
|
+
writeFileSync25(tokenPath, token, { mode: 384 });
|
|
60058
60126
|
} catch (err) {
|
|
60059
60127
|
await switchroomReply(ctx, `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ${escapeHtmlForTg(String(err))}
|
|
60060
60128
|
<i>Recover with: <code>switchroom vault grant ${escapeHtmlForTg(agentName3)} --keys ${escapeHtmlForTg(keyName)} --duration 30d</code> on the host.</i>`, { html: true });
|
|
@@ -60133,7 +60201,7 @@ async function performVaultAccessApproval(ctx, pending2, stageId, senderId, atte
|
|
|
60133
60201
|
const tokenPath = join35(homedir14(), ".switchroom", "agents", pending2.agent, ".vault-token");
|
|
60134
60202
|
try {
|
|
60135
60203
|
mkdirSync26(join35(homedir14(), ".switchroom", "agents", pending2.agent), { recursive: true });
|
|
60136
|
-
|
|
60204
|
+
writeFileSync25(tokenPath, token, { mode: 384 });
|
|
60137
60205
|
} catch (err) {
|
|
60138
60206
|
await switchroomReply(ctx, `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ${escapeHtmlForTg(String(err))}
|
|
60139
60207
|
<i>Recover with: <code>switchroom vault grant ${escapeHtmlForTg(pending2.agent)} --keys ${escapeHtmlForTg(pending2.key)} --duration ${Math.round(pending2.ttl_seconds / 86400)}d</code> on the host.</i>`, { html: true });
|
|
@@ -60637,7 +60705,7 @@ async function executeGrantWizard(ctx, chatId, state4) {
|
|
|
60637
60705
|
const tokenPath = join35(homedir14(), ".switchroom", "agents", state4.agent, ".vault-token");
|
|
60638
60706
|
try {
|
|
60639
60707
|
mkdirSync26(join35(homedir14(), ".switchroom", "agents", state4.agent), { recursive: true });
|
|
60640
|
-
|
|
60708
|
+
writeFileSync25(tokenPath, token, { mode: 384 });
|
|
60641
60709
|
} catch (err) {
|
|
60642
60710
|
await switchroomReply(ctx, `<b>Grant created but token write failed:</b> ${escapeHtmlForTg(String(err))}`, { html: true });
|
|
60643
60711
|
return;
|
|
@@ -61933,7 +62001,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
|
|
|
61933
62001
|
const unifiedDiff = (() => {
|
|
61934
62002
|
try {
|
|
61935
62003
|
const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
|
|
61936
|
-
const raw =
|
|
62004
|
+
const raw = readFileSync36(cfgPath, "utf8");
|
|
61937
62005
|
return synthesizeAllowRuleDiff({ agentName: agentName3, rule: chosen.rule, configText: raw });
|
|
61938
62006
|
} catch (err) {
|
|
61939
62007
|
process.stderr.write(`telegram gateway: always-allow diff synth failed: ${err.message}
|
|
@@ -62081,7 +62149,7 @@ bot.on("message:photo", async (ctx) => {
|
|
|
62081
62149
|
});
|
|
62082
62150
|
mkdirSync26(INBOX_DIR, { recursive: true, mode: 448 });
|
|
62083
62151
|
assertInsideInbox(INBOX_DIR, dlPath);
|
|
62084
|
-
|
|
62152
|
+
writeFileSync25(dlPath, buf, { mode: 384 });
|
|
62085
62153
|
return dlPath;
|
|
62086
62154
|
} catch (err) {
|
|
62087
62155
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
@@ -62122,7 +62190,7 @@ async function maybeTranscribeVoice(fileId, mimeType, language) {
|
|
|
62122
62190
|
try {
|
|
62123
62191
|
const path = __require("path").join(__require("os").homedir(), ".switchroom", "openai-api-key");
|
|
62124
62192
|
if (existsSync38(path)) {
|
|
62125
|
-
apiKey =
|
|
62193
|
+
apiKey = readFileSync36(path, "utf-8").trim();
|
|
62126
62194
|
}
|
|
62127
62195
|
} catch (err) {
|
|
62128
62196
|
process.stderr.write(`telegram gateway: voice-in: failed to read api key: ${err.message}
|
|
@@ -62992,6 +63060,7 @@ var didOneTimeSetup = false;
|
|
|
62992
63060
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === "1",
|
|
62993
63061
|
dockerMode: process.env.SWITCHROOM_RUNTIME === "docker",
|
|
62994
63062
|
configSnapshotPath: join35(resolvedAgentDirForBootCard, ".config-snapshot.json"),
|
|
63063
|
+
bootCardStatePath: join35(resolvedAgentDirForBootCard, ".boot-card-msgid.json"),
|
|
62995
63064
|
...updateOutcomeLine ? { updateOutcomeLine } : {}
|
|
62996
63065
|
}, ackMsgId);
|
|
62997
63066
|
activeBootCard = handle;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-reboot persistence for the boot card's Telegram message id.
|
|
3
|
+
*
|
|
4
|
+
* Why: a freshly *sent* Telegram message always bumps the chat's unread
|
|
5
|
+
* badge — `disable_notification: true` removes the sound/banner but not the
|
|
6
|
+
* badge (there is no Bot API flag for that). To make routine reboots produce
|
|
7
|
+
* ZERO notification (operator request, 2026-06-03), the gateway reuses the
|
|
8
|
+
* PRIOR boot card's message and EDITS it in place instead of sending a new
|
|
9
|
+
* one — and edits never touch the badge.
|
|
10
|
+
*
|
|
11
|
+
* That requires remembering the last boot card's `message_id` across gateway
|
|
12
|
+
* restarts, keyed by the chat (+ forum topic) it lives in. This module is the
|
|
13
|
+
* tiny JSON store for that, mirroring `config-snapshot.ts` /
|
|
14
|
+
* `boot-issue-cache.ts`: one file under the agent's (bind-mounted, reboot-
|
|
15
|
+
* surviving) state dir, read once on boot, written once after the id is
|
|
16
|
+
* established. All failures are non-fatal — a missing/corrupt file just means
|
|
17
|
+
* "no prior card", so the boot path falls back to a fresh (silent) send.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
/** Stable key for a boot-card target: chat id + optional forum topic. A DM
|
|
23
|
+
* agent always boots to the same `<chatId>:` key; a supergroup agent keys by
|
|
24
|
+
* `<chatId>:<threadId>` so a topic change starts a fresh card. */
|
|
25
|
+
export function bootCardChatKey(chatId: string, threadId: number | undefined): string {
|
|
26
|
+
return `${chatId}:${threadId ?? ''}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Store = Record<string, number>
|
|
30
|
+
|
|
31
|
+
function readStore(path: string): Store {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
34
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
35
|
+
return parsed as Store
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
/* missing / corrupt → treat as empty */
|
|
39
|
+
}
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The persisted message id for this chat+topic, or null when there's no
|
|
44
|
+
* prior boot card to reuse (first boot, corrupt file, different chat). */
|
|
45
|
+
export function loadBootCardMsgId(
|
|
46
|
+
path: string,
|
|
47
|
+
chatKey: string,
|
|
48
|
+
): number | null {
|
|
49
|
+
const id = readStore(path)[chatKey]
|
|
50
|
+
return typeof id === 'number' && Number.isFinite(id) && id > 0 ? id : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Record the current boot card's message id for this chat+topic. Merges into
|
|
54
|
+
* the existing store (other chats' ids survive). Non-fatal on write failure —
|
|
55
|
+
* the worst case is the next reboot sends a fresh card (one badge). */
|
|
56
|
+
export function saveBootCardMsgId(
|
|
57
|
+
path: string,
|
|
58
|
+
chatKey: string,
|
|
59
|
+
messageId: number,
|
|
60
|
+
): void {
|
|
61
|
+
if (!(Number.isFinite(messageId) && messageId > 0)) return
|
|
62
|
+
try {
|
|
63
|
+
const store = readStore(path)
|
|
64
|
+
if (store[chatKey] === messageId) return // idempotent — no rewrite
|
|
65
|
+
store[chatKey] = messageId
|
|
66
|
+
writeFileSync(path, JSON.stringify(store), 'utf8')
|
|
67
|
+
} catch {
|
|
68
|
+
/* non-fatal */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
type ConfigDiff,
|
|
68
68
|
} from './config-snapshot.js'
|
|
69
69
|
import { join } from 'path'
|
|
70
|
+
import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
|
|
70
71
|
import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
|
|
71
72
|
import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
|
|
72
73
|
|
|
@@ -134,6 +135,21 @@ export interface BotApiForBootCard {
|
|
|
134
135
|
text: string,
|
|
135
136
|
opts?: Record<string, unknown>,
|
|
136
137
|
): Promise<unknown>
|
|
138
|
+
/**
|
|
139
|
+
* Like `editMessageText`, but reports whether the target message still
|
|
140
|
+
* exists rather than swallowing a "message to edit not found" the way the
|
|
141
|
+
* shared retry policy does (retry-api-call.ts) — the boot path needs to
|
|
142
|
+
* know so it can fall back to a fresh send when the prior card was deleted.
|
|
143
|
+
* `'edited'` = the edit landed (or content was identical → message exists);
|
|
144
|
+
* `'gone'` = the message is missing (or any other error → send fresh).
|
|
145
|
+
* Optional so existing callers/tests without it fall back to always-send.
|
|
146
|
+
*/
|
|
147
|
+
editMessageTextStrict?(
|
|
148
|
+
chatId: string,
|
|
149
|
+
messageId: number,
|
|
150
|
+
text: string,
|
|
151
|
+
opts?: Record<string, unknown>,
|
|
152
|
+
): Promise<'edited' | 'gone'>
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
export interface BootCardHandle {
|
|
@@ -568,6 +584,17 @@ export interface RunProbesOpts {
|
|
|
568
584
|
* resolve the default memory collection label.
|
|
569
585
|
*/
|
|
570
586
|
configSnapshotPath?: string
|
|
587
|
+
/**
|
|
588
|
+
* Cross-reboot store for the boot card's Telegram message id (JSON,
|
|
589
|
+
* typically `<agentDir>/.boot-card-msgid.json`). When set AND the bot
|
|
590
|
+
* supports `editMessageTextStrict`, a routine reboot (no `ackMessageId`)
|
|
591
|
+
* EDITS the prior boot card in place instead of sending a new one — edits
|
|
592
|
+
* never bump the unread badge, so reboots produce zero notification
|
|
593
|
+
* (operator request 2026-06-03). Falls back to a fresh silent send when
|
|
594
|
+
* there's no prior card or it was deleted. Omit to keep the always-send
|
|
595
|
+
* behaviour.
|
|
596
|
+
*/
|
|
597
|
+
bootCardStatePath?: string
|
|
571
598
|
}
|
|
572
599
|
|
|
573
600
|
/** Run all six probes concurrently with their own per-probe timeouts.
|
|
@@ -641,20 +668,60 @@ export async function startBootCard(
|
|
|
641
668
|
// the chat is where you look, and nothing here warrants a push.
|
|
642
669
|
const silentBootCard = true
|
|
643
670
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
671
|
+
// Edit-in-place to produce ZERO notification (operator request 2026-06-03).
|
|
672
|
+
// A sent message always bumps the unread badge — `disable_notification`
|
|
673
|
+
// only kills the sound/banner. So for a ROUTINE reboot (no `ackMessageId`:
|
|
674
|
+
// operator update / cli rollout / crash / fresh) we EDIT the prior boot
|
|
675
|
+
// card in place — edits never touch the badge — instead of sending a new
|
|
676
|
+
// one. We only do this when the bot can tell us the prior message still
|
|
677
|
+
// exists (`editMessageTextStrict`); if it's gone, or this is a
|
|
678
|
+
// Telegram-initiated `/restart` (ackMessageId set — the operator asked and
|
|
679
|
+
// is watching, and the card should reply to their command), we fall back to
|
|
680
|
+
// a fresh silent send.
|
|
681
|
+
const chatKey = bootCardChatKey(chatId, threadId)
|
|
682
|
+
const reuseId =
|
|
683
|
+
ackMessageId == null && opts.bootCardStatePath != null && bot.editMessageTextStrict != null
|
|
684
|
+
? loadBootCardMsgId(opts.bootCardStatePath, chatKey)
|
|
685
|
+
: null
|
|
686
|
+
|
|
687
|
+
let messageId = -1
|
|
688
|
+
if (reuseId != null && bot.editMessageTextStrict != null) {
|
|
689
|
+
try {
|
|
690
|
+
const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
|
|
691
|
+
parse_mode: 'HTML',
|
|
692
|
+
link_preview_options: { is_disabled: true },
|
|
693
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
694
|
+
})
|
|
695
|
+
if (outcome === 'edited') {
|
|
696
|
+
messageId = reuseId
|
|
697
|
+
logger(`telegram gateway: boot-card: reused msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'} reason_detail=${opts.restartReasonDetail ?? '-'} edit_in_place=true notify=none\n`)
|
|
698
|
+
}
|
|
699
|
+
} catch (err: unknown) {
|
|
700
|
+
logger(`telegram gateway: boot-card: edit-in-place probe failed (${(err as Error)?.message ?? String(err)}) — sending fresh\n`)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (messageId < 0) {
|
|
705
|
+
try {
|
|
706
|
+
const sent = await bot.sendMessage(chatId, ackText, {
|
|
707
|
+
parse_mode: 'HTML',
|
|
708
|
+
link_preview_options: { is_disabled: true },
|
|
709
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
710
|
+
...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
|
|
711
|
+
...(silentBootCard ? { disable_notification: true } : {}),
|
|
712
|
+
})
|
|
713
|
+
messageId = sent.message_id
|
|
714
|
+
logger(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'} reason_detail=${opts.restartReasonDetail ?? '-'} silent=${silentBootCard}\n`)
|
|
715
|
+
} catch (err: unknown) {
|
|
716
|
+
logger(`telegram gateway: boot-card: failed to post ack: ${(err as Error)?.message ?? String(err)}\n`)
|
|
717
|
+
return { messageId: -1, complete: () => {} }
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Remember this card's id so the NEXT reboot can edit it in place (no
|
|
722
|
+
// notification). Idempotent on reuse; non-fatal on write failure.
|
|
723
|
+
if (opts.bootCardStatePath != null && messageId > 0) {
|
|
724
|
+
saveBootCardMsgId(opts.bootCardStatePath, chatKey, messageId)
|
|
658
725
|
}
|
|
659
726
|
|
|
660
727
|
// Determine the live window for agent-service status updates. Callers
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* is connected, inbound LLM messages get a "⏳ Agent is restarting…" reply.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context } from 'grammy'
|
|
12
|
+
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context, type Api } from 'grammy'
|
|
13
13
|
import { run, type RunnerHandle } from '@grammyjs/runner'
|
|
14
14
|
import type { ReactionTypeEmoji } from 'grammy/types'
|
|
15
15
|
import { randomBytes } from 'crypto'
|
|
@@ -2603,6 +2603,26 @@ function wrapBootCardApi(
|
|
|
2603
2603
|
),
|
|
2604
2604
|
opts(cid),
|
|
2605
2605
|
) as Promise<unknown>,
|
|
2606
|
+
// Strict edit for the boot-card edit-in-place probe: distinguishes
|
|
2607
|
+
// "message gone" (→ 'gone', caller sends fresh) from a landed/identical
|
|
2608
|
+
// edit (→ 'edited'). robustApiCall SWALLOWS "message to edit not found"
|
|
2609
|
+
// to undefined (retry-api-call.ts), so this can't go through it — a
|
|
2610
|
+
// deliberate single-attempt raw edit that classifies the error itself.
|
|
2611
|
+
editMessageTextStrict: async (cid, mid, text, editOpts) => {
|
|
2612
|
+
type EditOpts = Parameters<Api['editMessageText']>[3]
|
|
2613
|
+
try {
|
|
2614
|
+
// allow-raw-bot-api: boot-card edit-in-place probe — must detect a deleted target, which the shared retry policy swallows.
|
|
2615
|
+
await lockedBot.api.editMessageText(cid, mid, text, editOpts as EditOpts)
|
|
2616
|
+
return 'edited'
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
const desc =
|
|
2619
|
+
err instanceof GrammyError ? err.description : err instanceof Error ? err.message : String(err)
|
|
2620
|
+
// Content identical → message still exists; reuse it.
|
|
2621
|
+
if (typeof desc === 'string' && desc.toLowerCase().includes('not modified')) return 'edited'
|
|
2622
|
+
// Not found, or any other error → fall back to a fresh silent send.
|
|
2623
|
+
return 'gone'
|
|
2624
|
+
}
|
|
2625
|
+
},
|
|
2606
2626
|
}
|
|
2607
2627
|
}
|
|
2608
2628
|
|
|
@@ -4578,6 +4598,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4578
4598
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
4579
4599
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
4580
4600
|
configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
|
|
4601
|
+
bootCardStatePath: join(resolvedAgentDirForCard, '.boot-card-msgid.json'),
|
|
4581
4602
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
4582
4603
|
}, ackMsgId).then(handle => {
|
|
4583
4604
|
activeBootCard = handle
|
|
@@ -18469,6 +18490,7 @@ void (async () => {
|
|
|
18469
18490
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
18470
18491
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
18471
18492
|
configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
|
|
18493
|
+
bootCardStatePath: join(resolvedAgentDirForBootCard, '.boot-card-msgid.json'),
|
|
18472
18494
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
18473
18495
|
}, ackMsgId)
|
|
18474
18496
|
activeBootCard = handle
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit-in-place boot card (zero-notification reboots, operator request
|
|
3
|
+
* 2026-06-03). A routine reboot must EDIT the prior boot card rather than
|
|
4
|
+
* send a new one — a sent message bumps the unread badge even with
|
|
5
|
+
* `disable_notification: true`; an edit never does.
|
|
6
|
+
*
|
|
7
|
+
* Pins the startBootCard contract:
|
|
8
|
+
* - first boot (no persisted id) → SEND + persist the id
|
|
9
|
+
* - next routine boot (persisted id exists) → EDIT in place, NO send
|
|
10
|
+
* - persisted id but message deleted ('gone') → fall back to SEND
|
|
11
|
+
* - Telegram-initiated /restart (ackMessageId set) → SEND fresh (replies to
|
|
12
|
+
* the operator's command; they asked and are watching)
|
|
13
|
+
* - no bootCardStatePath → always SEND (back-compat)
|
|
14
|
+
*
|
|
15
|
+
* State is an isolated mkdtemp file — NEVER ~/.switchroom (test discipline).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { startBootCard } from '../gateway/boot-card.js'
|
|
23
|
+
import type { BotApiForBootCard } from '../gateway/boot-card.js'
|
|
24
|
+
|
|
25
|
+
let dir: string
|
|
26
|
+
let statePath: string
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), 'boot-card-eip-'))
|
|
30
|
+
statePath = join(dir, '.boot-card-msgid.json')
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** Capturing bot with a configurable strict-edit outcome. */
|
|
37
|
+
function makeBot(strictOutcome: 'edited' | 'gone' | null) {
|
|
38
|
+
const sends: Array<{ chatId: string; opts: Record<string, unknown> }> = []
|
|
39
|
+
const strictEdits: Array<{ chatId: string; messageId: number }> = []
|
|
40
|
+
let nextId = 1000
|
|
41
|
+
const bot: BotApiForBootCard = {
|
|
42
|
+
sendMessage: async (chatId, _text, opts) => {
|
|
43
|
+
sends.push({ chatId, opts: opts ?? {} })
|
|
44
|
+
return { message_id: ++nextId }
|
|
45
|
+
},
|
|
46
|
+
editMessageText: async () => ({}),
|
|
47
|
+
...(strictOutcome != null
|
|
48
|
+
? {
|
|
49
|
+
editMessageTextStrict: async (chatId: string, messageId: number) => {
|
|
50
|
+
strictEdits.push({ chatId, messageId })
|
|
51
|
+
return strictOutcome
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
55
|
+
}
|
|
56
|
+
return { bot, sends, strictEdits }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mkOpts(overrides: Record<string, unknown> = {}) {
|
|
60
|
+
return {
|
|
61
|
+
agentName: 'TestAgent',
|
|
62
|
+
agentSlug: 'test-agent',
|
|
63
|
+
version: 'v0.0.0-test',
|
|
64
|
+
agentDir: dir,
|
|
65
|
+
gatewayInfo: { pid: 1, startedAtMs: Date.now() },
|
|
66
|
+
restartReason: 'graceful' as const,
|
|
67
|
+
agentLiveWindowMs: 0, // disable the live loop — we assert the initial post/edit only
|
|
68
|
+
settleWindowMs: 1_000_000,
|
|
69
|
+
bootCardStatePath: statePath,
|
|
70
|
+
...overrides,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('boot card — edit-in-place (zero-notification reboots)', () => {
|
|
75
|
+
it('first boot with no persisted id → sends, and persists the id', async () => {
|
|
76
|
+
const { bot, sends, strictEdits } = makeBot('edited')
|
|
77
|
+
await startBootCard('chat1', undefined, bot, mkOpts())
|
|
78
|
+
expect(sends).toHaveLength(1) // first boot must send (one badge, ever)
|
|
79
|
+
expect(strictEdits).toHaveLength(0)
|
|
80
|
+
expect(sends[0]!.opts.disable_notification).toBe(true) // still silent
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('second routine boot → edits the prior card in place, NO new send', async () => {
|
|
84
|
+
// Boot 1 sends + persists.
|
|
85
|
+
const first = makeBot('edited')
|
|
86
|
+
await startBootCard('chat1', undefined, first.bot, mkOpts())
|
|
87
|
+
expect(first.sends).toHaveLength(1)
|
|
88
|
+
|
|
89
|
+
// Boot 2 (same state file) → reuse via strict edit, no send.
|
|
90
|
+
const second = makeBot('edited')
|
|
91
|
+
await startBootCard('chat1', undefined, second.bot, mkOpts())
|
|
92
|
+
expect(second.sends).toHaveLength(0) // ← zero notification: no new message
|
|
93
|
+
expect(second.strictEdits).toHaveLength(1)
|
|
94
|
+
// Boot 1's send returned message_id 1001 (nextId starts at 1000, ++ first);
|
|
95
|
+
// boot 2 must edit exactly that persisted id.
|
|
96
|
+
expect(second.strictEdits[0]!.messageId).toBe(1001)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('persisted id but message was deleted (gone) → falls back to a fresh send', async () => {
|
|
100
|
+
const first = makeBot('edited')
|
|
101
|
+
await startBootCard('chat1', undefined, first.bot, mkOpts())
|
|
102
|
+
|
|
103
|
+
const second = makeBot('gone')
|
|
104
|
+
await startBootCard('chat1', undefined, second.bot, mkOpts())
|
|
105
|
+
expect(second.strictEdits).toHaveLength(1) // probed the old id
|
|
106
|
+
expect(second.sends).toHaveLength(1) // and fell back to a fresh send
|
|
107
|
+
expect(second.sends[0]!.opts.disable_notification).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('Telegram-initiated /restart (ackMessageId set) → sends fresh, never edits', async () => {
|
|
111
|
+
// Seed a persisted id from a routine boot.
|
|
112
|
+
const seed = makeBot('edited')
|
|
113
|
+
await startBootCard('chat1', undefined, seed.bot, mkOpts())
|
|
114
|
+
|
|
115
|
+
// A /restart passes ackMessageId → must reply with a fresh card, not edit.
|
|
116
|
+
const restart = makeBot('edited')
|
|
117
|
+
await startBootCard('chat1', undefined, restart.bot, mkOpts(), 555)
|
|
118
|
+
expect(restart.strictEdits).toHaveLength(0) // no reuse on user-initiated restart
|
|
119
|
+
expect(restart.sends).toHaveLength(1)
|
|
120
|
+
expect(restart.sends[0]!.opts.reply_parameters).toEqual({ message_id: 555 })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('no bootCardStatePath → always sends (back-compat, unchanged)', async () => {
|
|
124
|
+
const { bot, sends, strictEdits } = makeBot('edited')
|
|
125
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ bootCardStatePath: undefined }))
|
|
126
|
+
expect(sends).toHaveLength(1)
|
|
127
|
+
expect(strictEdits).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('bot without editMessageTextStrict → always sends (graceful degrade)', async () => {
|
|
131
|
+
const { bot, sends } = makeBot(null) // no strict method
|
|
132
|
+
await startBootCard('chat1', undefined, bot, mkOpts())
|
|
133
|
+
const second = makeBot(null)
|
|
134
|
+
await startBootCard('chat1', undefined, second.bot, mkOpts())
|
|
135
|
+
// Both boots send — no strict method means no in-place reuse path.
|
|
136
|
+
expect(sends).toHaveLength(1)
|
|
137
|
+
expect(second.sends).toHaveLength(1)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the cross-reboot boot-card message-id store
|
|
3
|
+
* (gateway/boot-card-msgid.ts) — the persistence that lets a routine reboot
|
|
4
|
+
* EDIT the prior boot card in place (zero notification) instead of sending a
|
|
5
|
+
* new one.
|
|
6
|
+
*
|
|
7
|
+
* All I/O is to an isolated mkdtemp dir — NEVER ~/.switchroom (test discipline).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
bootCardChatKey,
|
|
16
|
+
loadBootCardMsgId,
|
|
17
|
+
saveBootCardMsgId,
|
|
18
|
+
} from '../gateway/boot-card-msgid.js'
|
|
19
|
+
|
|
20
|
+
let dir: string
|
|
21
|
+
let path: string
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dir = mkdtempSync(join(tmpdir(), 'boot-card-msgid-'))
|
|
25
|
+
path = join(dir, '.boot-card-msgid.json')
|
|
26
|
+
})
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('bootCardChatKey', () => {
|
|
32
|
+
it('keys a DM (no topic) distinctly from a supergroup topic', () => {
|
|
33
|
+
expect(bootCardChatKey('12345', undefined)).toBe('12345:')
|
|
34
|
+
expect(bootCardChatKey('-1001234567890', 4)).toBe('-1001234567890:4')
|
|
35
|
+
// Different topics in the same supergroup are distinct cards.
|
|
36
|
+
expect(bootCardChatKey('-100', 3)).not.toBe(bootCardChatKey('-100', 4))
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('loadBootCardMsgId / saveBootCardMsgId', () => {
|
|
41
|
+
it('returns null when the file does not exist (first boot)', () => {
|
|
42
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('round-trips a saved id', () => {
|
|
46
|
+
saveBootCardMsgId(path, 'dm:', 353)
|
|
47
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(353)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps ids for different chats independent', () => {
|
|
51
|
+
saveBootCardMsgId(path, 'dm:', 100)
|
|
52
|
+
saveBootCardMsgId(path, '-100:4', 200)
|
|
53
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(100)
|
|
54
|
+
expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
|
|
55
|
+
// Updating one leaves the other intact.
|
|
56
|
+
saveBootCardMsgId(path, 'dm:', 101)
|
|
57
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(101)
|
|
58
|
+
expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns null for an unknown chat key', () => {
|
|
62
|
+
saveBootCardMsgId(path, 'dm:', 1)
|
|
63
|
+
expect(loadBootCardMsgId(path, 'other:')).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('rejects non-positive / non-finite ids on save and load', () => {
|
|
67
|
+
saveBootCardMsgId(path, 'dm:', 0)
|
|
68
|
+
saveBootCardMsgId(path, 'dm:', -5)
|
|
69
|
+
saveBootCardMsgId(path, 'dm:', Number.NaN)
|
|
70
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('treats a corrupt file as empty (falls back to fresh send)', () => {
|
|
74
|
+
writeFileSync(path, 'not json{', 'utf8')
|
|
75
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
|
|
76
|
+
// And a subsequent save still works (overwrites the garbage).
|
|
77
|
+
saveBootCardMsgId(path, 'dm:', 7)
|
|
78
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(7)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not rewrite the file when the id is unchanged (idempotent)', () => {
|
|
82
|
+
saveBootCardMsgId(path, 'dm:', 42)
|
|
83
|
+
expect(existsSync(path)).toBe(true)
|
|
84
|
+
// Saving the same value again is a no-op; the value is still readable.
|
|
85
|
+
saveBootCardMsgId(path, 'dm:', 42)
|
|
86
|
+
expect(loadBootCardMsgId(path, 'dm:')).toBe(42)
|
|
87
|
+
})
|
|
88
|
+
})
|