switchroom 0.21.0 → 0.21.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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +156 -9
- package/telegram-plugin/gateway/gateway.ts +8 -0
- package/telegram-plugin/gateway/system-message-observer.ts +189 -13
- package/telegram-plugin/shared/sent-text-capture.ts +191 -0
- package/telegram-plugin/tests/card-history-lane.test.ts +121 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +526 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +11 -2
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.21.
|
|
2123
|
+
var VERSION = "0.21.1", COMMIT_SHA = "6aef7b24";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -21565,7 +21565,7 @@ function allocateAgentUid(name) {
|
|
|
21565
21565
|
}
|
|
21566
21566
|
|
|
21567
21567
|
// src/build-info.ts
|
|
21568
|
-
var VERSION = "0.21.
|
|
21568
|
+
var VERSION = "0.21.1";
|
|
21569
21569
|
|
|
21570
21570
|
// src/setup/hindsight-recall-passthrough.ts
|
|
21571
21571
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.21.
|
|
4
|
+
"version": "0.21.1",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -67137,6 +67137,83 @@ function installRichMarkdownGuard(bot) {
|
|
|
67137
67137
|
});
|
|
67138
67138
|
}
|
|
67139
67139
|
|
|
67140
|
+
// shared/sent-text-capture.ts
|
|
67141
|
+
var SENT_TEXT = Symbol.for("switchroom.telegram.sentText");
|
|
67142
|
+
var MAX_BLOCK_DEPTH = 8;
|
|
67143
|
+
function flattenInputRichBlocks(blocks, depth) {
|
|
67144
|
+
if (!Array.isArray(blocks) || depth > MAX_BLOCK_DEPTH)
|
|
67145
|
+
return "";
|
|
67146
|
+
const parts = [];
|
|
67147
|
+
for (const block of blocks) {
|
|
67148
|
+
if (block == null || typeof block !== "object")
|
|
67149
|
+
continue;
|
|
67150
|
+
const b = block;
|
|
67151
|
+
for (const key of ["markdown", "html", "text", "caption"]) {
|
|
67152
|
+
const v = b[key];
|
|
67153
|
+
if (typeof v === "string" && v.length > 0)
|
|
67154
|
+
parts.push(v);
|
|
67155
|
+
}
|
|
67156
|
+
const nested = flattenInputRichBlocks(b.blocks, depth + 1);
|
|
67157
|
+
if (nested.length > 0)
|
|
67158
|
+
parts.push(nested);
|
|
67159
|
+
}
|
|
67160
|
+
return parts.join(`
|
|
67161
|
+
`);
|
|
67162
|
+
}
|
|
67163
|
+
function outboundPayloadText(payload) {
|
|
67164
|
+
if (payload == null || typeof payload !== "object")
|
|
67165
|
+
return null;
|
|
67166
|
+
const p = payload;
|
|
67167
|
+
const rich = p.rich_message;
|
|
67168
|
+
if (rich != null && typeof rich === "object") {
|
|
67169
|
+
const r = rich;
|
|
67170
|
+
if (typeof r.markdown === "string")
|
|
67171
|
+
return r.markdown;
|
|
67172
|
+
if (typeof r.html === "string")
|
|
67173
|
+
return r.html;
|
|
67174
|
+
const flat = flattenInputRichBlocks(r.blocks, 0);
|
|
67175
|
+
if (flat.length > 0)
|
|
67176
|
+
return flat;
|
|
67177
|
+
}
|
|
67178
|
+
if (typeof p.text === "string")
|
|
67179
|
+
return p.text;
|
|
67180
|
+
if (typeof p.caption === "string")
|
|
67181
|
+
return p.caption;
|
|
67182
|
+
return null;
|
|
67183
|
+
}
|
|
67184
|
+
function attachSentText(envelope2, text4) {
|
|
67185
|
+
try {
|
|
67186
|
+
if (envelope2 == null || typeof envelope2 !== "object")
|
|
67187
|
+
return;
|
|
67188
|
+
const env = envelope2;
|
|
67189
|
+
if (env.ok !== true)
|
|
67190
|
+
return;
|
|
67191
|
+
const result = env.result;
|
|
67192
|
+
if (result == null || typeof result !== "object" || Array.isArray(result))
|
|
67193
|
+
return;
|
|
67194
|
+
Object.defineProperty(result, SENT_TEXT, {
|
|
67195
|
+
value: text4,
|
|
67196
|
+
enumerable: false,
|
|
67197
|
+
configurable: true,
|
|
67198
|
+
writable: true
|
|
67199
|
+
});
|
|
67200
|
+
} catch {}
|
|
67201
|
+
}
|
|
67202
|
+
function installSentTextCapture(bot) {
|
|
67203
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
67204
|
+
let text4 = null;
|
|
67205
|
+
try {
|
|
67206
|
+
text4 = outboundPayloadText(payload);
|
|
67207
|
+
} catch {
|
|
67208
|
+
text4 = null;
|
|
67209
|
+
}
|
|
67210
|
+
const res = await prev(method, payload, signal);
|
|
67211
|
+
if (text4 != null)
|
|
67212
|
+
attachSentText(res, text4);
|
|
67213
|
+
return res;
|
|
67214
|
+
});
|
|
67215
|
+
}
|
|
67216
|
+
|
|
67140
67217
|
// flood-circuit-breaker.ts
|
|
67141
67218
|
init_flood_429_ledger();
|
|
67142
67219
|
import {
|
|
@@ -76389,6 +76466,15 @@ function query(opts) {
|
|
|
76389
76466
|
return rows;
|
|
76390
76467
|
}
|
|
76391
76468
|
|
|
76469
|
+
// shared/sent-text-capture.ts
|
|
76470
|
+
var SENT_TEXT2 = Symbol.for("switchroom.telegram.sentText");
|
|
76471
|
+
function readSentText(message) {
|
|
76472
|
+
if (message == null || typeof message !== "object")
|
|
76473
|
+
return null;
|
|
76474
|
+
const v = message[SENT_TEXT2];
|
|
76475
|
+
return typeof v === "string" ? v : null;
|
|
76476
|
+
}
|
|
76477
|
+
|
|
76392
76478
|
// gateway/system-message-observer.ts
|
|
76393
76479
|
var DEFAULT_EDIT_REFRESH_MS = 20000;
|
|
76394
76480
|
var DEFAULT_MAX_TRACKED = 512;
|
|
@@ -76415,14 +76501,61 @@ function extractSentMessage(result, opts) {
|
|
|
76415
76501
|
return null;
|
|
76416
76502
|
const rawThread = msg.message_thread_id;
|
|
76417
76503
|
const threadId = typeof rawThread === "number" && Number.isInteger(rawThread) ? rawThread : typeof opts?.threadId === "number" ? opts.threadId : null;
|
|
76418
|
-
const text4 =
|
|
76504
|
+
const text4 = (msg.rich_message != null ? extractRichMessageText(msg.rich_message) : undefined) ?? nonEmptyString(msg.text) ?? nonEmptyString(msg.caption) ?? readSentText(result) ?? "";
|
|
76419
76505
|
return { chatId, messageId: id, threadId, text: text4 };
|
|
76420
76506
|
}
|
|
76507
|
+
function nonEmptyString(v) {
|
|
76508
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
76509
|
+
}
|
|
76510
|
+
var BODILESS_MESSAGE_KEYS = [
|
|
76511
|
+
"sticker",
|
|
76512
|
+
"animation",
|
|
76513
|
+
"voice",
|
|
76514
|
+
"video_note",
|
|
76515
|
+
"dice",
|
|
76516
|
+
"game",
|
|
76517
|
+
"poll",
|
|
76518
|
+
"contact",
|
|
76519
|
+
"location",
|
|
76520
|
+
"venue",
|
|
76521
|
+
"story",
|
|
76522
|
+
"invoice",
|
|
76523
|
+
"successful_payment",
|
|
76524
|
+
"checklist",
|
|
76525
|
+
"photo",
|
|
76526
|
+
"video",
|
|
76527
|
+
"audio",
|
|
76528
|
+
"document",
|
|
76529
|
+
"paid_media"
|
|
76530
|
+
];
|
|
76531
|
+
function isLegitimatelyBodiless(result) {
|
|
76532
|
+
if (result == null || typeof result !== "object")
|
|
76533
|
+
return false;
|
|
76534
|
+
const r = result;
|
|
76535
|
+
return BODILESS_MESSAGE_KEYS.some((k) => r[k] != null);
|
|
76536
|
+
}
|
|
76537
|
+
function defaultEmptyCardTextWarning(info) {
|
|
76538
|
+
try {
|
|
76539
|
+
process.stderr.write(`telegram gateway: card-history text capture MISSED kind=${info.kind ?? "-"} ` + `chat=${info.chat_id} id=${info.message_id} \u2014 the response carried no ` + `rich_message/text/caption and no request-side body was stamped, so a ` + `quote-reply to this card will resolve its kind but not its body ` + `(see gateway/system-message-observer.ts extractSentMessage)
|
|
76540
|
+
`);
|
|
76541
|
+
} catch {}
|
|
76542
|
+
}
|
|
76421
76543
|
function makeSystemMessageObserver(deps, options) {
|
|
76422
76544
|
const now = deps.now ?? Date.now;
|
|
76423
76545
|
const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS;
|
|
76424
76546
|
const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED);
|
|
76425
76547
|
const tracked = new Map;
|
|
76548
|
+
const emptyReported = new Set;
|
|
76549
|
+
const onEmptyText = deps.onEmptyText ?? defaultEmptyCardTextWarning;
|
|
76550
|
+
function reportEmpty(chatId, messageId, kind, verb) {
|
|
76551
|
+
const bucket = kind ?? (typeof verb === "string" && verb.length > 0 ? verb : "<untagged>");
|
|
76552
|
+
if (emptyReported.has(bucket))
|
|
76553
|
+
return;
|
|
76554
|
+
emptyReported.add(bucket);
|
|
76555
|
+
try {
|
|
76556
|
+
onEmptyText({ chat_id: chatId, message_id: messageId, kind });
|
|
76557
|
+
} catch {}
|
|
76558
|
+
}
|
|
76426
76559
|
function remember(key, state3) {
|
|
76427
76560
|
tracked.set(key, state3);
|
|
76428
76561
|
if (tracked.size > maxTracked) {
|
|
@@ -76443,13 +76576,20 @@ function makeSystemMessageObserver(deps, options) {
|
|
|
76443
76576
|
const key = `${sent.chatId}:${sent.messageId}`;
|
|
76444
76577
|
const t = now();
|
|
76445
76578
|
const seen = tracked.get(key);
|
|
76579
|
+
const kind = normalizeSendVerb(opts?.verb);
|
|
76580
|
+
if (sent.text.length === 0 && !isLegitimatelyBodiless(result)) {
|
|
76581
|
+
reportEmpty(sent.chatId, sent.messageId, kind, opts?.verb);
|
|
76582
|
+
}
|
|
76446
76583
|
if (seen != null) {
|
|
76447
76584
|
if (seen.lane === "foreign")
|
|
76448
76585
|
return;
|
|
76449
|
-
if (
|
|
76586
|
+
if (sent.text.length === 0)
|
|
76587
|
+
return;
|
|
76588
|
+
if (seen.storedLen > 0 && t - seen.lastStoredMs < editRefreshMs)
|
|
76450
76589
|
return;
|
|
76451
76590
|
if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
|
|
76452
76591
|
seen.lastStoredMs = t;
|
|
76592
|
+
seen.storedLen = sent.text.length;
|
|
76453
76593
|
} else {
|
|
76454
76594
|
seen.lane = "foreign";
|
|
76455
76595
|
}
|
|
@@ -76459,19 +76599,25 @@ function makeSystemMessageObserver(deps, options) {
|
|
|
76459
76599
|
chat_id: sent.chatId,
|
|
76460
76600
|
thread_id: sent.threadId,
|
|
76461
76601
|
message_id: sent.messageId,
|
|
76462
|
-
kind
|
|
76602
|
+
kind,
|
|
76463
76603
|
text: sent.text
|
|
76464
76604
|
});
|
|
76465
76605
|
if (inserted) {
|
|
76466
|
-
remember(key, { lane: "system", lastStoredMs: t });
|
|
76606
|
+
remember(key, { lane: "system", lastStoredMs: t, storedLen: sent.text.length });
|
|
76467
76607
|
return;
|
|
76468
76608
|
}
|
|
76609
|
+
if (sent.text.length === 0)
|
|
76610
|
+
return;
|
|
76469
76611
|
const refreshed = deps.updateText({
|
|
76470
76612
|
chat_id: sent.chatId,
|
|
76471
76613
|
message_id: sent.messageId,
|
|
76472
76614
|
text: sent.text
|
|
76473
76615
|
});
|
|
76474
|
-
remember(key, {
|
|
76616
|
+
remember(key, {
|
|
76617
|
+
lane: refreshed ? "system" : "foreign",
|
|
76618
|
+
lastStoredMs: t,
|
|
76619
|
+
storedLen: refreshed ? sent.text.length : 0
|
|
76620
|
+
});
|
|
76475
76621
|
} catch {}
|
|
76476
76622
|
};
|
|
76477
76623
|
}
|
|
@@ -104023,10 +104169,10 @@ function startOutboxSweep(deps) {
|
|
|
104023
104169
|
}
|
|
104024
104170
|
|
|
104025
104171
|
// ../src/build-info.ts
|
|
104026
|
-
var VERSION2 = "0.21.
|
|
104027
|
-
var COMMIT_SHA = "
|
|
104028
|
-
var COMMIT_DATE = "2026-08-
|
|
104029
|
-
var LATEST_PR =
|
|
104172
|
+
var VERSION2 = "0.21.1";
|
|
104173
|
+
var COMMIT_SHA = "6aef7b24";
|
|
104174
|
+
var COMMIT_DATE = "2026-08-10T02:48:34Z";
|
|
104175
|
+
var LATEST_PR = 4583;
|
|
104030
104176
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
104031
104177
|
|
|
104032
104178
|
// gateway/boot-version.ts
|
|
@@ -117251,6 +117397,7 @@ async function initGatewayBot() {
|
|
|
117251
117397
|
bot = new import_grammy17.Bot(TOKEN);
|
|
117252
117398
|
installTgPostLogger(bot);
|
|
117253
117399
|
installRichMarkdownGuard(bot);
|
|
117400
|
+
installSentTextCapture(bot);
|
|
117254
117401
|
installEditFloodFuse(bot, {
|
|
117255
117402
|
...editFloodFuseConfigFromEnv(process.env),
|
|
117256
117403
|
onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}
|
|
@@ -303,6 +303,7 @@ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-
|
|
|
303
303
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
304
304
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
305
305
|
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
306
|
+
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
306
307
|
import {
|
|
307
308
|
floodStatePath,
|
|
308
309
|
floodWindowsPath,
|
|
@@ -5608,6 +5609,8 @@ const rawRobustApiCall = createRetryApiCall({
|
|
|
5608
5609
|
// system-message-observer.ts for the send-vs-edit and throttling contract.
|
|
5609
5610
|
// Gated on the SAME condition as `initHistory` above — a non-main gateway
|
|
5610
5611
|
// process never opens the DB, so an observer there would be pure noise.
|
|
5612
|
+
// The empty-body alarm (#4576) is the observer's own default — see
|
|
5613
|
+
// `defaultEmptyCardTextWarning` in system-message-observer.ts.
|
|
5611
5614
|
const observeSentMessage = isGatewayMain && HISTORY_ENABLED
|
|
5612
5615
|
? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
|
|
5613
5616
|
: undefined
|
|
@@ -22877,6 +22880,11 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22877
22880
|
|
|
22878
22881
|
bot = new Bot(TOKEN)
|
|
22879
22882
|
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22883
|
+
// #4576 follow-up: FALLBACK card body. The observer takes the stored body off
|
|
22884
|
+
// the RESPONSE (`rich_message` → `text`/`caption`); this stamps the REQUEST body
|
|
22885
|
+
// on the resolved Message for the shapes a response can't supply. After the fmt
|
|
22886
|
+
// guard so it composes OUTSIDE it. See sent-text-capture.ts.
|
|
22887
|
+
installSentTextCapture(bot)
|
|
22880
22888
|
// #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
|
|
22881
22889
|
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22882
22890
|
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
@@ -29,12 +29,34 @@
|
|
|
29
29
|
* card send in the gateway is routed through it, enforced by the
|
|
30
30
|
* `check-bot-api-wrapping` lint guard.
|
|
31
31
|
*
|
|
32
|
-
* The observer reads the Telegram RESPONSE,
|
|
33
|
-
* things for free:
|
|
32
|
+
* The observer reads the Telegram RESPONSE, which buys three things for free:
|
|
34
33
|
* - the real `message_id` (the only thing a reply can point at),
|
|
35
34
|
* - the chat and forum-topic the message actually landed in,
|
|
36
|
-
* - the
|
|
37
|
-
*
|
|
35
|
+
* - the BODY, as Telegram RENDERED it.
|
|
36
|
+
*
|
|
37
|
+
* The #4576 bug was reading only ONE body field off the response. Every card
|
|
38
|
+
* goes out via Bot API 10.1 `sendRichMessage`, and the `Message` it returns is
|
|
39
|
+
* a `Message.RichMessageMessage`: the body lives under `rich_message.blocks`
|
|
40
|
+
* ("Content of the message" — Bot API `RichMessage`), and `text` / `caption`
|
|
41
|
+
* are absent. `extractSentMessage` read `msg.text ?? msg.caption ?? ''` and so
|
|
42
|
+
* stored `''` on 100% of the rows on every agent in the fleet — the agent could
|
|
43
|
+
* see WHICH card was quote-replied to but never WHAT IT SAID. The fix is to
|
|
44
|
+
* read `rich_message` too, flattened by the same renderer the inbound
|
|
45
|
+
* rich-message handler uses.
|
|
46
|
+
*
|
|
47
|
+
* Preferring the response is not just simpler, it is more FAITHFUL. The
|
|
48
|
+
* request-side body for the dominant card path has already been through
|
|
49
|
+
* `richMessage()`'s `guardAccidentalFormatting` (`rich-send.ts`), which is
|
|
50
|
+
* applied in the CALLER, so `sent_text_capture.ts` is on the wire as
|
|
51
|
+
* `sent\_text\_capture.ts` and `$12.40` as `\$12.40`. The response's
|
|
52
|
+
* `rich_message` is the parsed, rendered block tree with those escapes already
|
|
53
|
+
* resolved — i.e. what the operator actually saw on screen, which is exactly
|
|
54
|
+
* what a quote-reply antecedent should say.
|
|
55
|
+
*
|
|
56
|
+
* The request-side stamp (`shared/sent-text-capture.ts`) is kept, LAST in the
|
|
57
|
+
* precedence order, as the fallback for the shapes a response cannot supply —
|
|
58
|
+
* a rich send whose blocks render to nothing (a media-only card), or a future
|
|
59
|
+
* verb whose response omits the body. It is never preferred over the response.
|
|
38
60
|
*
|
|
39
61
|
* Send vs edit is NOT guessed from the verb (verb tagging is not uniform
|
|
40
62
|
* across call sites and would rot). It falls out of the data: an edit returns
|
|
@@ -55,6 +77,9 @@
|
|
|
55
77
|
* is observing.
|
|
56
78
|
*/
|
|
57
79
|
|
|
80
|
+
import { readSentText } from '../shared/sent-text-capture.js'
|
|
81
|
+
import { extractRichMessageText } from './rich-message-handler.js'
|
|
82
|
+
|
|
58
83
|
/** The subset of a Telegram `Message` response this observer reads. */
|
|
59
84
|
export interface SentMessageLike {
|
|
60
85
|
message_id?: unknown
|
|
@@ -62,6 +87,7 @@ export interface SentMessageLike {
|
|
|
62
87
|
message_thread_id?: unknown
|
|
63
88
|
text?: unknown
|
|
64
89
|
caption?: unknown
|
|
90
|
+
rich_message?: unknown
|
|
65
91
|
}
|
|
66
92
|
|
|
67
93
|
/** The subset of `robustApiCall`'s opts the observer reads. */
|
|
@@ -91,6 +117,23 @@ export interface SystemMessageObserverDeps {
|
|
|
91
117
|
updateText: (args: { chat_id: string; message_id: number; text: string }) => boolean
|
|
92
118
|
/** Injectable clock for the edit throttle. Defaults to `Date.now`. */
|
|
93
119
|
now?: () => number
|
|
120
|
+
/**
|
|
121
|
+
* Called when a card row is about to be written with an EMPTY body — i.e.
|
|
122
|
+
* neither the response nor the request-side stamp yielded anything readable.
|
|
123
|
+
*
|
|
124
|
+
* This is the alarm for the #4576 failure mode. That bug was silent for a
|
|
125
|
+
* whole release precisely because an empty body is indistinguishable from a
|
|
126
|
+
* healthy row unless someone queries `length(text)`. Fired at most ONCE per
|
|
127
|
+
* `kind` (else per raw verb) per process so a broken verb is loud in the log
|
|
128
|
+
* without becoming a per-send stderr storm. Never called with a non-empty
|
|
129
|
+
* body, and never for a response that is legitimately bodiless
|
|
130
|
+
* (`isLegitimatelyBodiless`) — an alarm on `sendSticker` is noise a reader
|
|
131
|
+
* cannot act on, and noise is what teaches people to ignore the alarm.
|
|
132
|
+
*
|
|
133
|
+
* Defaults to `defaultEmptyCardTextWarning` (stderr). Pass an explicit
|
|
134
|
+
* function to redirect it, or `() => {}` to silence it in a test.
|
|
135
|
+
*/
|
|
136
|
+
onEmptyText?: (info: { chat_id: string; message_id: number; kind: string | null }) => void
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
export interface SystemMessageObserverOptions {
|
|
@@ -133,7 +176,20 @@ export function normalizeSendVerb(verb: string | undefined | null): string | nul
|
|
|
133
176
|
*
|
|
134
177
|
* The response's own `chat.id` wins over the caller's `chat_id` opt: it is what
|
|
135
178
|
* Telegram actually delivered to, and several call sites pass no `chat_id` at
|
|
136
|
-
* all.
|
|
179
|
+
* all.
|
|
180
|
+
*
|
|
181
|
+
* `text` is resolved in strict precedence order, most authoritative first:
|
|
182
|
+
* 1. `rich_message` on the RESPONSE, flattened by the same renderer the
|
|
183
|
+
* inbound rich-message handler uses. This is Telegram's own rendering of
|
|
184
|
+
* the block tree, so markdown escapes are already resolved — it is what
|
|
185
|
+
* the operator saw, and it is the shape every card send returns;
|
|
186
|
+
* 2. `text` / `caption`, the plain-send and media-caption response shapes;
|
|
187
|
+
* 3. the body stamped by `installSentTextCapture` from the outbound REQUEST —
|
|
188
|
+
* LAST, because on the dominant card path (`richMessage(body)`) it is the
|
|
189
|
+
* guard-escaped wire form, not the body as written. It is the fallback for
|
|
190
|
+
* responses that carry no renderable body at all.
|
|
191
|
+
* `''` only when all three are absent, which the observer treats as an alarm
|
|
192
|
+
* unless the response is legitimately bodiless. Pure.
|
|
137
193
|
*/
|
|
138
194
|
export function extractSentMessage(
|
|
139
195
|
result: unknown,
|
|
@@ -157,13 +213,83 @@ export function extractSentMessage(
|
|
|
157
213
|
? opts.threadId
|
|
158
214
|
: null
|
|
159
215
|
const text =
|
|
160
|
-
|
|
216
|
+
(msg.rich_message != null ? extractRichMessageText(msg.rich_message) : undefined) ??
|
|
217
|
+
nonEmptyString(msg.text) ??
|
|
218
|
+
nonEmptyString(msg.caption) ??
|
|
219
|
+
readSentText(result) ??
|
|
220
|
+
''
|
|
161
221
|
return { chatId, messageId: id, threadId, text }
|
|
162
222
|
}
|
|
163
223
|
|
|
224
|
+
/** `v` when it is a non-empty string, else undefined — so an empty `text` on
|
|
225
|
+
* the response falls THROUGH to the next precedence tier instead of pinning
|
|
226
|
+
* the result to `''`. */
|
|
227
|
+
function nonEmptyString(v: unknown): string | undefined {
|
|
228
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Response keys that mark a Telegram `Message` as LEGITIMATELY bodiless: the
|
|
233
|
+
* send verb that produced it has no user-visible text by construction.
|
|
234
|
+
*
|
|
235
|
+
* The empty-body alarm exists to make a recurrence of #4576 loud. It is only
|
|
236
|
+
* useful if it fires on a REGRESSION, so the verbs that are *supposed* to
|
|
237
|
+
* store an empty body — `sendSticker`, `sendAnimation`, `sendVoice`,
|
|
238
|
+
* `forwardMessage` of a media message, an uncaptioned `sendPhoto` — must not
|
|
239
|
+
* emit an alarm indistinguishable from one.
|
|
240
|
+
*
|
|
241
|
+
* Keyed on the RESPONSE SHAPE rather than on `opts.verb` deliberately: verb
|
|
242
|
+
* tagging is not uniform across call sites (`forwardMessage`,
|
|
243
|
+
* `gateway.ts`, passes none at all), so a verb allowlist would rot exactly
|
|
244
|
+
* where this needs to hold. A regressed CARD is a `rich_message` response
|
|
245
|
+
* whose blocks rendered to nothing — it carries none of these keys and still
|
|
246
|
+
* alarms. Pure.
|
|
247
|
+
*/
|
|
248
|
+
const BODILESS_MESSAGE_KEYS = [
|
|
249
|
+
'sticker', 'animation', 'voice', 'video_note', 'dice', 'game', 'poll',
|
|
250
|
+
'contact', 'location', 'venue', 'story', 'invoice', 'successful_payment',
|
|
251
|
+
'checklist', 'photo', 'video', 'audio', 'document', 'paid_media',
|
|
252
|
+
] as const
|
|
253
|
+
|
|
254
|
+
export function isLegitimatelyBodiless(result: unknown): boolean {
|
|
255
|
+
if (result == null || typeof result !== 'object') return false
|
|
256
|
+
const r = result as Record<string, unknown>
|
|
257
|
+
return BODILESS_MESSAGE_KEYS.some((k) => r[k] != null)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The observer's DEFAULT empty-body alarm: one stderr line naming the card kind
|
|
262
|
+
* whose text capture missed.
|
|
263
|
+
*
|
|
264
|
+
* It lives here, and is the default rather than something gateway.ts wires,
|
|
265
|
+
* for two reasons: gateway.ts is under an anti-inflation line ratchet
|
|
266
|
+
* (switchroom#2996) so new logic belongs in a module; and a caller that forgets
|
|
267
|
+
* to pass `onEmptyText` is exactly the caller that would re-ship #4576
|
|
268
|
+
* silently. Opting OUT is now the explicit act.
|
|
269
|
+
*/
|
|
270
|
+
export function defaultEmptyCardTextWarning(info: {
|
|
271
|
+
chat_id: string
|
|
272
|
+
message_id: number
|
|
273
|
+
kind: string | null
|
|
274
|
+
}): void {
|
|
275
|
+
try {
|
|
276
|
+
process.stderr.write(
|
|
277
|
+
`telegram gateway: card-history text capture MISSED kind=${info.kind ?? '-'} ` +
|
|
278
|
+
`chat=${info.chat_id} id=${info.message_id} — the response carried no ` +
|
|
279
|
+
`rich_message/text/caption and no request-side body was stamped, so a ` +
|
|
280
|
+
`quote-reply to this card will resolve its kind but not its body ` +
|
|
281
|
+
`(see gateway/system-message-observer.ts extractSentMessage)\n`,
|
|
282
|
+
)
|
|
283
|
+
} catch {
|
|
284
|
+
/* a broken stderr must never break the send */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
164
288
|
/** Per-id bookkeeping. `foreign` = the id belongs to a non-system row (a real
|
|
165
|
-
* reply or an inbound); never write to it again.
|
|
166
|
-
|
|
289
|
+
* reply or an inbound); never write to it again. `storedLen` is the length of
|
|
290
|
+
* the body currently in the row — 0 means the row is a HOLE, which the edit
|
|
291
|
+
* throttle must not preserve. */
|
|
292
|
+
type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number; storedLen: number }
|
|
167
293
|
|
|
168
294
|
/**
|
|
169
295
|
* Build the observer. The returned function is called with the RESOLVED result
|
|
@@ -177,6 +303,30 @@ export function makeSystemMessageObserver(
|
|
|
177
303
|
const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS
|
|
178
304
|
const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED)
|
|
179
305
|
const tracked = new Map<string, TrackedState>()
|
|
306
|
+
/** Kinds already reported through `onEmptyText` — one alarm per kind, per process. */
|
|
307
|
+
const emptyReported = new Set<string>()
|
|
308
|
+
const onEmptyText = deps.onEmptyText ?? defaultEmptyCardTextWarning
|
|
309
|
+
|
|
310
|
+
function reportEmpty(
|
|
311
|
+
chatId: string,
|
|
312
|
+
messageId: number,
|
|
313
|
+
kind: string | null,
|
|
314
|
+
verb: string | undefined,
|
|
315
|
+
): void {
|
|
316
|
+
// Bucket on the kind, else the RAW verb, else the untagged catch-all. Using
|
|
317
|
+
// `kind ?? '<untagged>'` alone collapsed every untagged verb into one
|
|
318
|
+
// bucket, so the first bodiless untagged send in the process permanently
|
|
319
|
+
// silenced the alarm for every other untagged verb — including a real
|
|
320
|
+
// regression.
|
|
321
|
+
const bucket = kind ?? (typeof verb === 'string' && verb.length > 0 ? verb : '<untagged>')
|
|
322
|
+
if (emptyReported.has(bucket)) return
|
|
323
|
+
emptyReported.add(bucket)
|
|
324
|
+
try {
|
|
325
|
+
onEmptyText({ chat_id: chatId, message_id: messageId, kind })
|
|
326
|
+
} catch {
|
|
327
|
+
/* the alarm must never break the send either */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
180
330
|
|
|
181
331
|
function remember(key: string, state: TrackedState): void {
|
|
182
332
|
tracked.set(key, state)
|
|
@@ -200,11 +350,30 @@ export function makeSystemMessageObserver(
|
|
|
200
350
|
const t = now()
|
|
201
351
|
const seen = tracked.get(key)
|
|
202
352
|
|
|
353
|
+
const kind = normalizeSendVerb(opts?.verb)
|
|
354
|
+
if (sent.text.length === 0 && !isLegitimatelyBodiless(result)) {
|
|
355
|
+
reportEmpty(sent.chatId, sent.messageId, kind, opts?.verb)
|
|
356
|
+
}
|
|
357
|
+
|
|
203
358
|
if (seen != null) {
|
|
204
359
|
if (seen.lane === 'foreign') return
|
|
205
|
-
|
|
360
|
+
// Never blank a body we already stored. A refresh whose text did not
|
|
361
|
+
// reach us is missing information, not new information — overwriting
|
|
362
|
+
// with `''` would destroy a usable quote-reply antecedent and hand the
|
|
363
|
+
// agent the #4576 symptom on a row that was healthy a moment ago.
|
|
364
|
+
if (sent.text.length === 0) return
|
|
365
|
+
// The dual rule, and the one whose absence kept the #4576 symptom alive
|
|
366
|
+
// on a row the alarm had already given up on: ALWAYS fill an EMPTY
|
|
367
|
+
// stored body. A bodiless first observation (a media-only card, a
|
|
368
|
+
// response we could not read) inserts `''` and starts the throttle
|
|
369
|
+
// clock; the first REAL body then lands inside the 20s window and was
|
|
370
|
+
// dropped, leaving the row permanently unusable as a quote-reply
|
|
371
|
+
// antecedent. The throttle exists to cap SQLite writes on a card that
|
|
372
|
+
// is already READABLE, so it only applies once something is stored.
|
|
373
|
+
if (seen.storedLen > 0 && t - seen.lastStoredMs < editRefreshMs) return
|
|
206
374
|
if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
|
|
207
375
|
seen.lastStoredMs = t
|
|
376
|
+
seen.storedLen = sent.text.length
|
|
208
377
|
} else {
|
|
209
378
|
// The row is gone (retention prune / delete) or was promoted to a
|
|
210
379
|
// real `assistant` reply by recordOutbound. Either way this id is no
|
|
@@ -218,23 +387,30 @@ export function makeSystemMessageObserver(
|
|
|
218
387
|
chat_id: sent.chatId,
|
|
219
388
|
thread_id: sent.threadId,
|
|
220
389
|
message_id: sent.messageId,
|
|
221
|
-
kind
|
|
390
|
+
kind,
|
|
222
391
|
text: sent.text,
|
|
223
392
|
})
|
|
224
393
|
if (inserted) {
|
|
225
|
-
remember(key, { lane: 'system', lastStoredMs: t })
|
|
394
|
+
remember(key, { lane: 'system', lastStoredMs: t, storedLen: sent.text.length })
|
|
226
395
|
return
|
|
227
396
|
}
|
|
228
397
|
// A row already exists for this id and we did not create it in this
|
|
229
398
|
// process: either a real reply / inbound (leave it alone), or a card this
|
|
230
399
|
// gateway posted before a restart. One probing update disambiguates —
|
|
231
|
-
// `updateText` only ever matches a `system` row.
|
|
400
|
+
// `updateText` only ever matches a `system` row. The probe WRITES, so an
|
|
401
|
+
// empty body cannot be used to run it: skip, stay untracked, and let the
|
|
402
|
+
// next observation of this id (which carries a body) do the probing.
|
|
403
|
+
if (sent.text.length === 0) return
|
|
232
404
|
const refreshed = deps.updateText({
|
|
233
405
|
chat_id: sent.chatId,
|
|
234
406
|
message_id: sent.messageId,
|
|
235
407
|
text: sent.text,
|
|
236
408
|
})
|
|
237
|
-
remember(key, {
|
|
409
|
+
remember(key, {
|
|
410
|
+
lane: refreshed ? 'system' : 'foreign',
|
|
411
|
+
lastStoredMs: t,
|
|
412
|
+
storedLen: refreshed ? sent.text.length : 0,
|
|
413
|
+
})
|
|
238
414
|
} catch {
|
|
239
415
|
/* observing a send must never break the send */
|
|
240
416
|
}
|