@ticketpm/core
v0.0.6
Published
Core transcript tooling for ticket.pm uploads.
Readme
@ticketpm/core
Shared transcript contract for ticket.pm.
What this package owns
- Typed transcript structures used by the upload API and the viewer.
- Deterministic compaction and canonical JSON serialization.
- Upload-contract validation and viewer-compatibility validation.
- ZSTD compression helpers.
ticket.pmtranscript upload client.m.ticket.pmmedia proxy client plus URL rewriting helpers.
Runtime support
- Bun is preferred automatically when the package runs on Bun.
- Node.js is also supported for the shared package surface, including ZSTD compression through
node:zlib.
Install
bun add @ticketpm/corenpm install @ticketpm/coreQuick example
import {
TicketPmUploadClient,
type TranscriptBuildInput
} from "@ticketpm/core";
const draftTranscript: TranscriptBuildInput = {
context: {
channel_id: "123",
channels: {
"123": { name: "support" }
},
users: {
"789": {
id: "789",
username: "alice",
avatar: "a_discord_hash"
}
}
},
messages: [
{
id: "456",
timestamp: "2026-03-18T12:00:00.000Z",
author: {
id: "789",
username: "alice"
},
content: "hello",
attachments: [
{
id: "1",
filename: "image.png",
size: 123,
url: "https://cdn.discordapp.com/attachments/1/2/image.png"
}
]
}
]
};
const uploadClient = new TicketPmUploadClient({
baseUrl: "https://api.ticket.pm/v2",
token: process.env.TICKETPM_TOKEN
});
const result = await uploadClient.uploadDraftTranscript(draftTranscript);
console.log(result.id);uploadDraftTranscript() auto-creates a TicketPmMediaProxyClient when you do
not pass one explicitly. The default auto-created client uses:
- base URL:
https://m.ticket.pm/v2 - token: the same token configured on
TicketPmUploadClient - fetch: the same fetch implementation configured on
TicketPmUploadClient
Quick example with a custom media proxy
If you want a different media proxy base URL or token, pass a custom client.
import {
TicketPmMediaProxyClient,
TicketPmUploadClient,
type TranscriptBuildInput
} from "@ticketpm/core";
const draftTranscript: TranscriptBuildInput = {
context: {
channel_id: "123",
channels: {
"123": { name: "support" }
},
users: {
"789": {
id: "789",
username: "alice",
avatar: "a_discord_hash"
}
}
},
messages: [
{
id: "456",
timestamp: "2026-03-18T12:00:00.000Z",
author: {
id: "789",
username: "alice",
avatar: "a_discord_hash"
},
content: "hello",
attachments: [
{
id: "1",
filename: "image.png",
size: 123,
url: "https://cdn.discordapp.com/attachments/1/2/image.png"
}
]
}
]
};
const uploadClient = new TicketPmUploadClient({
baseUrl: "https://api.ticket.pm/v2",
token: process.env.TICKETPM_TOKEN
});
const mediaProxy = new TicketPmMediaProxyClient({
baseUrl: "https://media.example.com/v2",
token: process.env.MEDIA_PROXY_TOKEN
});
const result = await uploadClient.uploadDraftTranscript(draftTranscript, {
mediaProxy
});
console.log(result.id);Quick example without any media proxy
If you want to skip attachment, avatar, and guild icon proxying entirely, turn it off for the upload call.
import {
TicketPmUploadClient,
type TranscriptBuildInput
} from "@ticketpm/core";
const draftTranscript: TranscriptBuildInput = {
context: {
channel_id: "123",
channels: {
"123": { name: "support" }
}
},
messages: [
{
id: "456",
timestamp: "2026-03-18T12:00:00.000Z",
content: "hello",
attachments: [
{
id: "1",
filename: "image.png",
size: 123,
url: "https://cdn.discordapp.com/attachments/1/2/image.png"
}
]
}
]
};
const uploadClient = new TicketPmUploadClient({
baseUrl: "https://api.ticket.pm/v2",
token: process.env.TICKETPM_TOKEN
});
const result = await uploadClient.uploadDraftTranscript(draftTranscript, {
mediaProxy: false
});
console.log(result.id);In this mode, the original transcript media fields are uploaded as-is and no media proxy calls are made.
Core workflow
Most integrations follow this order:
- Normalize source messages into the draft transcript shape.
- Optionally proxy media and avatar assets.
uploadDraftTranscript()does this automatically unless you setmediaProxy: false. - Build the compact stored transcript with
buildStoredTranscript(). - Optionally validate it with
validateTicketPmUploadPayload()andvalidateViewerCompatibility(). - Compress it with
compressStoredTranscript(). - Upload it with
TicketPmUploadClient.
Public API
Transcript building
buildStoredTranscript(input)compacts a draft transcript into the viewer/upload format expected byticket.pm.sortMessagesChronologically(messages)sorts newest-first collections into stable oldest-first order.pruneForExport(value)removes empty structures and nullish values using the same rules as the compact export path.
Validation
validateTicketPmUploadPayload(transcript)checks the hard upload contract.validateViewerCompatibility(transcript)checks the softer viewer hydration contract.validateTranscriptUrls(payload)walks transcript-like payloads and validates media/link safety.
Serialization and compression
stringifyCanonicalJson(value)sorts object keys deterministically.serializeStoredTranscript(transcript)converts a stored transcript into upload-ready JSON bytes.compressBytesZstd(bytes, options)compresses arbitrary bytes.compressStoredTranscript(transcript, options)canonicalizes and compresses in one step.
Uploading
TicketPmUploadClientuploads compressed transcript bytes or full transcripts toPOST /upload.TicketPmUploadClient.uploadDraftTranscript()proxies draft assets, builds the stored transcript, compresses it, and uploads it in one step.TicketPmMediaProxyClientuploads avatar hashes, guild icon hashes, and attachment/media URLs to a media proxy.
Media proxy configuration
To use a custom media proxy, set baseUrl when constructing TicketPmMediaProxyClient.
const mediaProxy = new TicketPmMediaProxyClient({
baseUrl: "https://media.example.com/v2",
token: process.env.MEDIA_PROXY_TOKEN
});Behavior notes:
baseUrlis the root used for both upload endpoints and generated proxy URLs.uploadAvatarHash()callsPOST {baseUrl}/avatars/upload.uploadGuildIconHash()callsPOST {baseUrl}/icons/upload.uploadAttachmentUrl()callsPOST {baseUrl}/attachments/upload.- Successful attachment uploads produce
{baseUrl}/attachments/{hash}. - Successful avatar and icon uploads produce
{baseUrl}/avatars/{hash}and{baseUrl}/icons/{hash}URLs.
Failure behavior and fallbacks
This package is intentionally conservative when the media proxy is unavailable.
Attachment and embed media
When rewriteTranscriptMediaUrlsInPlace() or proxyTranscriptAssetsInPlace() tries to proxy media URLs:
- If the media proxy request succeeds and returns a valid hash,
proxy_urlorproxy_icon_urlis written. - If the proxy request fails, returns a non-2xx response, or returns an invalid payload, nothing is overwritten.
- The original Discord
url, existingproxy_url,icon_url, orproxy_icon_urlis kept as-is.
If the media proxy is down, the package falls back by not replacing the transcript field, so Discord-hosted media URLs remain in the payload.
Avatars
proxyTranscriptAvatarsInPlace() uploads avatar hashes only as a cache/warm-up side effect.
user.avataris never replaced with a proxy URL.- If avatar upload fails, the transcript is unchanged.
- This is required because the current viewer still expects
user.avatarto be the original Discord avatar hash.
Guild icons
proxyGuildIconInPlace() behaves differently from user avatars:
guild.iconremains the original hash.guild.proxy_icon_urlis set only on successful proxy upload.- If the upload fails,
guild.proxy_icon_urlis left unchanged.
No automatic retries
The core package does not implement retry/backoff logic for media or transcript uploads. If your environment needs retries, wrap the provided clients with your own retry policy.
Runtime selection for compression
Compression helpers prefer Bun automatically, but you can also force a specific runtime path:
await compressBytesZstd(bytes, { runtime: "auto" });
await compressBytesZstd(bytes, { runtime: "bun" });
await compressBytesZstd(bytes, { runtime: "node" });Behavior notes:
runtime: "auto"prefers Bun and falls back to Node.runtime: "bun"throws if Bun is not available.runtime: "node"uses the Node fallback even when running on Bun.
Upload client configuration
TicketPmUploadClient accepts:
baseUrl: transcript API root such ashttps://api.ticket.pm/v2token: optional bearer token or raw token stringfetch: optional custom fetch implementationdefaultMediaProxyBaseUrl: optional override for the auto-created media proxy client used byuploadDraftTranscript()
Example:
const uploadClient = new TicketPmUploadClient({
baseUrl: "https://api.ticket.pm/v2",
token: process.env.TICKETPM_TOKEN,
fetch: customFetch,
defaultMediaProxyBaseUrl: "https://m.ticket.pm/v2"
});Important:
uploadCompressedTranscript()anduploadTranscript()do not touch media proxying because they operate on already-built data.uploadDraftTranscript()auto-creates aTicketPmMediaProxyClientwhenmediaProxyis omitted.- The auto-created media proxy client inherits the uploader token and fetch implementation.
- The auto-created media proxy client defaults to
https://m.ticket.pm/v2, unlessdefaultMediaProxyBaseUrloverrides it. - If you pass an explicit
TicketPmMediaProxyClient, that client is used as-is instead of the auto-created one. - If you pass
mediaProxy: false, media proxying is disabled for that upload.
Example:
const token = process.env.TICKETPM_TOKEN;
const uploadClient = new TicketPmUploadClient({
baseUrl: "https://api.ticket.pm/v2",
token
});
await uploadClient.uploadDraftTranscript(draftTranscript);Media proxy client configuration
TicketPmMediaProxyClient accepts:
baseUrl: media API root such ashttps://m.ticket.pm/v2token: optional bearer token or raw token stringfetch: optional custom fetch implementation
Important compatibility notes
context.channel_idandcontext.channels[context.channel_id].nameare required for upload compatibility.- Canonical JSON ordering matters because server-side dedupe hashes decompressed bytes, not semantic JSON.
- Viewer compatibility is stricter than upload acceptance. A payload can upload successfully and still hydrate poorly if compact IDs are missing corresponding context entries.
user.avatarshould stay a Discord avatar hash, not a proxy URL.
