Skip to main content
The workflow:
  1. Record a conversation (in tests or in production) or build it from the store message log.
  2. Redact it so no personal data ends up in the repository.
  3. Save it as JSON.
  4. 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.
The handle exposes 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.
Options: 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 optionally event ('message' or 'interaction', where text holds the button/list data), isGroup, displayName, id and t.
  • Outbound turns (dir: 'out'): chatId, kind, text (text, caption, question…), and optionally content (the full JSON-safe content, only when recorded live; binaries become { $binary: { bytes, sha256 } }), id and t.
  • t is 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)

Experimental: private package@botmesh/deployments is private (not on npm) and its API may change. It is persistent: MemoryDeploymentStore is the reference and KvDeploymentStore persists over any DurableKv (SQLite, PostgreSQL, Redis, file). Gate runs are recorded in an audit log.
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, publish rejects with a DeploymentGateError (candidate, result) and nothing is stored, so the same version can be fixed and published again.
With a gate, 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.

See also