switchroom 0.21.4 → 0.21.6
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 +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +50 -23
- package/telegram-plugin/gateway/gateway.ts +21 -21
- package/telegram-plugin/gateway/inbound-router.ts +77 -26
- package/telegram-plugin/gateway/system-message-observer.ts +25 -6
- package/telegram-plugin/shared/bot-runtime.ts +119 -2
- package/telegram-plugin/tests/card-history-lane.test.ts +171 -2
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +16 -8
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +209 -4
- package/telegram-plugin/tests/system-message-observer.test.ts +84 -1
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.6", COMMIT_SHA = "ffc152ca";
|
|
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.6";
|
|
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.6",
|
|
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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"test:vitest": "vitest run",
|
|
29
29
|
"test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
|
|
30
30
|
"test:watch": "vitest",
|
|
31
|
-
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
|
|
31
|
+
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
|
|
32
32
|
"lint:tsc": "tsc --noEmit",
|
|
33
33
|
"lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
|
|
34
34
|
"lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"lint:gateway-line-ratchet": "node scripts/check-gateway-line-ratchet.mjs",
|
|
47
47
|
"lint:retry-flood-hooks": "node scripts/check-retry-flood-hooks.mjs",
|
|
48
48
|
"lint:callback-ctx-wrapping": "node scripts/check-callback-ctx-wrapping.mjs",
|
|
49
|
+
"lint:ctx-send-wrapping": "node scripts/check-ctx-send-wrapping.mjs",
|
|
49
50
|
"lint:status-pin-single-path": "node scripts/check-status-pin-single-path.mjs",
|
|
50
51
|
"lint:mcp-instructions-budget": "node scripts/check-mcp-instructions-budget.mjs",
|
|
51
52
|
"lint:litellm-config-guard": "node scripts/check-litellm-config-guard.mjs",
|
|
@@ -44729,6 +44729,7 @@ function shouldEmitShadowTrace(eventKind, effectCount, globalKind, verbose = gwT
|
|
|
44729
44729
|
// shared/bot-runtime.ts
|
|
44730
44730
|
init_rich_send();
|
|
44731
44731
|
var tgPostTagStore = new AsyncLocalStorage2;
|
|
44732
|
+
var sendContextStore = new AsyncLocalStorage2;
|
|
44732
44733
|
function escapeHtmlForTg(text) {
|
|
44733
44734
|
return text.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
44734
44735
|
}
|
|
@@ -45728,7 +45729,8 @@ function buildReplyForwardContext(p) {
|
|
|
45728
45729
|
const replyToMsg = p.ctx.message?.reply_to_message;
|
|
45729
45730
|
const replyToMessageId = replyToMsg?.message_id;
|
|
45730
45731
|
const quoteText = p.ctx.message?.quote?.text;
|
|
45731
|
-
const
|
|
45732
|
+
const richParentText = extractRichMessageText(replyToMsg?.rich_message);
|
|
45733
|
+
const replyToTextRaw = quoteText != null && quoteText.length > 0 ? quoteText : replyToMsg ? replyToMsg.text ?? replyToMsg.caption ?? richParentText ?? undefined : undefined;
|
|
45732
45734
|
const replyToText = replyToTextRaw != null ? replyToTextRaw.length > p.replyToTextMax ? replyToTextRaw.slice(0, p.replyToTextMax - 1) + "\u2026" : replyToTextRaw : undefined;
|
|
45733
45735
|
const replyToTextEscaped = formatReplyToText(replyToTextRaw, p.replyToTextMax);
|
|
45734
45736
|
const forwardOrigins = p.coalescedForwardOrigins ?? dedupeForwardOrigins2([parseForwardOrigin2(p.ctx.message?.forward_origin)]);
|
|
@@ -45749,18 +45751,18 @@ function resolveReplyToFromBuffer(p) {
|
|
|
45749
45751
|
let replyToRole;
|
|
45750
45752
|
let replyToKind;
|
|
45751
45753
|
const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0;
|
|
45752
|
-
if (p.historyEnabled && p.replyToMessageId != null
|
|
45754
|
+
if (p.historyEnabled && p.replyToMessageId != null) {
|
|
45753
45755
|
try {
|
|
45754
45756
|
const recovered = p.lookup(p.replyToMessageId);
|
|
45755
|
-
if (recovered != null
|
|
45756
|
-
replyToKind = recovered.kind;
|
|
45757
|
-
}
|
|
45758
|
-
if (recovered && recovered.text.length > 0) {
|
|
45759
|
-
replyToText = recovered.text.length > p.replyToTextMax ? recovered.text.slice(0, p.replyToTextMax - 1) + "\u2026" : recovered.text;
|
|
45760
|
-
replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax);
|
|
45761
|
-
replyToRole = recovered.role;
|
|
45762
|
-
} else if (recovered) {
|
|
45757
|
+
if (recovered != null) {
|
|
45763
45758
|
replyToRole = recovered.role;
|
|
45759
|
+
if (recovered.role === "system" && recovered.kind) {
|
|
45760
|
+
replyToKind = recovered.kind;
|
|
45761
|
+
}
|
|
45762
|
+
if (liveTextEmpty && recovered.text.length > 0) {
|
|
45763
|
+
replyToText = recovered.text.length > p.replyToTextMax ? recovered.text.slice(0, p.replyToTextMax - 1) + "\u2026" : recovered.text;
|
|
45764
|
+
replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax);
|
|
45765
|
+
}
|
|
45764
45766
|
}
|
|
45765
45767
|
} catch {}
|
|
45766
45768
|
}
|
|
@@ -67220,6 +67222,37 @@ function installRichMarkdownGuard(bot) {
|
|
|
67220
67222
|
return prev(method, payload, signal);
|
|
67221
67223
|
});
|
|
67222
67224
|
}
|
|
67225
|
+
var sendContextStore2 = new AsyncLocalStorage5;
|
|
67226
|
+
function withTgSendContext(ctx, fn) {
|
|
67227
|
+
return sendContextStore2.run(ctx, fn);
|
|
67228
|
+
}
|
|
67229
|
+
function installSystemMessageObserver(bot, observe) {
|
|
67230
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
67231
|
+
const res = await prev(method, payload, signal);
|
|
67232
|
+
try {
|
|
67233
|
+
const r = res;
|
|
67234
|
+
if (r != null && typeof r === "object" && r.ok === true) {
|
|
67235
|
+
observe(r.result, resolveSendContext(payload));
|
|
67236
|
+
}
|
|
67237
|
+
} catch {}
|
|
67238
|
+
return res;
|
|
67239
|
+
});
|
|
67240
|
+
}
|
|
67241
|
+
function resolveSendContext(payload) {
|
|
67242
|
+
const ctx = sendContextStore2.getStore();
|
|
67243
|
+
const p = payload ?? {};
|
|
67244
|
+
const rawChat = p.chat_id;
|
|
67245
|
+
const chat_id = ctx?.chat_id ?? (typeof rawChat === "string" || typeof rawChat === "number" ? String(rawChat) : undefined);
|
|
67246
|
+
const rawThread = p.message_thread_id;
|
|
67247
|
+
const threadId = ctx?.threadId ?? (typeof rawThread === "number" ? rawThread : undefined);
|
|
67248
|
+
if (chat_id == null && threadId == null && ctx?.verb == null)
|
|
67249
|
+
return;
|
|
67250
|
+
return {
|
|
67251
|
+
...chat_id != null ? { chat_id } : {},
|
|
67252
|
+
...threadId != null ? { threadId } : {},
|
|
67253
|
+
...ctx?.verb != null ? { verb: ctx.verb } : {}
|
|
67254
|
+
};
|
|
67255
|
+
}
|
|
67223
67256
|
|
|
67224
67257
|
// shared/sent-text-capture.ts
|
|
67225
67258
|
var SENT_TEXT = Symbol.for("switchroom.telegram.sentText");
|
|
@@ -104434,10 +104467,10 @@ function startOutboxSweep(deps) {
|
|
|
104434
104467
|
}
|
|
104435
104468
|
|
|
104436
104469
|
// ../src/build-info.ts
|
|
104437
|
-
var VERSION2 = "0.21.
|
|
104438
|
-
var COMMIT_SHA = "
|
|
104439
|
-
var COMMIT_DATE = "2026-08-
|
|
104440
|
-
var LATEST_PR =
|
|
104470
|
+
var VERSION2 = "0.21.6";
|
|
104471
|
+
var COMMIT_SHA = "ffc152ca";
|
|
104472
|
+
var COMMIT_DATE = "2026-08-11T03:22:29Z";
|
|
104473
|
+
var LATEST_PR = 4605;
|
|
104441
104474
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
104442
104475
|
|
|
104443
104476
|
// gateway/boot-version.ts
|
|
@@ -108611,15 +108644,7 @@ var rawRobustApiCall = createRetryApiCall2({
|
|
|
108611
108644
|
floodWaitRemainingMs: probeFloodWaitRemainingMs
|
|
108612
108645
|
});
|
|
108613
108646
|
var observeSentMessage = isGatewayMain && HISTORY_ENABLED ? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText }) : undefined;
|
|
108614
|
-
var robustApiCall = (fn, opts) =>
|
|
108615
|
-
const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts);
|
|
108616
|
-
if (observeSentMessage == null)
|
|
108617
|
-
return p;
|
|
108618
|
-
return p.then((res) => {
|
|
108619
|
-
observeSentMessage(res, opts);
|
|
108620
|
-
return res;
|
|
108621
|
-
});
|
|
108622
|
-
};
|
|
108647
|
+
var robustApiCall = (fn, opts) => sendGate.gate(() => withTgSendContext(opts, () => rawRobustApiCall(fn, opts)), opts);
|
|
108623
108648
|
var swallowingApiCall = createSwallowingRetryApiCall(robustApiCall, (line) => process.stderr.write(line));
|
|
108624
108649
|
var resetPrivacyForNewSession = makePrivacyResetForNewSession((chatId, threadId, text5) => void swallowingApiCall(() => lockedBot.api.sendMessage(chatId, text5, threadId != null ? { message_thread_id: threadId, disable_notification: false } : { disable_notification: false }), { chat_id: chatId, verb: "privacy-reset-alert", priorityClass: "critical" }));
|
|
108625
108650
|
var gatedSetMessageReaction = (chatId, messageId, reaction) => robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
|
|
@@ -117665,6 +117690,8 @@ async function initGatewayBot() {
|
|
|
117665
117690
|
installTgPostLogger(bot);
|
|
117666
117691
|
installRichMarkdownGuard(bot);
|
|
117667
117692
|
installSentTextCapture(bot);
|
|
117693
|
+
if (observeSentMessage != null)
|
|
117694
|
+
installSystemMessageObserver(bot, observeSentMessage);
|
|
117668
117695
|
installEditFloodFuse(bot, {
|
|
117669
117696
|
...editFloodFuseConfigFromEnv(process.env),
|
|
117670
117697
|
onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}
|
|
@@ -302,7 +302,7 @@ import {
|
|
|
302
302
|
import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-fuse.js'
|
|
303
303
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
304
304
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
305
|
-
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
305
|
+
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags, withTgSendContext, installSystemMessageObserver } from '../shared/bot-runtime.js'
|
|
306
306
|
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
307
307
|
import {
|
|
308
308
|
floodStatePath,
|
|
@@ -5603,28 +5603,25 @@ const rawRobustApiCall = createRetryApiCall({
|
|
|
5603
5603
|
floodWaitRemainingMs: probeFloodWaitRemainingMs,
|
|
5604
5604
|
})
|
|
5605
5605
|
|
|
5606
|
-
// #4571 — card/system-surface history lane
|
|
5607
|
-
//
|
|
5608
|
-
//
|
|
5609
|
-
//
|
|
5610
|
-
//
|
|
5611
|
-
// system-message-observer.ts for the send-vs-edit
|
|
5612
|
-
// Gated
|
|
5613
|
-
// process never opens the DB, so an observer there would be pure noise.
|
|
5614
|
-
// The empty-body alarm (#4576) is the observer's own default — see
|
|
5615
|
-
// `defaultEmptyCardTextWarning` in system-message-observer.ts.
|
|
5606
|
+
// #4571 — card/system-surface history lane: every card the gateway posts must leave
|
|
5607
|
+
// a row so its id resolves when the operator quote-replies to it. #4599 moved the
|
|
5608
|
+
// hook off `robustApiCall` (which the `ctx.replyWithRichMessage` slash-command card
|
|
5609
|
+
// path bypasses entirely) onto the grammy transformer layer — see
|
|
5610
|
+
// `installSystemMessageObserver`, wired at bot construction, and
|
|
5611
|
+
// system-message-observer.ts for the send-vs-edit / throttling / alarm contract.
|
|
5612
|
+
// Gated as `initHistory` is: a non-main gateway never opens the DB.
|
|
5616
5613
|
const observeSentMessage = isGatewayMain && HISTORY_ENABLED
|
|
5617
5614
|
? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
|
|
5618
5615
|
: undefined
|
|
5619
5616
|
|
|
5617
|
+
// `withTgSendContext` publishes {chat_id, threadId, verb} down to the transformer layer
|
|
5618
|
+
// where the observer runs — how a card keeps its `kind`. Sends outside this wrapper
|
|
5619
|
+
// still record, just without a verb.
|
|
5620
5620
|
const robustApiCall = <T>(
|
|
5621
5621
|
fn: () => Promise<T>,
|
|
5622
5622
|
opts?: Parameters<typeof rawRobustApiCall<T>>[1],
|
|
5623
|
-
): Promise<T> =>
|
|
5624
|
-
|
|
5625
|
-
if (observeSentMessage == null) return p
|
|
5626
|
-
return p.then((res) => { observeSentMessage(res, opts); return res })
|
|
5627
|
-
}
|
|
5623
|
+
): Promise<T> =>
|
|
5624
|
+
sendGate.gate(() => withTgSendContext(opts, () => rawRobustApiCall(fn, opts)), opts)
|
|
5628
5625
|
|
|
5629
5626
|
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
5630
5627
|
// `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
|
|
@@ -15061,11 +15058,11 @@ export async function handleInbound(
|
|
|
15061
15058
|
} = replyForwardCtx
|
|
15062
15059
|
|
|
15063
15060
|
// Reply-to buffer fallback (post-reset continuity, resolveReplyToFromBuffer).
|
|
15064
|
-
// On a native reply to
|
|
15065
|
-
// reply_to_message.message_id but NOT its .text
|
|
15066
|
-
//
|
|
15067
|
-
//
|
|
15068
|
-
//
|
|
15061
|
+
// On a native reply to a PLAIN bot-sent message, Telegram delivers
|
|
15062
|
+
// reply_to_message.message_id but NOT its .text (a RICH parent — every card —
|
|
15063
|
+
// carries rich_message and is resolved LIVE upstream since #4598), so the live
|
|
15064
|
+
// reply text is empty even though we persisted it (role='assistant', 30-day
|
|
15065
|
+
// retention). Recover it so it survives a reset. Fills replyToText (raw, so the
|
|
15069
15066
|
// recordInbound write below persists it — envelope-only would leave the row
|
|
15070
15067
|
// NULL and starve future briefings), replyToTextEscaped (channel meta), and
|
|
15071
15068
|
// replyToRole (the reply_to_role attribute). Only when the live text is
|
|
@@ -22887,6 +22884,9 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22887
22884
|
// on the resolved Message for the shapes a response can't supply. After the fmt
|
|
22888
22885
|
// guard so it composes OUTSIDE it. See sent-text-capture.ts.
|
|
22889
22886
|
installSentTextCapture(bot)
|
|
22887
|
+
// #4599 card-history lane: AFTER sent-text-capture so it composes outside it and
|
|
22888
|
+
// can read the request-side stamp. The seam `ctx.replyWithRichMessage` can't bypass.
|
|
22889
|
+
if (observeSentMessage != null) installSystemMessageObserver(bot, observeSentMessage)
|
|
22890
22890
|
// #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
|
|
22891
22891
|
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22892
22892
|
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './inbound-interceptors.js'
|
|
36
36
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
37
37
|
import { deriveTurnId } from './derive-turn-id.js'
|
|
38
|
+
import { extractRichMessageText } from './rich-message-handler.js'
|
|
38
39
|
import { formatReplyToText } from '../steering.js'
|
|
39
40
|
import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
|
|
40
41
|
import { safeResolvePersonName, type PersonDirectory } from './resolve-person.js'
|
|
@@ -160,10 +161,33 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
|
160
161
|
// gateway runs when neither is present. Accessed defensively in case the
|
|
161
162
|
// installed grammy/@grammyjs/types predate `TextQuote`.
|
|
162
163
|
const quoteText = p.ctx.message?.quote?.text
|
|
164
|
+
// Rich-message parents (#4598). Every card the gateway posts goes out via
|
|
165
|
+
// Bot API 10.1 `sendRichMessage`, so a native reply to one delivers a
|
|
166
|
+
// `reply_to_message` whose body lives under `rich_message.blocks` with
|
|
167
|
+
// `text` / `caption` ABSENT. Reading only `.text ?? .caption` therefore
|
|
168
|
+
// yielded `undefined` for 100% of card parents and pushed every such reply
|
|
169
|
+
// onto the history-buffer fallback — which cannot help when no row was ever
|
|
170
|
+
// recorded (a send while the gateway was down, or one that bypassed the
|
|
171
|
+
// recording chokepoint).
|
|
172
|
+
//
|
|
173
|
+
// Measured on the wire, not inferred: a user reply to a `sendRichMessage`
|
|
174
|
+
// card yields `reply_to_message` keys
|
|
175
|
+
// `[message_id, from, chat, date, rich_message]`, `text`/`caption` absent,
|
|
176
|
+
// `rich_message.blocks` fully populated. The older "Telegram omits the
|
|
177
|
+
// parent body" comments on this path predate rich messages.
|
|
178
|
+
//
|
|
179
|
+
// Flattened by the SAME renderer the inbound rich-message handler and the
|
|
180
|
+
// outbound card observer use, so all three agree on what a card "says".
|
|
181
|
+
// Returns `undefined` (never `''`) for an unrenderable block tree, so
|
|
182
|
+
// `resolveReplyToFromBuffer`'s `liveTextEmpty` gate still falls through to
|
|
183
|
+
// the buffer instead of pinning an empty antecedent.
|
|
184
|
+
const richParentText = extractRichMessageText(
|
|
185
|
+
(replyToMsg as { rich_message?: unknown } | undefined)?.rich_message,
|
|
186
|
+
)
|
|
163
187
|
const replyToTextRaw = (quoteText != null && quoteText.length > 0)
|
|
164
188
|
? quoteText
|
|
165
189
|
: replyToMsg
|
|
166
|
-
? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
|
|
190
|
+
? (replyToMsg.text ?? replyToMsg.caption ?? richParentText ?? undefined)
|
|
167
191
|
: undefined
|
|
168
192
|
const replyToText = replyToTextRaw != null
|
|
169
193
|
? (replyToTextRaw.length > p.replyToTextMax
|
|
@@ -198,8 +222,11 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
|
|
|
198
222
|
export interface ReplyToBufferFallbackParams {
|
|
199
223
|
/** From {@link buildReplyForwardContext}. */
|
|
200
224
|
replyToMessageId: number | undefined
|
|
201
|
-
/** Raw reply text off the live update (for the SQLite write)
|
|
202
|
-
*
|
|
225
|
+
/** Raw reply text off the live update (for the SQLite write). Empty only
|
|
226
|
+
* when the live update carried NO readable parent body at all — a plain
|
|
227
|
+
* `sendMessage` parent (Telegram omits `.text` on the bot's own plain
|
|
228
|
+
* messages), or a rich parent whose blocks render to nothing. A normal
|
|
229
|
+
* card parent now arrives populated off `rich_message` (#4598). */
|
|
203
230
|
replyToText: string | undefined
|
|
204
231
|
/** XML-escaped reply text for the channel meta; empty in the same case. */
|
|
205
232
|
replyToTextEscaped: string | undefined
|
|
@@ -219,21 +246,36 @@ export interface ReplyToBufferFallbackParams {
|
|
|
219
246
|
}
|
|
220
247
|
|
|
221
248
|
/**
|
|
222
|
-
* Reply-to buffer fallback (post-reset continuity). On a native reply to
|
|
223
|
-
*
|
|
224
|
-
* its `.text` — so the live reply text
|
|
225
|
-
* authored (and, via `recordOutbound`,
|
|
226
|
-
* the antecedent from the local history
|
|
227
|
-
* (`resume_mode: handoff`), where the
|
|
249
|
+
* Reply-to buffer fallback (post-reset continuity). On a native reply to a
|
|
250
|
+
* PLAIN message the bot itself sent, Telegram delivers
|
|
251
|
+
* `reply_to_message.message_id` but NOT its `.text` — so the live reply text
|
|
252
|
+
* is empty even though the gateway authored (and, via `recordOutbound`,
|
|
253
|
+
* persisted) that message. This recovers the antecedent from the local history
|
|
254
|
+
* buffer so it survives a session reset (`resume_mode: handoff`), where the
|
|
255
|
+
* transcript is gone.
|
|
256
|
+
*
|
|
257
|
+
* Since #4598 this is the SECOND line of defence FOR THE TEXT, not the first:
|
|
258
|
+
* a reply to a RICH parent (every card) now carries its body on
|
|
259
|
+
* `reply_to_message.rich_message` and is resolved live in
|
|
260
|
+
* {@link buildReplyForwardContext}, so a live-resolved body is never
|
|
261
|
+
* overwritten from the buffer.
|
|
262
|
+
*
|
|
263
|
+
* The LOOKUP itself, however, still runs on every reply while history is on.
|
|
264
|
+
* `replyToRole` and `replyToKind` have no live-update source at all, so
|
|
265
|
+
* skipping the lookup whenever the live text resolved would strip
|
|
266
|
+
* `reply_to_role` / `reply_to_kind` from precisely the recorded-card replies
|
|
267
|
+
* the #4571 kind lane was built for.
|
|
228
268
|
*
|
|
229
269
|
* Returns updated `replyToText` (raw, for the SQLite `recordInbound` write —
|
|
230
270
|
* envelope-only would leave the row NULL and starve future handoff briefings),
|
|
231
271
|
* `replyToTextEscaped` (for the channel-meta `reply_to_text`), and the
|
|
232
272
|
* recovered `replyToRole` ('assistant' = the bot's own message, disambiguating
|
|
233
273
|
* the "you're replying to yourself" case). Pure except for the injected
|
|
234
|
-
* `lookup`. Only fills in when the live reply text is empty — never
|
|
235
|
-
* a non-empty live value (a partial-quote
|
|
236
|
-
*
|
|
274
|
+
* `lookup`. Only fills in the TEXT when the live reply text is empty — never
|
|
275
|
+
* overwrites a non-empty live value (a partial-quote, a reply to a person's
|
|
276
|
+
* message, or a live-rendered rich parent). Role and kind are filled in from
|
|
277
|
+
* any buffer hit regardless. Degrades silently to the id-only inputs on any
|
|
278
|
+
* lookup failure.
|
|
237
279
|
*/
|
|
238
280
|
export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
239
281
|
replyToText: string | undefined
|
|
@@ -248,23 +290,32 @@ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
|
|
|
248
290
|
let replyToRole: 'user' | 'assistant' | 'system' | undefined
|
|
249
291
|
let replyToKind: string | undefined
|
|
250
292
|
const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
|
|
251
|
-
if (p.historyEnabled && p.replyToMessageId != null
|
|
293
|
+
if (p.historyEnabled && p.replyToMessageId != null) {
|
|
252
294
|
try {
|
|
253
295
|
const recovered = p.lookup(p.replyToMessageId)
|
|
254
|
-
if (recovered != null
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
replyToRole = recovered.role
|
|
264
|
-
} else if (recovered) {
|
|
265
|
-
// Row exists (authorship known) but text is empty/redacted — still
|
|
266
|
-
// surface the role so the model knows whose message it is replying to.
|
|
296
|
+
if (recovered != null) {
|
|
297
|
+
// Role and kind are produced ONLY here — there is no live-update
|
|
298
|
+
// source for either. So the lookup runs on EVERY reply, not just the
|
|
299
|
+
// ones whose text the live update failed to carry: gating it on
|
|
300
|
+
// `liveTextEmpty` would mean a full reply to a recorded card resolved
|
|
301
|
+
// its body live (#4598) and silently lost `reply_to_role="system"` +
|
|
302
|
+
// `reply_to_kind` (#4571) — exactly the case the kind lane exists for,
|
|
303
|
+
// and the thing #4599 goes to AsyncLocalStorage lengths to record.
|
|
304
|
+
// Costs one SQLite point-read per reply.
|
|
267
305
|
replyToRole = recovered.role
|
|
306
|
+
if (recovered.role === 'system' && recovered.kind) {
|
|
307
|
+
replyToKind = recovered.kind
|
|
308
|
+
}
|
|
309
|
+
// The TEXT is still second line of defence: never overwrite a
|
|
310
|
+
// non-empty live value (a partial quote, a person's message, or a
|
|
311
|
+
// rich parent rendered off the wire).
|
|
312
|
+
if (liveTextEmpty && recovered.text.length > 0) {
|
|
313
|
+
replyToText =
|
|
314
|
+
recovered.text.length > p.replyToTextMax
|
|
315
|
+
? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
|
|
316
|
+
: recovered.text
|
|
317
|
+
replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
|
|
318
|
+
}
|
|
268
319
|
}
|
|
269
320
|
} catch {
|
|
270
321
|
// History disabled mid-run / requireDb throws / row missing — degrade
|
|
@@ -24,10 +24,29 @@
|
|
|
24
24
|
* Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
|
|
25
25
|
* send sites (which is exactly the kind of per-call-site discipline that
|
|
26
26
|
* decays — the `reply` path was the only site anyone remembered), this hooks
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
27
|
+
* ONE chokepoint and observes the resolved response there.
|
|
28
|
+
*
|
|
29
|
+
* WHICH chokepoint took two goes, and the first answer was wrong (#4599). This
|
|
30
|
+
* docblock used to say the hook sat on `gateway.ts`'s `robustApiCall` — "the
|
|
31
|
+
* ONE chokepoint every gateway outbound already goes through … enforced by the
|
|
32
|
+
* `check-bot-api-wrapping` lint guard". Both halves were false from the day it
|
|
33
|
+
* merged. grammY's `ctx.*` sugar builds the payload and calls `bot.api.*`
|
|
34
|
+
* itself, so `switchroomReply` — the helper every SLASH-COMMAND card answers
|
|
35
|
+
* through (`/usage`, `/model`, `/auth`, `/approvals`, `/start`, `/help`) — sends
|
|
36
|
+
* via `ctx.replyWithRichMessage` and never touches `robustApiCall`; and the
|
|
37
|
+
* lint guard could not have caught that, because its verb pattern matched only
|
|
38
|
+
* `(bot|lockedBot|ctx)\.api\.<verb>`, never mentioned `sendRichMessage`, and
|
|
39
|
+
* structurally cannot match a `ctx.replyWith*` call at all. Measured, not
|
|
40
|
+
* inferred: a live agent's `/usage` card at id 20938 left NO row while the
|
|
41
|
+
* `tg-post` transformer logged its `sendRichMessage` POST.
|
|
42
|
+
*
|
|
43
|
+
* The hook therefore lives at the grammy API TRANSFORMER layer
|
|
44
|
+
* (`installSystemMessageObserver` in `shared/bot-runtime.ts`), which grammy
|
|
45
|
+
* resolves immediately around the HTTP POST — below every helper, `ctx.*`
|
|
46
|
+
* shorthand, `lockedBot`, and `bot.api.raw`. No call shape reaches Telegram
|
|
47
|
+
* without passing through it, so no future verb can opt out the way
|
|
48
|
+
* `switchroomReply` silently did. `robustApiCall` still publishes its `verb`
|
|
49
|
+
* down to that layer (`withTgSendContext`) so cards keep their `kind`.
|
|
31
50
|
*
|
|
32
51
|
* The observer reads the Telegram RESPONSE, which buys three things for free:
|
|
33
52
|
* - the real `message_id` (the only thing a reply can point at),
|
|
@@ -90,7 +109,7 @@ export interface SentMessageLike {
|
|
|
90
109
|
rich_message?: unknown
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
/** The
|
|
112
|
+
/** The call-site metadata the observer reads (`bot-runtime.ts`'s `TgSendContext`). */
|
|
94
113
|
export interface ObservedCallOpts {
|
|
95
114
|
chat_id?: string
|
|
96
115
|
threadId?: number
|
|
@@ -293,7 +312,7 @@ type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number; storedLe
|
|
|
293
312
|
|
|
294
313
|
/**
|
|
295
314
|
* Build the observer. The returned function is called with the RESOLVED result
|
|
296
|
-
* of every
|
|
315
|
+
* of every outbound Bot API call (from the transformer layer) and never throws.
|
|
297
316
|
*/
|
|
298
317
|
export function makeSystemMessageObserver(
|
|
299
318
|
deps: SystemMessageObserverDeps,
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* standalone foreman bot before its retirement.
|
|
6
6
|
*
|
|
7
7
|
* What lives here:
|
|
8
|
-
* - `installTgPostLogger` / `installRichMarkdownGuard`
|
|
9
|
-
*
|
|
8
|
+
* - `installTgPostLogger` / `installRichMarkdownGuard` /
|
|
9
|
+
* `installSystemMessageObserver` — the grammy API transformers every
|
|
10
|
+
* outbound call transits. This layer, not any caller-side wrapper, is the
|
|
11
|
+
* one seam no `ctx.*` helper or raw `bot.api.*` call can bypass.
|
|
10
12
|
* - `makeSwitchroomExec` / `makeSwitchroomExecCombined` — factory fns for
|
|
11
13
|
* the switchroom CLI exec helpers (callers pass their own CLI path / config
|
|
12
14
|
* env so each process can be configured independently).
|
|
@@ -292,6 +294,121 @@ export function installRichMarkdownGuard(bot: Bot): void {
|
|
|
292
294
|
})
|
|
293
295
|
}
|
|
294
296
|
|
|
297
|
+
// ─── outbound send observation (#4571 / #4599) ────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The call-site metadata a send observer wants but the wire payload cannot
|
|
301
|
+
* supply. Structurally the same shape as `ObservedCallOpts` in
|
|
302
|
+
* `gateway/system-message-observer.ts`; declared here so this module keeps its
|
|
303
|
+
* no-imports-from-gateway rule.
|
|
304
|
+
*/
|
|
305
|
+
export interface TgSendContext {
|
|
306
|
+
chat_id?: string
|
|
307
|
+
threadId?: number
|
|
308
|
+
/** The caller's own label for what this send IS (`activity-summary.send`). */
|
|
309
|
+
verb?: string
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const sendContextStore = new AsyncLocalStorage<TgSendContext | undefined>()
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Publish the enclosing call's `{chat_id, threadId, verb}` to the API
|
|
316
|
+
* transformer layer for the duration of `fn`'s async chain. `gateway.ts`'s
|
|
317
|
+
* `robustApiCall` wraps every call it issues; anything sent outside it (a
|
|
318
|
+
* `ctx.reply*` helper, a raw `bot.api.*`) simply observes with no verb.
|
|
319
|
+
*/
|
|
320
|
+
export function withTgSendContext<T>(ctx: TgSendContext | undefined, fn: () => T): T {
|
|
321
|
+
return sendContextStore.run(ctx, fn)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Exposed for the transformer (and tests). Undefined outside a wrapped call. */
|
|
325
|
+
export function _getTgSendContext(): TgSendContext | undefined {
|
|
326
|
+
return sendContextStore.getStore()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Install the card-history send observer as a grammy API TRANSFORMER — the
|
|
331
|
+
* real universal outbound seam (#4599).
|
|
332
|
+
*
|
|
333
|
+
* #4571 hooked the observer onto `gateway.ts`'s `robustApiCall`, and
|
|
334
|
+
* `system-message-observer.ts` claimed that was "the ONE chokepoint every
|
|
335
|
+
* gateway outbound already goes through … enforced by the
|
|
336
|
+
* `check-bot-api-wrapping` lint guard". That claim was false on both halves.
|
|
337
|
+
* grammY's `ctx.*` sugar builds the payload and calls `bot.api.*` directly, so
|
|
338
|
+
* `switchroomReply` (`gateway.ts`, the helper every SLASH-COMMAND card answers
|
|
339
|
+
* through — `/usage`, `/model`, `/auth`, `/approvals`, `/start`, `/help`) sends
|
|
340
|
+
* via `ctx.replyWithRichMessage` and never transits `robustApiCall` at all; and
|
|
341
|
+
* the lint guard could not have caught it, because its verb pattern matched
|
|
342
|
+
* only `(bot|lockedBot|ctx)\.api\.<verb>` — it never mentioned `sendRichMessage`
|
|
343
|
+
* and structurally cannot match a `ctx.replyWith*` helper.
|
|
344
|
+
*
|
|
345
|
+
* Measured, not inferred: on a live agent's buffer the `/usage` card at id
|
|
346
|
+
* 20938 left NO row, while the `tg-post` transformer in this very file logged
|
|
347
|
+
* its `sendRichMessage` POST. The transformer layer SAW the send the recorder
|
|
348
|
+
* missed. That is the whole argument for moving here: grammy resolves this
|
|
349
|
+
* chain immediately around the HTTP POST, below every helper, every `ctx.*`
|
|
350
|
+
* shorthand, `lockedBot`, and `bot.api.raw` — there is no call shape that
|
|
351
|
+
* reaches Telegram without passing through it, so no future verb can silently
|
|
352
|
+
* opt out the way `switchroomReply` did.
|
|
353
|
+
*
|
|
354
|
+
* `observe` is injected rather than imported: this module must not depend on
|
|
355
|
+
* anything under `gateway/` (see the file header). The caller keeps ownership
|
|
356
|
+
* of the history writers, the `isGatewayMain && HISTORY_ENABLED` gate, and the
|
|
357
|
+
* empty-body alarm.
|
|
358
|
+
*
|
|
359
|
+
* Only a RESOLVED, `ok:true` response is observed. grammy hands this chain the
|
|
360
|
+
* raw `ApiResponse` and converts `{ok:false}` into a thrown `GrammyError` only
|
|
361
|
+
* after the chain returns (see `installTgPostLogger`'s docblock), so a
|
|
362
|
+
* rejection arrives here as a resolved body and must be skipped — recording a
|
|
363
|
+
* Telegram error object as a card body is exactly the empty-row failure #4576
|
|
364
|
+
* was. Non-message results (`getUpdates` arrays, `getMe`, `true` from
|
|
365
|
+
* `answerCallbackQuery`) are filtered by the observer's own shape test.
|
|
366
|
+
*
|
|
367
|
+
* Pure observation: the response is returned untouched and nothing here can
|
|
368
|
+
* throw into the send path.
|
|
369
|
+
*/
|
|
370
|
+
export function installSystemMessageObserver(
|
|
371
|
+
bot: Bot,
|
|
372
|
+
observe: (result: unknown, opts?: TgSendContext) => void,
|
|
373
|
+
): void {
|
|
374
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
375
|
+
const res = await prev(method, payload, signal)
|
|
376
|
+
try {
|
|
377
|
+
const r = res as unknown as { ok?: boolean; result?: unknown }
|
|
378
|
+
if (r != null && typeof r === 'object' && r.ok === true) {
|
|
379
|
+
observe(r.result, resolveSendContext(payload))
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
/* observing a send must never break the send */
|
|
383
|
+
}
|
|
384
|
+
return res
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Merge the caller-published context with what the outbound PAYLOAD itself
|
|
390
|
+
* carries. The context wins where both exist (it is the caller's own intent);
|
|
391
|
+
* the payload is what keeps an unwrapped `ctx.reply*` send attributable to a
|
|
392
|
+
* chat and topic at all. The response's own `chat.id` still outranks both
|
|
393
|
+
* downstream — this is only the fallback tier.
|
|
394
|
+
*/
|
|
395
|
+
function resolveSendContext(payload: unknown): TgSendContext | undefined {
|
|
396
|
+
const ctx = sendContextStore.getStore()
|
|
397
|
+
const p = (payload ?? {}) as Record<string, unknown>
|
|
398
|
+
const rawChat = p.chat_id
|
|
399
|
+
const chat_id =
|
|
400
|
+
ctx?.chat_id ??
|
|
401
|
+
(typeof rawChat === 'string' || typeof rawChat === 'number' ? String(rawChat) : undefined)
|
|
402
|
+
const rawThread = p.message_thread_id
|
|
403
|
+
const threadId = ctx?.threadId ?? (typeof rawThread === 'number' ? rawThread : undefined)
|
|
404
|
+
if (chat_id == null && threadId == null && ctx?.verb == null) return undefined
|
|
405
|
+
return {
|
|
406
|
+
...(chat_id != null ? { chat_id } : {}),
|
|
407
|
+
...(threadId != null ? { threadId } : {}),
|
|
408
|
+
...(ctx?.verb != null ? { verb: ctx.verb } : {}),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
295
412
|
// ─── robustApiCall factory: REMOVED (#3863) ───────────────────────────────
|
|
296
413
|
//
|
|
297
414
|
// `createRobustApiCall` used to live here as a second `createRetryApiCall`
|
|
@@ -49,8 +49,13 @@ import {
|
|
|
49
49
|
type EnvelopeBuildParams,
|
|
50
50
|
} from '../gateway/inbound-router.js'
|
|
51
51
|
import { makeSystemMessageObserver } from '../gateway/system-message-observer.js'
|
|
52
|
-
import { Bot } from 'grammy'
|
|
53
|
-
import {
|
|
52
|
+
import { Bot, Context } from 'grammy'
|
|
53
|
+
import {
|
|
54
|
+
installRichMarkdownGuard,
|
|
55
|
+
installSystemMessageObserver,
|
|
56
|
+
makeSwitchroomReply,
|
|
57
|
+
withTgSendContext,
|
|
58
|
+
} from '../shared/bot-runtime.js'
|
|
54
59
|
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
55
60
|
import { richMessage } from '../rich-send.js'
|
|
56
61
|
|
|
@@ -317,6 +322,170 @@ describe('#4576: the stored card body is NOT empty — real sendRichMessage resp
|
|
|
317
322
|
})
|
|
318
323
|
})
|
|
319
324
|
|
|
325
|
+
describe('#4599: the SLASH-COMMAND card path is recorded too', () => {
|
|
326
|
+
const BOT_INFO = {
|
|
327
|
+
id: 123456, is_bot: true as const, first_name: 'Test', username: 'test_bot',
|
|
328
|
+
can_join_groups: false, can_read_all_group_messages: false,
|
|
329
|
+
supports_inline_queries: false, can_connect_to_business: false,
|
|
330
|
+
has_main_web_app: false,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The gateway's REAL transformer stack with the observer installed exactly
|
|
335
|
+
* where gateway.ts installs it (after `installSentTextCapture`), transport
|
|
336
|
+
* stubbed to answer with the true `Message.RichMessageMessage` shape echoing
|
|
337
|
+
* the markdown that was actually POSTed.
|
|
338
|
+
*
|
|
339
|
+
* `editMessageText` re-uses the `message_id` in the payload, so an edit
|
|
340
|
+
* returns the id it edited — the shape the observer's send-vs-edit rule
|
|
341
|
+
* depends on.
|
|
342
|
+
*/
|
|
343
|
+
function makeCardBot(observe: (result: unknown, opts?: { verb?: string }) => void) {
|
|
344
|
+
let nextId = 30_000
|
|
345
|
+
const posted: string[] = []
|
|
346
|
+
const fakeFetch = (async (_url: unknown, init: { body?: string } | undefined) => {
|
|
347
|
+
const payload = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
|
|
348
|
+
posted.push(String(payload.__method__ ?? ''))
|
|
349
|
+
const rich = payload.rich_message as { markdown?: unknown } | undefined
|
|
350
|
+
const id = typeof payload.message_id === 'number' ? payload.message_id : nextId++
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
status: 200,
|
|
354
|
+
json: async () => ({
|
|
355
|
+
ok: true,
|
|
356
|
+
result: {
|
|
357
|
+
message_id: id,
|
|
358
|
+
date: 0,
|
|
359
|
+
chat: { id: Number(CHAT), type: 'private' },
|
|
360
|
+
...(typeof rich?.markdown === 'string'
|
|
361
|
+
? { rich_message: { blocks: [{ type: 'paragraph', text: { text: rich.markdown } }] } }
|
|
362
|
+
: { text: String(payload.text ?? '') }),
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
} as unknown as Response
|
|
366
|
+
}) as unknown as typeof fetch
|
|
367
|
+
|
|
368
|
+
const bot = new Bot('123456:TEST_TOKEN', { botInfo: BOT_INFO, client: { fetch: fakeFetch } })
|
|
369
|
+
installRichMarkdownGuard(bot)
|
|
370
|
+
installSentTextCapture(bot)
|
|
371
|
+
installSystemMessageObserver(bot, observe)
|
|
372
|
+
return { bot, posted }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A grammy Context for an inbound `/usage`, as the command handler receives it. */
|
|
376
|
+
function makeCtx(bot: Bot): Context {
|
|
377
|
+
return new Context(
|
|
378
|
+
{
|
|
379
|
+
update_id: 1,
|
|
380
|
+
message: {
|
|
381
|
+
message_id: 20_930,
|
|
382
|
+
date: 0,
|
|
383
|
+
chat: { id: Number(CHAT), type: 'private' as const },
|
|
384
|
+
from: { id: 111, is_bot: false, first_name: 'Alice' },
|
|
385
|
+
text: '/usage',
|
|
386
|
+
},
|
|
387
|
+
} as never,
|
|
388
|
+
bot.api,
|
|
389
|
+
BOT_INFO,
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
it('a /usage card sent via switchroomReply leaves exactly one non-empty row', async () => {
|
|
394
|
+
// THE #4599 DEFECT, pinned. `switchroomReply` answers every slash command
|
|
395
|
+
// through `ctx.replyWithRichMessage`, which builds its own payload and calls
|
|
396
|
+
// `bot.api.*` directly — it never transits `robustApiCall`, where the #4571
|
|
397
|
+
// recorder was hooked. Measured on a live agent: the `/usage` card at id
|
|
398
|
+
// 20938 left no row, so a quote-reply to it resolved to nothing. A recorder
|
|
399
|
+
// that only sees `robustApiCall` cannot pass this test.
|
|
400
|
+
const observe = makeObserver()
|
|
401
|
+
const { bot } = makeCardBot(observe)
|
|
402
|
+
const switchroomReply = makeSwitchroomReply(() => undefined)
|
|
403
|
+
const BODY = 'Usage this week — Opus 41 percent, Sonnet 12 percent'
|
|
404
|
+
|
|
405
|
+
await switchroomReply(makeCtx(bot), BODY, { html: true })
|
|
406
|
+
|
|
407
|
+
const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
|
|
408
|
+
(r) => r.role === 'system',
|
|
409
|
+
)
|
|
410
|
+
expect(cards).toHaveLength(1)
|
|
411
|
+
expect(cards[0]!.text).toBe(BODY)
|
|
412
|
+
|
|
413
|
+
// …and the quote-reply the operator actually makes now resolves.
|
|
414
|
+
const resolved = resolveReplyToFromBuffer({
|
|
415
|
+
replyToMessageId: cards[0]!.message_id,
|
|
416
|
+
replyToText: undefined,
|
|
417
|
+
replyToTextEscaped: undefined,
|
|
418
|
+
historyEnabled: true,
|
|
419
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
420
|
+
lookup: boundLookup,
|
|
421
|
+
})
|
|
422
|
+
expect(resolved.replyToRole).toBe('system')
|
|
423
|
+
expect(resolved.replyToText).toBe(BODY)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('the PLAIN ctx.reply branch of switchroomReply is recorded as well', async () => {
|
|
427
|
+
const observe = makeObserver()
|
|
428
|
+
const { bot } = makeCardBot(observe)
|
|
429
|
+
const switchroomReply = makeSwitchroomReply(() => undefined)
|
|
430
|
+
|
|
431
|
+
await switchroomReply(makeCtx(bot), 'plain notice, no markdown', {})
|
|
432
|
+
|
|
433
|
+
const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
|
|
434
|
+
(r) => r.role === 'system',
|
|
435
|
+
)
|
|
436
|
+
expect(cards).toHaveLength(1)
|
|
437
|
+
expect(cards[0]!.text).toBe('plain notice, no markdown')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('a wrapped send keeps its verb → kind, and its edits do NOT add a second row', async () => {
|
|
441
|
+
// The behaviour the move must PRESERVE: `robustApiCall` publishes its verb
|
|
442
|
+
// through `withTgSendContext`, so the 117 live `role='system'` rows keep
|
|
443
|
+
// their `kind` (`activity-summary`, `worker-feed`, …) instead of degrading
|
|
444
|
+
// to null now that the recorder no longer sits on that wrapper.
|
|
445
|
+
let clock = 1_000
|
|
446
|
+
const observe = makeObserver(() => clock)
|
|
447
|
+
const { bot } = makeCardBot(observe)
|
|
448
|
+
|
|
449
|
+
const sent = await withTgSendContext({ chat_id: CHAT, verb: 'activity-summary.send' }, () =>
|
|
450
|
+
bot.api.sendRichMessage(Number(CHAT), richMessage('Working — 3 tools')),
|
|
451
|
+
)
|
|
452
|
+
const id = (sent as { message_id: number }).message_id
|
|
453
|
+
|
|
454
|
+
clock += 60_000
|
|
455
|
+
await withTgSendContext({ chat_id: CHAT, verb: 'activity-summary.edit' }, () =>
|
|
456
|
+
bot.api.editMessageText(Number(CHAT), id, richMessage('Working — 11 tools')),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
|
|
460
|
+
(r) => r.role === 'system',
|
|
461
|
+
)
|
|
462
|
+
expect(cards).toHaveLength(1)
|
|
463
|
+
expect(cards[0]!.message_id).toBe(id)
|
|
464
|
+
expect(cards[0]!.kind).toBe('activity-summary')
|
|
465
|
+
expect(cards[0]!.text).toBe('Working — 11 tools')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('a Telegram REJECTION is never recorded as a card', async () => {
|
|
469
|
+
// grammy resolves the transformer chain with the raw `{ok:false}` body and
|
|
470
|
+
// only throws afterwards, so a failed send reaches the observer as a
|
|
471
|
+
// RESOLVED response. Recording its error object would be #4576 all over.
|
|
472
|
+
const observe = makeObserver()
|
|
473
|
+
const failFetch = (async () =>
|
|
474
|
+
({
|
|
475
|
+
ok: true,
|
|
476
|
+
status: 200,
|
|
477
|
+
json: async () => ({ ok: false, error_code: 400, description: 'chat not found' }),
|
|
478
|
+
}) as unknown as Response) as unknown as typeof fetch
|
|
479
|
+
const bot = new Bot('123456:TEST_TOKEN', { botInfo: BOT_INFO, client: { fetch: failFetch } })
|
|
480
|
+
installSystemMessageObserver(bot, observe)
|
|
481
|
+
|
|
482
|
+
await expect(
|
|
483
|
+
bot.api.sendRichMessage(Number(CHAT), richMessage('never lands')),
|
|
484
|
+
).rejects.toThrow()
|
|
485
|
+
expect(query({ chat_id: CHAT, limit: 50, include_system: true })).toHaveLength(0)
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
|
|
320
489
|
describe('the card lane does not pollute normal history reads', () => {
|
|
321
490
|
it('query() (get_recent_messages) lists the conversation, never the cards', () => {
|
|
322
491
|
const observe = makeObserver()
|
|
@@ -700,14 +700,22 @@ describe('startOrphanedDbSweep', () => {
|
|
|
700
700
|
// Multiple ticks: proves the interval repeats, not just fires once.
|
|
701
701
|
expect(lines.length).toBeGreaterThan(1)
|
|
702
702
|
|
|
703
|
-
stop()
|
|
704
|
-
// A tick is async, so one may be in flight when stop() lands. Let it settle
|
|
705
|
-
// before snapshotting — the property under test is "no NEW ticks start",
|
|
706
|
-
// not "no line is ever appended after the stop() call returns".
|
|
707
|
-
await sleep(60)
|
|
708
703
|
const atStop = lines.length
|
|
709
|
-
|
|
710
|
-
//
|
|
711
|
-
|
|
704
|
+
stop()
|
|
705
|
+
// The property under test is "no NEW ticks start", not "no line is ever
|
|
706
|
+
// appended after stop() returns": the tick body is async, so one tick can
|
|
707
|
+
// already be in flight when stop() lands and will still emit its line.
|
|
708
|
+
//
|
|
709
|
+
// The sweep's own `running` guard means AT MOST ONE can be in flight, so
|
|
710
|
+
// the tolerance is exactly one line — assert that, rather than trying to
|
|
711
|
+
// out-wait the in-flight tick and then demanding exact equality. Every
|
|
712
|
+
// fixed settle window is a guess a loaded runner beats: 60ms flaked two of
|
|
713
|
+
// three consecutive merge-queue passes and a 40ms quiescence poll flaked
|
|
714
|
+
// again on the very next one (#4601), because a tick that has not yet
|
|
715
|
+
// resolved looks identical to quiescence.
|
|
716
|
+
await sleep(400)
|
|
717
|
+
// Not a weakening: an uncleared 5ms interval adds dozens of lines here, so
|
|
718
|
+
// this still fails hard on a stop() that does not clear the timer.
|
|
719
|
+
expect(lines.length - atStop).toBeLessThanOrEqual(1)
|
|
712
720
|
})
|
|
713
721
|
})
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
* partial quote) over the full parent `.text`.
|
|
26
26
|
* - `buildInboundEnvelope` emits `reply_to_role` when known and `reply_to_text`
|
|
27
27
|
* from the recovered escaped form.
|
|
28
|
+
* - `buildReplyForwardContext` reads a RICH parent's body off
|
|
29
|
+
* `reply_to_message.rich_message` LIVE (#4598) — the only path that can
|
|
30
|
+
* resolve a card the buffer never recorded. The last describe block pins
|
|
31
|
+
* that, including a killer case a buffer-only implementation must fail.
|
|
28
32
|
*
|
|
29
33
|
* The persisted-row-non-NULL half of the 1a contract (which needs a real
|
|
30
34
|
* bun:sqlite history.db) lives in reply-to-buffer-history.test.ts (bun).
|
|
@@ -98,20 +102,32 @@ describe('resolveReplyToFromBuffer — reply-to buffer fallback (1a)', () => {
|
|
|
98
102
|
})
|
|
99
103
|
|
|
100
104
|
it('does NOT overwrite a non-empty LIVE reply text (reply to a person, or a partial quote)', () => {
|
|
101
|
-
const lookup = () => {
|
|
102
|
-
throw new Error('lookup must not be called when live text is present')
|
|
103
|
-
}
|
|
104
105
|
const out = resolveReplyToFromBuffer({
|
|
105
106
|
replyToMessageId: 9,
|
|
106
107
|
replyToText: 'live raw text',
|
|
107
108
|
replyToTextEscaped: 'live escaped text',
|
|
108
109
|
historyEnabled: true,
|
|
109
110
|
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
110
|
-
lookup,
|
|
111
|
+
lookup: () => ({ role: 'user', text: 'STALE BUFFER TEXT' }),
|
|
111
112
|
})
|
|
112
113
|
expect(out.replyToText).toBe('live raw text')
|
|
113
114
|
expect(out.replyToTextEscaped).toBe('live escaped text')
|
|
115
|
+
// …but the lookup still supplies the ROLE, which has no live-update source.
|
|
116
|
+
expect(out.replyToRole).toBe('user')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('a missing row does not clobber a live reply text', () => {
|
|
120
|
+
const out = resolveReplyToFromBuffer({
|
|
121
|
+
replyToMessageId: 9,
|
|
122
|
+
replyToText: 'live raw text',
|
|
123
|
+
replyToTextEscaped: 'live escaped text',
|
|
124
|
+
historyEnabled: true,
|
|
125
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
126
|
+
lookup: () => null,
|
|
127
|
+
})
|
|
128
|
+
expect(out.replyToText).toBe('live raw text')
|
|
114
129
|
expect(out.replyToRole).toBeUndefined()
|
|
130
|
+
expect(out.replyToKind).toBeUndefined()
|
|
115
131
|
})
|
|
116
132
|
|
|
117
133
|
it('history-disabled guard: never calls lookup, degrades to id-only, does not throw', () => {
|
|
@@ -208,6 +224,195 @@ describe('buildReplyForwardContext — native partial-quote preference (2a)', ()
|
|
|
208
224
|
})
|
|
209
225
|
})
|
|
210
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Rich-message parents (#4598).
|
|
229
|
+
*
|
|
230
|
+
* Every card the gateway posts ships via Bot API 10.1 `sendRichMessage`, so a
|
|
231
|
+
* native reply to one delivers a `reply_to_message` with `rich_message.blocks`
|
|
232
|
+
* populated and `text` / `caption` ABSENT. Measured on the wire against a real
|
|
233
|
+
* bot: keys `[message_id, from, chat, date, rich_message]`.
|
|
234
|
+
*
|
|
235
|
+
* Before #4598 the body of a card antecedent was 100% buffer-sourced, so a
|
|
236
|
+
* card that never made it into `history.db` — posted while the gateway was
|
|
237
|
+
* down, or through a send path that bypassed the recording chokepoint — was
|
|
238
|
+
* permanently unresolvable no matter how the recording side was fixed. These
|
|
239
|
+
* pin the LIVE read.
|
|
240
|
+
*/
|
|
241
|
+
describe('buildReplyForwardContext — rich_message parent (#4598)', () => {
|
|
242
|
+
/** The shape Telegram actually delivers for a reply to a card. */
|
|
243
|
+
function richParent(message_id: number) {
|
|
244
|
+
return {
|
|
245
|
+
message_id,
|
|
246
|
+
rich_message: {
|
|
247
|
+
blocks: [
|
|
248
|
+
{ type: 'paragraph', text: [{ type: 'bold', text: 'Usage' }, ' this week'] },
|
|
249
|
+
{ type: 'paragraph', text: 'Opus 41% then Sonnet 12%' },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const RENDERED = 'Usage this week\nOpus 41% then Sonnet 12%'
|
|
256
|
+
|
|
257
|
+
it('reads the parent body off rich_message when text and caption are absent', () => {
|
|
258
|
+
const ctx = makeCtx({ reply_to_message: richParent(9938) })
|
|
259
|
+
const out = buildReplyForwardContext({
|
|
260
|
+
ctx,
|
|
261
|
+
coalescedForwardOrigins: undefined,
|
|
262
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
263
|
+
})
|
|
264
|
+
expect(out.replyToMessageId).toBe(9938)
|
|
265
|
+
expect(out.replyToText).toBe(RENDERED)
|
|
266
|
+
expect(out.replyToTextEscaped).toBe(RENDERED)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('truncates a long rich body to the cap, like every other antecedent', () => {
|
|
270
|
+
const ctx = makeCtx({
|
|
271
|
+
reply_to_message: {
|
|
272
|
+
message_id: 1,
|
|
273
|
+
rich_message: { blocks: [{ type: 'paragraph', text: 'x'.repeat(400) }] },
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
const out = buildReplyForwardContext({
|
|
277
|
+
ctx,
|
|
278
|
+
coalescedForwardOrigins: undefined,
|
|
279
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
280
|
+
})
|
|
281
|
+
expect(out.replyToText).toHaveLength(REPLY_TO_TEXT_MAX)
|
|
282
|
+
expect(out.replyToText?.endsWith('…')).toBe(true)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('yields undefined (not empty string) for an unrenderable block tree, so the buffer still runs', () => {
|
|
286
|
+
// A thinking-only / media-stripped card renders to nothing. It must fall
|
|
287
|
+
// THROUGH to the buffer rather than pinning the antecedent to ''.
|
|
288
|
+
const ctx = makeCtx({
|
|
289
|
+
reply_to_message: { message_id: 5, rich_message: { blocks: [{ type: 'thinking' }] } },
|
|
290
|
+
})
|
|
291
|
+
const live = buildReplyForwardContext({
|
|
292
|
+
ctx,
|
|
293
|
+
coalescedForwardOrigins: undefined,
|
|
294
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
295
|
+
})
|
|
296
|
+
expect(live.replyToText).toBeUndefined()
|
|
297
|
+
expect(live.replyToTextEscaped).toBeUndefined()
|
|
298
|
+
|
|
299
|
+
const out = resolveReplyToFromBuffer({
|
|
300
|
+
replyToMessageId: live.replyToMessageId,
|
|
301
|
+
replyToText: live.replyToText,
|
|
302
|
+
replyToTextEscaped: live.replyToTextEscaped,
|
|
303
|
+
historyEnabled: true,
|
|
304
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
305
|
+
lookup: () => ({ role: 'system', text: 'stored card body', kind: 'activity-summary' }),
|
|
306
|
+
})
|
|
307
|
+
expect(out.replyToText).toBe('stored card body')
|
|
308
|
+
expect(out.replyToKind).toBe('activity-summary')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('prefers a native partial quote over the rich parent body', () => {
|
|
312
|
+
const ctx = makeCtx({
|
|
313
|
+
reply_to_message: richParent(7),
|
|
314
|
+
quote: { text: 'Opus 41%', position: 5, is_manual: true },
|
|
315
|
+
})
|
|
316
|
+
const out = buildReplyForwardContext({
|
|
317
|
+
ctx,
|
|
318
|
+
coalescedForwardOrigins: undefined,
|
|
319
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
320
|
+
})
|
|
321
|
+
expect(out.replyToText).toBe('Opus 41%')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('THE BUFFER-ONLY KILLER: the live rich body wins over a DIFFERENT stored row', () => {
|
|
325
|
+
// A buffer-only implementation resolves this reply from the stored row and
|
|
326
|
+
// returns the STALE text — which is the point: this is the one test in the
|
|
327
|
+
// file that a recording-side-only fix cannot pass. The lookup DOES run
|
|
328
|
+
// (role/kind have no live source, see the next test) but it must not be
|
|
329
|
+
// allowed to win the text, so the stub deliberately returns a different
|
|
330
|
+
// body from the one on the wire.
|
|
331
|
+
const ctx = makeCtx({ reply_to_message: richParent(9925) })
|
|
332
|
+
const live = buildReplyForwardContext({
|
|
333
|
+
ctx,
|
|
334
|
+
coalescedForwardOrigins: undefined,
|
|
335
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
let lookupCalls = 0
|
|
339
|
+
const out = resolveReplyToFromBuffer({
|
|
340
|
+
replyToMessageId: live.replyToMessageId,
|
|
341
|
+
replyToText: live.replyToText,
|
|
342
|
+
replyToTextEscaped: live.replyToTextEscaped,
|
|
343
|
+
historyEnabled: true,
|
|
344
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
345
|
+
lookup: () => {
|
|
346
|
+
lookupCalls++
|
|
347
|
+
return { role: 'system', text: 'STALE BUFFER TEXT', kind: 'usage-card' }
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
expect(out.replyToText).toBe(RENDERED)
|
|
352
|
+
expect(out.replyToTextEscaped).toBe(RENDERED)
|
|
353
|
+
expect(out.replyToText).not.toContain('STALE BUFFER TEXT')
|
|
354
|
+
// The row was consulted — for role/kind only, never for the body.
|
|
355
|
+
expect(lookupCalls).toBe(1)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('a live-resolved rich body still carries reply_to_role AND reply_to_kind through the envelope', () => {
|
|
359
|
+
// The regression the `liveTextEmpty` gate introduced: an operator FULL-
|
|
360
|
+
// replies (no partial quote) to a live activity card whose row IS in
|
|
361
|
+
// history.db. The body now resolves live off `rich_message` — and if that
|
|
362
|
+
// short-circuits the lookup, the envelope loses `reply_to_role="system"`
|
|
363
|
+
// and `reply_to_kind="activity-summary"` entirely, killing the #4571 kind
|
|
364
|
+
// lane for exactly the case it was built for.
|
|
365
|
+
const ctx = makeCtx({ reply_to_message: richParent(9925) })
|
|
366
|
+
const live = buildReplyForwardContext({
|
|
367
|
+
ctx,
|
|
368
|
+
coalescedForwardOrigins: undefined,
|
|
369
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
370
|
+
})
|
|
371
|
+
const resolved = resolveReplyToFromBuffer({
|
|
372
|
+
replyToMessageId: live.replyToMessageId,
|
|
373
|
+
replyToText: live.replyToText,
|
|
374
|
+
replyToTextEscaped: live.replyToTextEscaped,
|
|
375
|
+
historyEnabled: true,
|
|
376
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
377
|
+
// The row IS recorded — same card, stored body.
|
|
378
|
+
lookup: () => ({ role: 'system', text: RENDERED, kind: 'activity-summary' }),
|
|
379
|
+
})
|
|
380
|
+
expect(resolved.replyToRole).toBe('system')
|
|
381
|
+
expect(resolved.replyToKind).toBe('activity-summary')
|
|
382
|
+
|
|
383
|
+
const msg = buildInboundEnvelope(
|
|
384
|
+
makeEnvelopeParams({
|
|
385
|
+
ctx,
|
|
386
|
+
replyToMessageId: live.replyToMessageId,
|
|
387
|
+
replyToTextEscaped: resolved.replyToTextEscaped,
|
|
388
|
+
replyToRole: resolved.replyToRole,
|
|
389
|
+
replyToKind: resolved.replyToKind,
|
|
390
|
+
}),
|
|
391
|
+
)
|
|
392
|
+
expect(msg.meta?.reply_to_text).toBe(RENDERED)
|
|
393
|
+
expect(msg.meta?.reply_to_role).toBe('system')
|
|
394
|
+
expect(msg.meta?.reply_to_kind).toBe('activity-summary')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('carries the live rich body through to the inbound envelope', () => {
|
|
398
|
+
const ctx = makeCtx({ reply_to_message: richParent(9925) })
|
|
399
|
+
const live = buildReplyForwardContext({
|
|
400
|
+
ctx,
|
|
401
|
+
coalescedForwardOrigins: undefined,
|
|
402
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
403
|
+
})
|
|
404
|
+
const msg = buildInboundEnvelope(
|
|
405
|
+
makeEnvelopeParams({
|
|
406
|
+
ctx,
|
|
407
|
+
replyToMessageId: live.replyToMessageId,
|
|
408
|
+
replyToTextEscaped: live.replyToTextEscaped,
|
|
409
|
+
}),
|
|
410
|
+
)
|
|
411
|
+
expect(msg.meta?.reply_to_message_id).toBe('9925')
|
|
412
|
+
expect(msg.meta?.reply_to_text).toBe(RENDERED)
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
211
416
|
function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
|
|
212
417
|
return {
|
|
213
418
|
ctx: makeCtx({}),
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* system-message-observer — the card-lane recorder's pure behaviour (#4571).
|
|
3
3
|
*
|
|
4
|
-
* The observer hangs off
|
|
4
|
+
* The observer hangs off the grammy API transformer seam, installed at bot
|
|
5
|
+
* construction (#4599 moved it there from gateway.ts's `robustApiCall`, which
|
|
6
|
+
* the `ctx.replyWithRichMessage` slash-command path bypassed entirely), and
|
|
5
7
|
* turns every card the gateway posts (activity summary, status pin, approval /
|
|
6
8
|
* boot / issues cards, progress lines) into ONE resolvable history row. The
|
|
7
9
|
* two properties that make it safe to run on the hot send path are asserted
|
|
@@ -12,12 +14,20 @@
|
|
|
12
14
|
* 2. an id that turns out to belong to a real reply / inbound is demoted to
|
|
13
15
|
* `foreign` and never written to again.
|
|
14
16
|
*
|
|
17
|
+
* Both install the observer in their OWN harness bot, so a third block at the
|
|
18
|
+
* bottom of this file pins the single PRODUCTION install line in
|
|
19
|
+
* `initGatewayBot()` — see its docblock.
|
|
20
|
+
*
|
|
15
21
|
* The end-to-end proof (card posted → id resolvable → a reply pointing at it
|
|
16
22
|
* is understood) needs a real bun:sqlite history.db and lives in
|
|
17
23
|
* card-history-lane.test.ts (bun).
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import { describe, it, expect } from 'vitest'
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
import { fileURLToPath } from 'node:url'
|
|
29
|
+
import { dirname, resolve } from 'node:path'
|
|
30
|
+
import ts from 'typescript'
|
|
21
31
|
import {
|
|
22
32
|
makeSystemMessageObserver,
|
|
23
33
|
normalizeSendVerb,
|
|
@@ -223,3 +233,76 @@ describe('makeSystemMessageObserver', () => {
|
|
|
223
233
|
expect(store.rows.get(1)?.text).toBe('card 1 edited')
|
|
224
234
|
})
|
|
225
235
|
})
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Boot-wiring pin (#4599).
|
|
239
|
+
*
|
|
240
|
+
* Every behaviour test above installs the observer in its OWN harness, so
|
|
241
|
+
* deleting the single production install line in `initGatewayBot()` leaves all
|
|
242
|
+
* of them green while ALL card recording silently vanishes — worse than
|
|
243
|
+
* pre-#4571, because the old `robustApiCall` hook is gone and the empty-body
|
|
244
|
+
* alarm now lives INSIDE the observer, so nothing would fire either. Grammy's
|
|
245
|
+
* installed transformers are anonymous fns with nothing to grip at runtime, so
|
|
246
|
+
* this is a source-level AST assertion on the boot path — the same approach
|
|
247
|
+
* `format-guard-pins.test.ts` uses for `installRichMarkdownGuard`.
|
|
248
|
+
*/
|
|
249
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
250
|
+
const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
|
|
251
|
+
const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
|
|
252
|
+
const gatewaySource = ts.createSourceFile(
|
|
253
|
+
GATEWAY_PATH,
|
|
254
|
+
GATEWAY_SRC,
|
|
255
|
+
ts.ScriptTarget.Latest,
|
|
256
|
+
true,
|
|
257
|
+
ts.ScriptKind.TS,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
function findFunction(name: string): ts.FunctionDeclaration | undefined {
|
|
261
|
+
for (const s of gatewaySource.statements) {
|
|
262
|
+
if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
|
|
263
|
+
}
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function countCallsTo(root: ts.Node, name: string): number {
|
|
268
|
+
let count = 0
|
|
269
|
+
const visit = (node: ts.Node): void => {
|
|
270
|
+
if (
|
|
271
|
+
ts.isCallExpression(node) &&
|
|
272
|
+
ts.isIdentifier(node.expression) &&
|
|
273
|
+
node.expression.text === name
|
|
274
|
+
) {
|
|
275
|
+
count++
|
|
276
|
+
// Installed on the constructed bot instance, with the real observer.
|
|
277
|
+
expect(node.arguments[0]?.getText(gatewaySource)).toBe('bot')
|
|
278
|
+
expect(node.arguments[1]?.getText(gatewaySource)).toBe('observeSentMessage')
|
|
279
|
+
}
|
|
280
|
+
ts.forEachChild(node, visit)
|
|
281
|
+
}
|
|
282
|
+
visit(root)
|
|
283
|
+
return count
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
describe('boot wiring: installSystemMessageObserver is installed on the production Bot', () => {
|
|
287
|
+
it('imports installSystemMessageObserver from ../shared/bot-runtime.js', () => {
|
|
288
|
+
// The import must exist for the boot call to resolve; a refactor that drops
|
|
289
|
+
// the import would break the seam.
|
|
290
|
+
expect(GATEWAY_SRC).toMatch(
|
|
291
|
+
/import\s*\{[^}]*\binstallSystemMessageObserver\b[^}]*\}\s*from\s*'\.\.\/shared\/bot-runtime\.js'/,
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('calls installSystemMessageObserver(bot, observeSentMessage) exactly once inside initGatewayBot()', () => {
|
|
296
|
+
const fn = findFunction('initGatewayBot')
|
|
297
|
+
expect(fn?.body).toBeDefined()
|
|
298
|
+
expect(countCallsTo(fn!.body!, 'installSystemMessageObserver')).toBe(1)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('builds the production observer from the real history writers', () => {
|
|
302
|
+
// The install is conditional on `observeSentMessage`; pin what fills it, or
|
|
303
|
+
// the line above could survive against a permanently-undefined observer.
|
|
304
|
+
expect(GATEWAY_SRC).toMatch(
|
|
305
|
+
/const observeSentMessage = isGatewayMain && HISTORY_ENABLED\s*\n?\s*\?\s*makeSystemMessageObserver\(/,
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
})
|