- Record a conversation (in tests or in production) or build it from the store message log.
- Redact it so no personal data ends up in the repository.
- Save it as JSON.
- Replay it in a test (or in a deployment gate) and fail on any diff.
The recording, redaction and format helpers are also re-exported by
@botmesh/core, marked
@deprecated: import them from @botmesh/core/testing (recording works in production too).
Record a conversation
recordTranscript(provider, options?) wraps any IProvider (a real transport
or a MockProvider) in a transparent RecordingProvider. Give
recorder.provider to the bot; it records inbound messages and interactions
and every successful send.
transcript({ name?, description? }) (a snapshot),
clear(), pause() and resume().
In a test built with createTestBot, chat.transcript() returns the same shape
straight from the mock (see Conversation snapshots).
Failed or skipped sends are not recorded. Recording also works in production
(
RecordingProvider wraps a real transport as well as the mock), but the buffer keeps message
bodies, display names and ids unredacted in RAM: keep maxTurns bounded
and call clear() once you have taken a transcript.
From the store message log
transcriptFromStore(source, chatIds, options?) reads one or more chats from
getMessagesByChat and merges them into one transcript ordered by timestamp.
Pass the bot (or bot.getStore()): a Bot writes to a namespaced view of
the store you give it, so the raw store finds nothing under the plain chat
ids.
name, limit (per chat, default 1000), includeFailed (keep
outbound messages with status failed, default false) and isGroup (a flag
or (chatId) => boolean; when omitted, transcriptFromStore reads the chat
kind from the store). transcriptFromMessages(messages, options?) does the
same with an array of StoredMessage.
The store keeps no full outbound content and no display names, so these
transcripts compare only kind and text, and replays use the mock’s default
push name.
The file format
- Inbound turns (
dir: 'in'):chatId,senderId,type,text, and optionallyevent('message'or'interaction', wheretextholds the button/list data),isGroup,displayName,idandt. - Outbound turns (
dir: 'out'):chatId,kind,text(text, caption, question…), and optionallycontent(the full JSON-safe content, only when recorded live; binaries become{ $binary: { bytes, sha256 } }),idandt. tis milliseconds since the first turn;startedAt(epoch ms) is optional and redaction drops it.- Ids are opaque strings: nothing parses them.
serializeTranscript(t) writes stable, diff-friendly JSON (2-space indent,
trailing newline). parseTranscript(input) (a JSON string or an already-parsed
object) validates the shape and version and throws TranscriptFormatError otherwise.
createTranscript(turns, meta?) builds one by hand.
Redaction
redactTranscript(transcript, options?) returns a redacted copy with stable
placeholders numbered by first appearance, so the result is deterministic and
redacting twice changes nothing.
- Ids become
<user:N>,<chat:N>(group chats) and<msg:N>. User and chat ids of 4+ characters are also replaced inside texts (message ids only as whole fields). - Display names become
<name:N>, in the field and inside any text (so a reply that greets the user by name is redacted too). On replay the placeholder is sent as the push name, so the reply still matches. - Emails and phone numbers (7+ digits) in inbound texts become
<email:N>/<phone:N>. Outbound texts only get the values already learned from the inbound side (data the bot echoes back): prices, dates or order numbers the bot prints on its own stay as they are, because the replay compares them against the real values.
Replay and assert
replayTranscript(transcript, createBot, options?) builds a fresh bot around
an in-memory MockProvider, feeds it the inbound turns one by one and diffs
what it sends back against the recorded outbound turns. createBot receives
the provider and must return the bot without starting it; the replay
starts and stops it.
assertTranscript throws a TranscriptMismatchError (with .result) whose
message is the report. replayTranscript returns a ReplayResult instead:
{ ok, diffs, expected, actual, report() }, where actual is the
conversation the bot produced. Each TranscriptDiff has a kind
('missing', 'unexpected', 'mismatch' or 'timing'), the step (index of
the inbound turn, -1 before the first one), chatId, position, the
expected and actual turns, the first differing field ('kind',
'text', 'content.buttons.0.text', 't', 'id') and a message.
formatReplayReport(transcript, diffs) renders the same report.
After each inbound turn the replay waits until the bot has sent at least as
many messages as expected and then stays quiet for settleMs, so both missing
and extra replies are detected.
Tolerance options
REPLAY_MASKS has ready-made masks: isoDate, time, uuid and number.
Publish gate (@botmesh/deployments)
DeploymentRegistry keeps immutable versions per tenant and bot with exactly
one active version (publish, active, rollback, list).
publish(tenantId, botId, version, artifact, { gate }) runs the gate
before activating the version:
- if it passes, the version is stored and activated with
deployment.gate = { passed: true, checkedAt, summary? }; - if it fails or throws,
publishrejects with aDeploymentGateError(candidate,result) and nothing is stored, so the same version can be fixed and published again.
publish returns a Promise, and the version is reserved while
the gate runs (a duplicate publish fails with deployment version already exists). A gate is any (candidate) => boolean | GateResult function, sync or
async; GateResult is { ok, reason?, summary?, details? }.
goldenTranscriptGate(options) replays golden transcripts against the
candidate and passes only if all of them match. Each transcript runs against a
fresh bot.
On failure,
error.result.details.results lists
{ name, ok, diffs, report } per transcript.