switchroom 0.16.29 → 0.16.46
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/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -72,6 +72,45 @@ export interface ObservedMessage {
|
|
|
72
72
|
* must be silent, only the final answer should ping.
|
|
73
73
|
*/
|
|
74
74
|
silent: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The rich-formatting entities Telegram actually attached to the
|
|
77
|
+
* rendered message (bold / italic / code / pre / text_link / …),
|
|
78
|
+
* normalized from mtcute's `Message.entities`. This is what the
|
|
79
|
+
* highest-fidelity UAT layer asserts on: not the markdown we SENT, but
|
|
80
|
+
* the entity structure Telegram PARSED and will render. Empty array for
|
|
81
|
+
* a plain-text message with no formatting.
|
|
82
|
+
*
|
|
83
|
+
* Empty (never undefined) when the message body could not be decoded to
|
|
84
|
+
* real text+entities (see `text === "\x01"` sentinel below).
|
|
85
|
+
*/
|
|
86
|
+
entities: ObservedEntity[];
|
|
87
|
+
/**
|
|
88
|
+
* The `t.me/c/<internal_chat>/<msgid>` (or `t.me/<username>/<msgid>`)
|
|
89
|
+
* permalink to this message, for human eyeball reference in UAT output.
|
|
90
|
+
* `undefined` for chats that don't support message links (private DMs) —
|
|
91
|
+
* mtcute's `Message.link` getter throws there, so we swallow and leave it
|
|
92
|
+
* unset rather than fail the observation.
|
|
93
|
+
*/
|
|
94
|
+
link?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One rich-formatting entity as Telegram parsed it, flattened from
|
|
99
|
+
* mtcute's `MessageEntity` into the fields a UAT assertion cares about.
|
|
100
|
+
* `kind` is mtcute's entity kind (`bold`, `italic`, `code`, `pre`,
|
|
101
|
+
* `text_link`, `blockquote`, …); `text` is the exact inner substring the
|
|
102
|
+
* entity spans; `offset`/`length` are UTF-16 code-unit coordinates (the
|
|
103
|
+
* same units Telegram uses on the wire).
|
|
104
|
+
*/
|
|
105
|
+
export interface ObservedEntity {
|
|
106
|
+
kind: string;
|
|
107
|
+
offset: number;
|
|
108
|
+
length: number;
|
|
109
|
+
text: string;
|
|
110
|
+
/** Destination for `text_link` entities; undefined otherwise. */
|
|
111
|
+
url?: string;
|
|
112
|
+
/** Language info string for `pre` fenced blocks; undefined otherwise. */
|
|
113
|
+
language?: string;
|
|
75
114
|
}
|
|
76
115
|
|
|
77
116
|
export interface ObservedButton {
|
|
@@ -142,11 +181,21 @@ export class Driver {
|
|
|
142
181
|
await this.client.connect();
|
|
143
182
|
// `connect()` opens the transport but does NOT start the updates
|
|
144
183
|
// dispatch loop — that's `start()`'s job. For a returning session
|
|
145
|
-
// (no interactive login) we have to
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
184
|
+
// (no interactive login) we have to drive that lifecycle ourselves,
|
|
185
|
+
// otherwise `onNewMessage` / `onEditMessage` never fire and
|
|
186
|
+
// `observeMessages` silently waits forever. Symptom: `expectMessage`
|
|
187
|
+
// timing out even though the bot's reply has arrived in the chat
|
|
188
|
+
// (visible in Telegram).
|
|
189
|
+
//
|
|
190
|
+
// `notifyLoggedIn` is the lifecycle call `start()` makes from its
|
|
191
|
+
// login callback but a manually-imported session skips — it tells the
|
|
192
|
+
// updates manager the client is authorized (and seeds self into the
|
|
193
|
+
// peer cache). mtcute 0.30's own docs recommend calling it, or
|
|
194
|
+
// otherwise ensuring the user is logged in, before `startUpdatesLoop`.
|
|
195
|
+
// Resolve self from the imported session and notify BEFORE starting
|
|
196
|
+
// the loop, matching what `start()` does internally.
|
|
197
|
+
const me = await this.client.getMe();
|
|
198
|
+
await this.client.notifyLoggedIn(me.raw);
|
|
150
199
|
await this.client.startUpdatesLoop();
|
|
151
200
|
}
|
|
152
201
|
|
|
@@ -290,18 +339,30 @@ export class Driver {
|
|
|
290
339
|
else queue.push(m);
|
|
291
340
|
};
|
|
292
341
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
342
|
+
// Defensive wrapper: a throw inside the listener propagates out of
|
|
343
|
+
// mtcute's `onNewMessage`/`onEditMessage` emitter (it does not catch
|
|
344
|
+
// listener errors) and drops the update entirely, so `observeMessages`
|
|
345
|
+
// would silently never yield it. `toObserved` is already hardened against
|
|
346
|
+
// the known `PeersIndex` throw, but any future getter that throws must
|
|
347
|
+
// NOT be allowed to swallow an observation — log and move on instead.
|
|
348
|
+
const dispatchObserved = (msg: Message, edited: boolean): void => {
|
|
349
|
+
let observed: ObservedMessage;
|
|
350
|
+
try {
|
|
351
|
+
observed = toObserved(msg, edited);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
// eslint-disable-next-line no-console -- harness diagnostic
|
|
354
|
+
console.warn(
|
|
355
|
+
`[uat/driver] observeMessages: dropping unobservable message ` +
|
|
356
|
+
`(id=${msg.id}): ${err instanceof Error ? err.message : String(err)}`,
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
301
360
|
if (observed.chatId !== chatId) return;
|
|
302
361
|
if (targetThread !== undefined && observed.threadId !== targetThread) return;
|
|
303
362
|
dispatch(observed);
|
|
304
363
|
};
|
|
364
|
+
const onNew = (msg: Message): void => dispatchObserved(msg, false);
|
|
365
|
+
const onEdit = (msg: Message): void => dispatchObserved(msg, true);
|
|
305
366
|
|
|
306
367
|
c.onNewMessage.add(onNew);
|
|
307
368
|
c.onEditMessage.add(onEdit);
|
|
@@ -838,15 +899,411 @@ export class Driver {
|
|
|
838
899
|
}
|
|
839
900
|
|
|
840
901
|
function toObserved(msg: Message, edited: boolean): ObservedMessage {
|
|
902
|
+
// Bot API 10.1 rich messages (`sendMessage` with `{ markdown }`) used to
|
|
903
|
+
// arrive as `messageMediaUnsupported` with an empty `message.message` on
|
|
904
|
+
// the pinned mtcute (0.27.x). With mtcute >=0.30 the message decodes to
|
|
905
|
+
// real text + entities, so we surface those directly. The
|
|
906
|
+
// `messageMediaUnsupported` sentinel path is KEPT as a defensive fallback:
|
|
907
|
+
// if a future Bot API / TL-layer change ever regresses the decode, the
|
|
908
|
+
// `\x01` sentinel still lets text-presence assertions fire (and the
|
|
909
|
+
// formatting scenario will flag the empty-entities case loudly rather than
|
|
910
|
+
// silently pass). It is no longer expected to trigger on the happy path.
|
|
911
|
+
const rawText = msg.text ?? "";
|
|
912
|
+
// Bot API 10.1 rich messages (`sendRichMessage({ markdown })`) arrive on the
|
|
913
|
+
// wire in a NEW TL field — `message.richMessage` — with the legacy `message`
|
|
914
|
+
// string left EMPTY and NO `entities`. mtcute 0.30 does not yet map that
|
|
915
|
+
// field onto `Message.text`/`Message.entities`, so `msg.text` is "" and the
|
|
916
|
+
// observation looks textless. This is the sibling case to the
|
|
917
|
+
// `messageMediaUnsupported` sentinel below: the visible text is on the wire,
|
|
918
|
+
// just in a shape the getter doesn't decode. Flatten the `richMessage`
|
|
919
|
+
// page-block/RichText tree ourselves into plain text + entities. (Confirmed
|
|
920
|
+
// via a raw-TL dump on the uat-host runner, #2744.)
|
|
921
|
+
const richDecoded =
|
|
922
|
+
rawText === "" && msg.raw._ === "message"
|
|
923
|
+
? decodeRichMessage((msg.raw as { richMessage?: unknown }).richMessage)
|
|
924
|
+
: null;
|
|
925
|
+
const isRichMedia = rawText === "" && !richDecoded &&
|
|
926
|
+
msg.raw._ === "message" && msg.raw.media?._ === "messageMediaUnsupported";
|
|
927
|
+
// Resolve chat/sender ids from the RAW TL peer, not the `msg.chat` /
|
|
928
|
+
// `msg.sender` getters. Those getters look the peer up in the update's
|
|
929
|
+
// `PeersIndex` and THROW `MtArgumentError` ("peer not available in this
|
|
930
|
+
// index") when it's absent — which happens for the driver because it runs
|
|
931
|
+
// on `MemoryStorage` (empty peer cache each connect), so an incoming bot
|
|
932
|
+
// reply can arrive before that peer has been cached. An unguarded throw
|
|
933
|
+
// here propagates out of the mtcute `onNewMessage`/`onEditMessage` emitter
|
|
934
|
+
// (that emitter does not catch listener errors), so the WHOLE update is
|
|
935
|
+
// dropped and `observeMessages` never yields it — the exact "bot replied
|
|
936
|
+
// fast but `expectMessage` timed out" failure. `getMarkedPeerId` reads the
|
|
937
|
+
// raw TL peer directly and never touches the index, so it can't throw.
|
|
938
|
+
const chatId = getMarkedPeerId(msg.raw.peerId);
|
|
939
|
+
const rawFrom = msg.raw._ === "message" || msg.raw._ === "messageService"
|
|
940
|
+
? msg.raw.fromId
|
|
941
|
+
: undefined;
|
|
942
|
+
const senderUserId = rawFrom
|
|
943
|
+
? getMarkedPeerId(rawFrom)
|
|
944
|
+
: chatId; // no explicit sender ⇒ the DM peer is the sender (bot reply in a DM)
|
|
841
945
|
return {
|
|
842
|
-
chatId
|
|
946
|
+
chatId,
|
|
843
947
|
messageId: msg.id,
|
|
844
948
|
threadId: msg.replyToMessage?.threadId ?? undefined,
|
|
845
|
-
text:
|
|
846
|
-
senderUserId
|
|
847
|
-
fromBot: msg
|
|
949
|
+
text: isRichMedia ? "\x01" : (richDecoded ? richDecoded.text : rawText),
|
|
950
|
+
senderUserId,
|
|
951
|
+
fromBot: safeIsFromBot(msg),
|
|
848
952
|
date: msg.date,
|
|
849
953
|
edited,
|
|
850
954
|
silent: msg.isSilent,
|
|
955
|
+
entities: isRichMedia
|
|
956
|
+
? []
|
|
957
|
+
: (richDecoded ? richDecoded.entities : toObservedEntities(msg)),
|
|
958
|
+
link: safeMessageLink(msg),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Flatten mtcute's `Message.entities` into the `ObservedEntity[]` the UAT
|
|
964
|
+
* assertions consume. Pulls the entity `url` (text_link) and `language`
|
|
965
|
+
* (pre) out of the discriminated `params` union so scenarios can assert on
|
|
966
|
+
* link destinations and fenced-block languages, not just the span kind.
|
|
967
|
+
*/
|
|
968
|
+
function toObservedEntities(msg: Message): ObservedEntity[] {
|
|
969
|
+
// Real mtcute always populates `.entities` (empty array for unformatted
|
|
970
|
+
// text). Guard against a message shape that lacks it (e.g. a hand-rolled
|
|
971
|
+
// test double) so the observation degrades to "no entities" rather than
|
|
972
|
+
// throwing and taking down the whole scenario.
|
|
973
|
+
const entities = msg.entities ?? [];
|
|
974
|
+
return entities.map((e): ObservedEntity => {
|
|
975
|
+
const params = e.params;
|
|
976
|
+
return {
|
|
977
|
+
kind: e.kind,
|
|
978
|
+
offset: e.offset,
|
|
979
|
+
length: e.length,
|
|
980
|
+
text: e.text,
|
|
981
|
+
url: params.kind === "text_link" ? params.url : undefined,
|
|
982
|
+
language: params.kind === "pre" ? params.language : undefined,
|
|
983
|
+
};
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Decode a Bot API 10.1 rich message (`message.richMessage`) into flat
|
|
989
|
+
* `{ text, entities }`, mirroring what `Message.text` + `Message.entities`
|
|
990
|
+
* would carry if mtcute mapped the field. Returns `null` when the value isn't
|
|
991
|
+
* a `richMessage` (so callers fall back to the legacy `message` string).
|
|
992
|
+
*
|
|
993
|
+
* On the wire a `richMessage` is Telegram's Instant-View page tree: a list of
|
|
994
|
+
* `pageBlock`s, each carrying a `RichText` node (or, for tables/lists,
|
|
995
|
+
* structured children). We model the block kinds Telegram's GFM parser
|
|
996
|
+
* actually emits for a chat rich message, all shapes grounded against
|
|
997
|
+
* `@mtcute/tl@223.0.0` (the TL layer mtcute 0.30 ships):
|
|
998
|
+
*
|
|
999
|
+
* - `pageBlockParagraph` — styled inline text (`.text: RichText`).
|
|
1000
|
+
* - `pageBlockPreformatted` — fenced code → one `pre` entity spanning the
|
|
1001
|
+
* whole block, carrying `.language`.
|
|
1002
|
+
* - `pageBlockHeader` / — a markdown heading (`# …` / `## …`) →
|
|
1003
|
+
* `pageBlockSubheader` rendered as a `bold` entity over the line
|
|
1004
|
+
* (Telegram has no first-class heading entity;
|
|
1005
|
+
* GFM headings surface as bold on a phone).
|
|
1006
|
+
* - `pageBlockBlockquote` / — `> …` quote → a `blockquote` entity over the
|
|
1007
|
+
* `pageBlockPullquote` quote body (`.text`; `.caption` follows on a
|
|
1008
|
+
* newline, unstyled).
|
|
1009
|
+
* - `pageBlockList` — unordered list; `.items: PageListItem[]`
|
|
1010
|
+
* (`pageListItemText.text: RichText` or
|
|
1011
|
+
* `pageListItemBlocks.blocks: PageBlock[]`),
|
|
1012
|
+
* each item rendered `• ` + inline content.
|
|
1013
|
+
* - `pageBlockOrderedList` — ordered list; `.items: PageListOrderedItem[]`
|
|
1014
|
+
* (`…Text` / `…Blocks`) each carrying a `.num`
|
|
1015
|
+
* label rendered `<num>. ` + content.
|
|
1016
|
+
* - `pageBlockTable` — `.title: RichText`, `.rows: PageTableRow[]`,
|
|
1017
|
+
* each `.cells: PageTableCell[]` with an
|
|
1018
|
+
* optional `.text: RichText` (+ `.header`).
|
|
1019
|
+
* Cells join with " | ", rows with newlines;
|
|
1020
|
+
* header cells emit a `bold` entity.
|
|
1021
|
+
* - `pageBlockDivider` — a horizontal rule (`---`) → a literal "———"
|
|
1022
|
+
* line, no entity.
|
|
1023
|
+
*
|
|
1024
|
+
* NOTE (grounding): TL layer 223 has NO `textSpoiler` RichText kind. GFM
|
|
1025
|
+
* spoiler syntax (`||…||`) surfaces on the IV wire as `textMarked`
|
|
1026
|
+
* ("highlighted text") — so `textMarked` maps to the Bot API `spoiler`
|
|
1027
|
+
* entity. `textStrike`/`textUnderline` ARE first-class RichText kinds and
|
|
1028
|
+
* map to `strikethrough`/`underline` directly.
|
|
1029
|
+
*
|
|
1030
|
+
* Blocks are joined with a newline. The RichText tree is a
|
|
1031
|
+
* discriminated union: leaf `textPlain` carries a string; `textConcat` holds a
|
|
1032
|
+
* `texts[]` sequence; the styled wrappers (`textBold`/`textItalic`/`textFixed`/
|
|
1033
|
+
* `textUrl`/…) nest another RichText in `.text`. We flatten depth-first,
|
|
1034
|
+
* tracking UTF-16 code-unit offsets (JS string `.length` is already UTF-16, the
|
|
1035
|
+
* same units Telegram uses on the wire) and emit an `ObservedEntity` for each
|
|
1036
|
+
* styled span so the render-fidelity assertions see the same entity structure
|
|
1037
|
+
* the legacy `entities` path produces.
|
|
1038
|
+
*/
|
|
1039
|
+
export function decodeRichMessage(
|
|
1040
|
+
rich: unknown,
|
|
1041
|
+
): { text: string; entities: ObservedEntity[] } | null {
|
|
1042
|
+
const rm = rich as
|
|
1043
|
+
| { _?: string; blocks?: Array<Record<string, unknown>> }
|
|
1044
|
+
| undefined;
|
|
1045
|
+
if (!rm || rm._ !== "richMessage" || !Array.isArray(rm.blocks)) return null;
|
|
1046
|
+
|
|
1047
|
+
let text = "";
|
|
1048
|
+
const entities: ObservedEntity[] = [];
|
|
1049
|
+
|
|
1050
|
+
const emit = (
|
|
1051
|
+
kind: string,
|
|
1052
|
+
start: number,
|
|
1053
|
+
extra?: { url?: string; language?: string },
|
|
1054
|
+
): void => {
|
|
1055
|
+
const length = text.length - start;
|
|
1056
|
+
if (length <= 0) return;
|
|
1057
|
+
entities.push({
|
|
1058
|
+
kind,
|
|
1059
|
+
offset: start,
|
|
1060
|
+
length,
|
|
1061
|
+
text: text.slice(start, start + length),
|
|
1062
|
+
url: extra?.url,
|
|
1063
|
+
language: extra?.language,
|
|
1064
|
+
});
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
// Depth-first walk of a RichText node, appending plain text to `text` and
|
|
1068
|
+
// pushing an entity for each styled wrapper (mapping TL kinds to mtcute's).
|
|
1069
|
+
const walk = (node: unknown): void => {
|
|
1070
|
+
const n = node as {
|
|
1071
|
+
_?: string;
|
|
1072
|
+
text?: unknown;
|
|
1073
|
+
texts?: unknown[];
|
|
1074
|
+
url?: string;
|
|
1075
|
+
};
|
|
1076
|
+
if (!n || typeof n._ !== "string") return;
|
|
1077
|
+
switch (n._) {
|
|
1078
|
+
case "textEmpty":
|
|
1079
|
+
return;
|
|
1080
|
+
case "textPlain":
|
|
1081
|
+
if (typeof n.text === "string") text += n.text;
|
|
1082
|
+
return;
|
|
1083
|
+
case "textConcat":
|
|
1084
|
+
for (const t of n.texts ?? []) walk(t);
|
|
1085
|
+
return;
|
|
1086
|
+
case "textBold": {
|
|
1087
|
+
const s = text.length;
|
|
1088
|
+
walk(n.text);
|
|
1089
|
+
emit("bold", s);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
case "textItalic": {
|
|
1093
|
+
const s = text.length;
|
|
1094
|
+
walk(n.text);
|
|
1095
|
+
emit("italic", s);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
case "textUnderline": {
|
|
1099
|
+
const s = text.length;
|
|
1100
|
+
walk(n.text);
|
|
1101
|
+
emit("underline", s);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
case "textStrike": {
|
|
1105
|
+
const s = text.length;
|
|
1106
|
+
walk(n.text);
|
|
1107
|
+
emit("strikethrough", s);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
case "textFixed": {
|
|
1111
|
+
// Inline fixed-width (`code_token`) → Bot API `code` entity.
|
|
1112
|
+
const s = text.length;
|
|
1113
|
+
walk(n.text);
|
|
1114
|
+
emit("code", s);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
case "textMarked": {
|
|
1118
|
+
// TL layer 223 has no `textSpoiler`; GFM `||spoiler||` rides the
|
|
1119
|
+
// IV "highlighted text" node (`textMarked`) → Bot API `spoiler`.
|
|
1120
|
+
const s = text.length;
|
|
1121
|
+
walk(n.text);
|
|
1122
|
+
emit("spoiler", s);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
case "textUrl": {
|
|
1126
|
+
const s = text.length;
|
|
1127
|
+
walk(n.text);
|
|
1128
|
+
emit("text_link", s, { url: n.url });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
case "textEmail": {
|
|
1132
|
+
const s = text.length;
|
|
1133
|
+
walk(n.text);
|
|
1134
|
+
emit("email", s);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
default:
|
|
1138
|
+
// Any other wrapper (marked/sub/superscript/phone/anchor/…) — keep its
|
|
1139
|
+
// visible text but don't classify the span; the render assertions only
|
|
1140
|
+
// key off the kinds the gateway emits.
|
|
1141
|
+
if (n.text !== undefined) walk(n.text);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
851
1144
|
};
|
|
1145
|
+
|
|
1146
|
+
// Emit a span that Telegram has no first-class entity for (heading, table
|
|
1147
|
+
// header) but which renders styled on a phone — we pick the closest Bot API
|
|
1148
|
+
// entity so the render-fidelity assertions can key off it.
|
|
1149
|
+
const emitOver = (kind: string, start: number): void => emit(kind, start);
|
|
1150
|
+
|
|
1151
|
+
// Walk one IV page-block, appending its text to `text` and pushing entities.
|
|
1152
|
+
// Recursive: lists/tables nest paragraph-like children.
|
|
1153
|
+
const walkBlock = (block: Record<string, unknown> | undefined): void => {
|
|
1154
|
+
if (!block || typeof block._ !== "string") return;
|
|
1155
|
+
switch (block._) {
|
|
1156
|
+
case "pageBlockPreformatted": {
|
|
1157
|
+
// Fenced code block → one `pre` entity spanning the whole block, with
|
|
1158
|
+
// the language string Telegram tagged the fence with.
|
|
1159
|
+
const s = text.length;
|
|
1160
|
+
walk(block.text);
|
|
1161
|
+
const length = text.length - s;
|
|
1162
|
+
if (length > 0) {
|
|
1163
|
+
entities.push({
|
|
1164
|
+
kind: "pre",
|
|
1165
|
+
offset: s,
|
|
1166
|
+
length,
|
|
1167
|
+
text: text.slice(s, s + length),
|
|
1168
|
+
url: undefined,
|
|
1169
|
+
language: (block.language as string) || undefined,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
case "pageBlockHeader":
|
|
1175
|
+
case "pageBlockSubheader": {
|
|
1176
|
+
// GFM heading (`#`/`##`) → no first-class Telegram heading entity;
|
|
1177
|
+
// it renders bold on a phone, so we model it as a `bold` span.
|
|
1178
|
+
const s = text.length;
|
|
1179
|
+
walk(block.text);
|
|
1180
|
+
emitOver("bold", s);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
case "pageBlockBlockquote":
|
|
1184
|
+
case "pageBlockPullquote": {
|
|
1185
|
+
// `> …` quote → `blockquote` entity over the quote body. `.caption`
|
|
1186
|
+
// (if any) follows on its own line, unstyled.
|
|
1187
|
+
const s = text.length;
|
|
1188
|
+
walk(block.text);
|
|
1189
|
+
emitOver("blockquote", s);
|
|
1190
|
+
const caption = block.caption as Record<string, unknown> | undefined;
|
|
1191
|
+
if (caption && caption._ && caption._ !== "textEmpty") {
|
|
1192
|
+
text += "\n";
|
|
1193
|
+
walk(caption);
|
|
1194
|
+
}
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
case "pageBlockList": {
|
|
1198
|
+
// Unordered list. Items are pageListItemText (RichText) or
|
|
1199
|
+
// pageListItemBlocks (nested page blocks); each gets a "• " bullet.
|
|
1200
|
+
const items = (block.items as Array<Record<string, unknown>>) ?? [];
|
|
1201
|
+
items.forEach((item, i) => {
|
|
1202
|
+
if (i > 0) text += "\n";
|
|
1203
|
+
text += "• ";
|
|
1204
|
+
if (item._ === "pageListItemText") {
|
|
1205
|
+
walk(item.text);
|
|
1206
|
+
} else if (item._ === "pageListItemBlocks") {
|
|
1207
|
+
const blocks = (item.blocks as Array<Record<string, unknown>>) ?? [];
|
|
1208
|
+
blocks.forEach((b, j) => {
|
|
1209
|
+
if (j > 0) text += "\n";
|
|
1210
|
+
walkBlock(b);
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
case "pageBlockOrderedList": {
|
|
1217
|
+
// Ordered list. Items carry a `.num` label ("1", "2", …).
|
|
1218
|
+
const items = (block.items as Array<Record<string, unknown>>) ?? [];
|
|
1219
|
+
items.forEach((item, i) => {
|
|
1220
|
+
if (i > 0) text += "\n";
|
|
1221
|
+
text += `${(item.num as string) || String(i + 1)}. `;
|
|
1222
|
+
if (item._ === "pageListOrderedItemText") {
|
|
1223
|
+
walk(item.text);
|
|
1224
|
+
} else if (item._ === "pageListOrderedItemBlocks") {
|
|
1225
|
+
const blocks = (item.blocks as Array<Record<string, unknown>>) ?? [];
|
|
1226
|
+
blocks.forEach((b, j) => {
|
|
1227
|
+
if (j > 0) text += "\n";
|
|
1228
|
+
walkBlock(b);
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
case "pageBlockTable": {
|
|
1235
|
+
// `.title` (optional) on its own line, then rows joined by newline,
|
|
1236
|
+
// cells within a row joined by " | ". Header cells emit a `bold` span.
|
|
1237
|
+
const title = block.title as Record<string, unknown> | undefined;
|
|
1238
|
+
let wroteTitle = false;
|
|
1239
|
+
if (title && title._ && title._ !== "textEmpty") {
|
|
1240
|
+
walk(title);
|
|
1241
|
+
wroteTitle = true;
|
|
1242
|
+
}
|
|
1243
|
+
const rows = (block.rows as Array<Record<string, unknown>>) ?? [];
|
|
1244
|
+
rows.forEach((row, ri) => {
|
|
1245
|
+
if (ri > 0 || wroteTitle) text += "\n";
|
|
1246
|
+
const cells = (row.cells as Array<Record<string, unknown>>) ?? [];
|
|
1247
|
+
cells.forEach((cell, ci) => {
|
|
1248
|
+
if (ci > 0) text += " | ";
|
|
1249
|
+
const s = text.length;
|
|
1250
|
+
if (cell.text !== undefined) walk(cell.text);
|
|
1251
|
+
if (cell.header) emitOver("bold", s);
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
case "pageBlockDivider": {
|
|
1257
|
+
// Horizontal rule (`---`). No entity; a literal rule line.
|
|
1258
|
+
text += "———";
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
default:
|
|
1262
|
+
// pageBlockParagraph, footer, kicker, title, subtitle, and any other
|
|
1263
|
+
// text-bearing block — inline walk of `.text`.
|
|
1264
|
+
walk(block.text);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
for (const block of rm.blocks) {
|
|
1270
|
+
if (text.length > 0) text += "\n"; // join blocks in send order
|
|
1271
|
+
walkBlock(block);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return { text, entities };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* `Message.link` throws (`MtArgumentError`) for chats that don't support
|
|
1279
|
+
* message permalinks — notably private DMs, which is exactly where most
|
|
1280
|
+
* UAT scenarios run. Swallow that and leave the field unset rather than
|
|
1281
|
+
* blow up the whole observation; group/channel messages still get a real
|
|
1282
|
+
* `t.me/c/<chat>/<msgid>` link for human eyeball reference.
|
|
1283
|
+
*/
|
|
1284
|
+
function safeMessageLink(msg: Message): string | undefined {
|
|
1285
|
+
try {
|
|
1286
|
+
return msg.link;
|
|
1287
|
+
} catch {
|
|
1288
|
+
return undefined;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Whether the message was sent by a bot. Reads `msg.sender`, which resolves
|
|
1294
|
+
* the peer via the update's `PeersIndex` and THROWS `MtArgumentError` when
|
|
1295
|
+
* that peer isn't cached (the driver's `MemoryStorage` starts empty, so an
|
|
1296
|
+
* incoming reply can arrive before its sender is cached). We only need
|
|
1297
|
+
* `fromBot` for coarse classification, and `expectMessage`'s `from: "bot"`
|
|
1298
|
+
* filter keys off `senderUserId` (resolved from the raw peer above), not this
|
|
1299
|
+
* flag — so a swallowed lookup degrades to `false` rather than dropping the
|
|
1300
|
+
* whole observation. Group/DM messages with a cached sender still classify
|
|
1301
|
+
* correctly.
|
|
1302
|
+
*/
|
|
1303
|
+
function safeIsFromBot(msg: Message): boolean {
|
|
1304
|
+
try {
|
|
1305
|
+
return msg.sender.type === "user" && msg.sender.isBot === true;
|
|
1306
|
+
} catch {
|
|
1307
|
+
return false;
|
|
1308
|
+
}
|
|
852
1309
|
}
|
|
@@ -49,16 +49,28 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
49
49
|
timeout: 60_000,
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
// ── 1.
|
|
52
|
+
// ── 1. Main page → External page navigation ────────────────────
|
|
53
|
+
// Nested-page design: the main page carries the Claude buttons + a
|
|
54
|
+
// "🌐 External models ▸" button; sr-* models and the billed-separately
|
|
55
|
+
// header live on the second page, reached by pressing mdl:page:ext.
|
|
56
|
+
const mainKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
57
|
+
const mainFlat = (mainKb ?? []).flat().filter((b) => b.callbackData);
|
|
58
|
+
const extOpen = mainFlat.find((b) => b.callbackData === "mdl:page:ext");
|
|
59
|
+
expect(extOpen, "🌐 External models ▸ open button on the main page").toBeDefined();
|
|
60
|
+
expect(menu.text).toContain("Max/Pro subscription");
|
|
61
|
+
|
|
62
|
+
// Open the external page and re-read the keyboard.
|
|
63
|
+
await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:page:ext");
|
|
64
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
65
|
+
const extMsg = await sc.driver.getMessage(sc.botUserId, menu.messageId);
|
|
66
|
+
expect(extMsg?.text ?? "", "external page body").toContain("billed separately");
|
|
53
67
|
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
54
68
|
const flat = (kb ?? []).flat().filter((b) => b.callbackData);
|
|
55
69
|
|
|
56
|
-
const
|
|
57
|
-
(b) => b.text.includes("
|
|
58
|
-
);
|
|
59
|
-
const openrouterHeader = flat.find(
|
|
60
|
-
(b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
|
|
70
|
+
const externalHeader = flat.find(
|
|
71
|
+
(b) => b.text.includes("External") && b.callbackData === "mdl:h",
|
|
61
72
|
);
|
|
73
|
+
const backButton = flat.find((b) => b.callbackData === "mdl:page:main");
|
|
62
74
|
// Prefer deepseek-v3 (non-thinking, consistently fast) for the E2E test.
|
|
63
75
|
// gemini-2.5-flash may run in thinking mode via OpenRouter (5+ min latency),
|
|
64
76
|
// reasoning models (deepseek-r1, o1, o3) also take 2-5 min and hit the
|
|
@@ -70,14 +82,12 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
70
82
|
flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
|
|
71
83
|
|
|
72
84
|
if (!srButton) {
|
|
73
|
-
console.log("No sr-* buttons
|
|
85
|
+
console.log("No sr-* buttons on the external page — agent not LiteLLM-enabled or no sr-* models registered. Skipping.");
|
|
74
86
|
return;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
79
|
-
expect(menu.text).toContain("Max/Pro subscription");
|
|
80
|
-
expect(menu.text).toContain("OpenRouter");
|
|
89
|
+
expect(externalHeader, "External (billed separately) header row").toBeDefined();
|
|
90
|
+
expect(backButton, "◂ Back button on the external page").toBeDefined();
|
|
81
91
|
|
|
82
92
|
// ── 2. sr-* switch ─────────────────────────────────────────────
|
|
83
93
|
const spendBefore = await getLiteLLMSpendForAgent(AGENT);
|
|
@@ -146,15 +156,25 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
|
|
|
146
156
|
from: "bot",
|
|
147
157
|
timeout: 90_000,
|
|
148
158
|
});
|
|
159
|
+
// The header row now lives on the external page — navigate there first.
|
|
160
|
+
const mainKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
161
|
+
const extOpen = (mainKb ?? []).flat().find((b) => b.callbackData === "mdl:page:ext");
|
|
162
|
+
if (!extOpen) {
|
|
163
|
+
console.log("No External page — agent not LiteLLM-enabled. Skipping.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:page:ext");
|
|
167
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
168
|
+
const extMsg = await sc.driver.getMessage(sc.botUserId, menu.messageId);
|
|
149
169
|
const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
|
|
150
170
|
const flat = (kb ?? []).flat();
|
|
151
171
|
const headerBtn = flat.find((b) => b.callbackData === "mdl:h");
|
|
152
172
|
if (!headerBtn) {
|
|
153
|
-
console.log("No header row — agent not LiteLLM-enabled. Skipping.");
|
|
173
|
+
console.log("No header row on external page — agent not LiteLLM-enabled. Skipping.");
|
|
154
174
|
return;
|
|
155
175
|
}
|
|
156
|
-
// Pressing the header should NOT change the menu text
|
|
157
|
-
const textBefore =
|
|
176
|
+
// Pressing the header should NOT change the (external-page) menu text
|
|
177
|
+
const textBefore = extMsg?.text ?? "";
|
|
158
178
|
await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:h");
|
|
159
179
|
await new Promise((r) => setTimeout(r, 3_000));
|
|
160
180
|
const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
|