A three-year group chat is a knowledge base nobody can read. Somewhere in it is
how long the tax office actually took, which form replaced the old one, which
accountant people quietly stopped recommending. Telegram's search finds a word
you already know. It cannot answer a question.
The fix is boring in outline: get the history out, turn it into documents, hand
them to something that reads — NotebookLM, in my case. I built that pipeline for
real chats. The parsing has traps, and I list them below, but the design
problem is elsewhere: packing, and making the second run idempotent.
Two clients, two shapes
Export exists in exactly two places. Telegram Desktop
has had it since 2018 and offers JSON or HTML. The native
Telegram for macOS app — the Mac-only client, not
Desktop — added Export Chat History… in 12.10 (24 August 2026) and writes
HTML only; the Mac App Store build was still on 12.9, without the menu item,
at the end of August. Telegram Web and the phone apps have nothing.
So you have to read both formats:
-
JSON — one
result.jsonwith the chat'snameat the root and amessagesarray:{id, date, from, text, text_entities}per message. -
HTML — paginated.
messages.html,messages2.html,messages3.html, one page per file, each a few MB.
text is not a string
In the JSON export, a plain message has text: "hello". A message with a link,
a bold run or a code span has an array of runs:
"text": ["see ", { "type": "bold", "text": "section 4" }, " first"]
String(msg.text) on that gives "[object Object]" in the middle of your
document, and it does it silently. Join the runs instead:
// Telegram writes a formatted message as
// text: ["plain ", {type: "bold", text: "…"}, …] — String() gives "[object Object]".
function contentValueToString(v: unknown): string {
if (v === undefined || v === null) return '';
if (Array.isArray(v)) {
return v
.map((x) =>
x !== null && typeof x === 'object' && 'text' in x && typeof (x as { text: unknown }).text === 'string'
? (x as { text: string }).text
: contentValueToString(x),
)
.join('');
}
if (typeof v === 'object') return renderTree(v, 0, new WeakSet());
return String(v);
}
renderTree is the generic object-to-tree renderer for anything that is not a
run of text.
Three traps in the HTML export
I read the HTML pages with string splits and regexes, not DOMParser. The files
run to several megabytes and I need three fields per message; building a full
DOM for that is paying for a tree nobody walks.
Three things bite, and all three are invisible on a small test export:
The sender is sticky. Telegram prints from_name only on the first
message of a run by the same person. Consecutive messages have no author at all,
so you carry the last one forward.
A forwarded message nests a second author. Desktop puts the original
sender's from_name inside <div class="forwarded body">. Match the author on
the chunk naively and every forward is attributed to the person it was forwarded
from:
// The message author must come from before the nested forwarded original.
const [head, fwdBody] = chunk.split('<div class="forwarded body">');
const fromMatch = head.match(/<div class="from_name">\s*([\s\S]*?)\s*<\/div>/);
if (fromMatch) lastFrom = decodeEntities(fromMatch[1]);
The two clients print dates differently. macOS writes
9 September 2020, 18:44:51; Desktop writes 09.09.2020 18:44:51 UTC+01:00.
Both get normalised to ISO — not for tidiness, but because the incremental
cursor further down compares dates lexicographically, and neither of those forms
sorts.
The actual constraint: packing
Here is where the naive version loses. NotebookLM caps a source at 500,000
words — but the number of sources per notebook is the scarcer cap, and it
depends on your plan. So the expensive mistake is not exceeding a limit, it is
under-filling: feeding the pages in one at a time gives you one undersized source
per page and burns the budget that actually runs out.
Which makes it a greedy bin-pack: fill each file to a word budget (400,000 by
default — margin under the hard 500,000), never split a message across two
files. Two parts of that were not obvious to me until they broke.
Measure the rendered Markdown, not the raw text. The separators between
messages and the file's own frontmatter count toward the limit. And frontmatter
is written after packing, when the file's index and range are known — so the
packer renders it with maximally wide placeholders (part: 999/999,
range: 99999-99999) and reserves that. The real values are shorter or equal,
so the final render cannot exceed what was already checked.
Re-rendering per record is O(n²). The obvious loop — add a message,
re-render the batch, count words — is quadratic in batch size, and with a
generous budget the batch stays huge. On 20k records the popup visibly hung.
Fix: a running word sum for the current batch plus per-record counts
memoised in a WeakMap, so adding a message is O(1).
The second run: idempotence with no local state
Chats grow. Next month you export again and you want only the new messages
uploaded, with no duplicates to delete by hand.
The tempting design is a local watermark — store the last message date, filter
by it. It desynchronises constantly: someone deletes a source by hand, a second
dataset lands in the same notebook, the extension gets reinstalled, the same
export goes into a different notebook. Every one of those leaves the local
number describing a world that no longer exists.
So the state lives in the destination instead. Each generated filename carries
its index, the date of its last message and, as a slug, the id of its first:
nomad-001-2023-06-01t16-05-52-1.md
On the next run, read the source names already in the notebook, invert the
filename pattern, recover the highest index and the last cursor, and keep only
the records strictly after it. The notebook is the watermark. Re-exporting an
overlapping date range is harmless, because filtering happens per record, not
per file.
One detail worth stealing: cursors are compared in their slugified form, as they
sit in the filename, which means a numeric cursor has to be padded.
const NUMERIC_RE = /^\d+$/;
// A purely numeric cursor is zero-padded (otherwise '4037' > '11280');
// an ISO-date slug '2025-04-07t12-56-18' compares correctly as-is.
export function cursorKey(cursor: string): string {
return NUMERIC_RE.test(cursor) ? cursor.padStart(20, '0') : cursor;
}
The one rule this design imposes on the user: don't rename the files in the
notebook. The name is the state.
A real run
One expat group chat, exported to JSON: result.json, 19.78 MB. Packed into
four Markdown files, 10.64 MB total, uploaded in one pass.
-
nomad-001-2023-06-01t16-05-52-1.md— 399,970 words, 1128 messages -
nomad-002-2024-06-21t16-48-00-1308.md— 399,118 words, 908 messages -
nomad-003-…,nomad-004-…
Read the two columns against each other: the word counts converge on the budget
while the message counts differ by a fifth, because the budget is words and a
file closes at the last whole message that fits. The last message of each file
runs from 2023-06-01 to 2026-08-20 — three years of chat in four sources.
Then you ask it things a word search cannot answer: which providers were
recommended more than once and by whom; what changed over time about registering
as a sole trader; which questions came up repeatedly and never got an answer.
Citations point back into the Markdown, where the sender and the reply chain sit
in a metadata block under each message.
Where this runs
The whole conversion happens in a Chrome extension popup. Files are built in
memory and posted to the notebook's own origin, using the session you are
already signed into. Nothing is written to disk, and nothing goes to a server of
ours — there isn't one.
What does not survive
- Text only. A photo or voice message becomes a placeholder or a file path in metadata; captions come through.
- Formatting is flattened — a link keeps its words, not its URL.
- Service messages (joins, pins, renames) come through from JSON as near-empty entries and are dropped from HTML. Polls are dropped from HTML: a poll message has neither a text block nor a media block, and a message with neither is skipped.
-
Forwards from HTML are marked in the message body (
Forwarded from Name:) rather than in metadata. - NotebookLM's own caps still apply — the per-plan source count is on Google's side; the 400,000-word budget keeps each file under the per-source limit. Numbers per plan: NotebookLM limits.
If you just want the thing
It ships as source-lm on the Chrome Web Store; the source is on GitHub.
The free tier is 5 bulk uploads a calendar month — one chat export is one — and after that it's $29 once.
The click-by-click version of this post, with the export dialogs for both clients, is on the site.
Not affiliated with Google. NotebookLM and Gemini are Google trademarks; this is
an independent extension that automates a signed-in session.

Top comments (0)