Skip to content

Commit 923fef0

Browse files
committed
Add Telegram export parser
1 parent 5a4f17e commit 923fef0

14 files changed

Lines changed: 539 additions & 61 deletions

File tree

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ Transform chat exports into geocoded activity suggestions.
77

88
## Overview
99

10-
ChatToMap extracts "things to do" from WhatsApp and iMessage exports - restaurants to try, places to visit, trips to take. It finds suggestions buried in years of chat history and puts them on a map.
10+
ChatToMap extracts "things to do" from WhatsApp, iMessage, and Telegram exports - restaurants to try, places to visit, trips to take. It finds suggestions buried in years of chat history and puts them on a map.
1111

1212
**Features:**
13-
- Parse WhatsApp (iOS/Android) and iMessage exports
13+
- Parse WhatsApp (iOS/Android), iMessage, and Telegram Desktop JSON exports
1414
- Extract suggestions using multilingual regex patterns, embeddings, and URL detection
1515
- Classify with AI (activity vs errand, mappable vs general)
1616
- Scrape metadata from TikTok and YouTube links
@@ -50,6 +50,9 @@ chat-to-map preview <input>
5050
# Full analysis with exports
5151
chat-to-map analyze <input>
5252

53+
# Telegram Desktop exports
54+
chat-to-map analyze "/path/to/ChatExport_2026-04-26/result.json"
55+
5356
# List previously processed chats
5457
chat-to-map list
5558
```
@@ -150,7 +153,7 @@ export CHAT_TO_MAP_CONFIG=/path/to/config.json
150153

151154
```typescript
152155
import {
153-
parseWhatsAppChat,
156+
parseChat,
154157
extractCandidatesByHeuristics,
155158
extractCandidates, // combined: heuristics + embeddings
156159
classifyMessages,
@@ -164,7 +167,7 @@ const scan = quickScan(chatText)
164167
console.log(`Found ${scan.candidates.length} candidates`)
165168

166169
// Parse messages
167-
const messages = parseWhatsAppChat(chatText)
170+
const messages = parseChat(chatText)
168171

169172
// Extract candidates (heuristics only - sync, free)
170173
const { candidates } = extractCandidatesByHeuristics(messages)

src/cli/commands/parse.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { writeFile } from 'node:fs/promises'
88
import { dirname } from 'node:path'
99
import { VERSION } from '../../index'
10+
import type { ChatSource } from '../../types'
1011
import type { CLIArgs } from '../args'
1112
import { ensureDir } from '../io'
1213
import type { Logger } from '../logger'
@@ -15,8 +16,10 @@ import { initContext, stepParse } from '../steps/index'
1516
/**
1617
* Format chat source for display.
1718
*/
18-
function formatSource(source: 'whatsapp' | 'imessage'): string {
19-
return source === 'whatsapp' ? 'WhatsApp' : 'iMessage'
19+
function formatSource(source: ChatSource): string {
20+
if (source === 'whatsapp') return 'WhatsApp'
21+
if (source === 'imessage') return 'iMessage'
22+
return 'Telegram'
2023
}
2124

2225
/**

src/cli/input.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ describe('getInputMetadata', () => {
4747
expect(metadata.baseName).toBe('chat')
4848
})
4949

50+
it('removes .json extension', async () => {
51+
const filePath = join(TEST_DIR, 'result.json')
52+
await writeFile(filePath, '{"messages":[]}')
53+
54+
const metadata = await getInputMetadata(filePath)
55+
56+
expect(metadata.baseName).toBe('result')
57+
})
58+
5059
it('generates different hash for different mtimes', async () => {
5160
const filePath = join(TEST_DIR, 'chat.txt')
5261
await writeFile(filePath, 'test')
@@ -119,6 +128,16 @@ describe('readInputFileWithMetadata', () => {
119128
const result = await readInputFileWithMetadata(filePath)
120129
expect(result.content).toBe('Chat content')
121130
})
131+
132+
it('reads Telegram result.json files', async () => {
133+
const filePath = join(TEST_DIR, 'result.json')
134+
await writeFile(filePath, '{"messages":[]}')
135+
136+
const result = await readInputFileWithMetadata(filePath)
137+
138+
expect(result.content).toBe('{"messages":[]}')
139+
expect(result.metadata.baseName).toBe('result')
140+
})
122141
})
123142

124143
describe('PipelineCache chat content caching', () => {

src/cli/io.test.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ describe('readInputFile', () => {
2323

2424
expect(content).toBe('Hello, world!')
2525
})
26+
27+
it('reads a direct Telegram result.json file', async () => {
28+
const filePath = join(TEST_DIR, 'result.json')
29+
await writeFile(filePath, '{"messages":[]}')
30+
31+
const content = await readInputFile(filePath)
32+
33+
expect(content).toBe('{"messages":[]}')
34+
})
2635
})
2736

2837
describe('zip files', () => {
@@ -54,7 +63,21 @@ describe('readInputFile', () => {
5463
expect(content).toBe('Chat via _chat.txt')
5564
})
5665

57-
it('throws when no .txt file found in zip', async () => {
66+
it('reads result.json from a Telegram zip archive', async () => {
67+
const JSZip = await import('jszip')
68+
const zip = new JSZip.default()
69+
zip.file('ChatExport/result.json', '{"messages":[]}')
70+
71+
const zipContent = await zip.generateAsync({ type: 'uint8array' })
72+
const filePath = join(TEST_DIR, 'telegram.zip')
73+
await writeFile(filePath, zipContent)
74+
75+
const content = await readInputFile(filePath)
76+
77+
expect(content).toBe('{"messages":[]}')
78+
})
79+
80+
it('throws when no supported chat file is found in zip', async () => {
5881
const JSZip = await import('jszip')
5982
const zip = new JSZip.default()
6083
zip.file('readme.md', 'Not a chat file')
@@ -78,6 +101,27 @@ describe('readInputFile', () => {
78101
expect(content).toBe('iMessage content')
79102
})
80103

104+
it('reads result.json from a Telegram export directory', async () => {
105+
const subDir = join(TEST_DIR, 'telegram-export')
106+
await mkdir(subDir, { recursive: true })
107+
await writeFile(join(subDir, 'result.json'), '{"messages":[]}')
108+
109+
const content = await readInputFile(subDir)
110+
111+
expect(content).toBe('{"messages":[]}')
112+
})
113+
114+
it('prefers Telegram result.json over text files in a directory', async () => {
115+
const subDir = join(TEST_DIR, 'telegram-export-with-files')
116+
await mkdir(subDir, { recursive: true })
117+
await writeFile(join(subDir, 'result.json'), '{"messages":[]}')
118+
await writeFile(join(subDir, 'captions.txt'), 'caption attachment')
119+
120+
const content = await readInputFile(subDir)
121+
122+
expect(content).toBe('{"messages":[]}')
123+
})
124+
81125
it('concatenates multiple .txt files from a directory', async () => {
82126
const subDir = join(TEST_DIR, 'multi-export')
83127
await mkdir(subDir, { recursive: true })
@@ -90,19 +134,19 @@ describe('readInputFile', () => {
90134
expect(content).toBe('First chat\nSecond chat')
91135
})
92136

93-
it('throws when directory contains no .txt files', async () => {
137+
it('throws when directory contains no supported chat files', async () => {
94138
const subDir = join(TEST_DIR, 'empty-export')
95139
await mkdir(subDir, { recursive: true })
96140
await writeFile(join(subDir, 'readme.md'), 'Not a txt file')
97141

98-
await expect(readInputFile(subDir)).rejects.toThrow('No .txt files found')
142+
await expect(readInputFile(subDir)).rejects.toThrow('No .txt or result.json files found')
99143
})
100144

101145
it('throws when directory is empty', async () => {
102146
const subDir = join(TEST_DIR, 'empty-dir')
103147
await mkdir(subDir, { recursive: true })
104148

105-
await expect(readInputFile(subDir)).rejects.toThrow('No .txt files found')
149+
await expect(readInputFile(subDir)).rejects.toThrow('No .txt or result.json files found')
106150
})
107151

108152
it('ignores non-.txt files in directory', async () => {

src/cli/io.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,22 @@
77
import { mkdir, readdir, readFile, stat } from 'node:fs/promises'
88
import { join } from 'node:path'
99

10+
function isTelegramResultJson(name: string): boolean {
11+
return name === 'result.json' || name.endsWith('/result.json')
12+
}
13+
1014
/**
11-
* Find .txt files in a directory (non-recursive).
15+
* Find supported chat files in a directory (non-recursive).
1216
*/
13-
async function findTxtFilesInDir(dirPath: string): Promise<string[]> {
17+
async function findChatFilesInDir(dirPath: string): Promise<string[]> {
1418
const entries = await readdir(dirPath)
1519
const txtFiles: string[] = []
1620

1721
for (const entry of entries) {
22+
if (entry === 'result.json') {
23+
return [join(dirPath, entry)]
24+
}
25+
1826
if (entry.endsWith('.txt')) {
1927
txtFiles.push(join(dirPath, entry))
2028
}
@@ -27,35 +35,35 @@ async function findTxtFilesInDir(dirPath: string): Promise<string[]> {
2735
* Read an input file, handling zip archives and directories.
2836
*
2937
* Supports:
30-
* - .zip files (WhatsApp exports)
31-
* - Directories containing .txt files (iMessage exports, extracted WhatsApp)
32-
* - Direct .txt file paths
38+
* - .zip files (WhatsApp and Telegram exports)
39+
* - Directories containing result.json or .txt files
40+
* - Direct file paths
3341
*/
3442
export async function readInputFile(path: string): Promise<string> {
3543
const stats = await stat(path)
3644

3745
// Handle directory input
3846
if (stats.isDirectory()) {
39-
const txtFiles = await findTxtFilesInDir(path)
47+
const chatFiles = await findChatFilesInDir(path)
4048

41-
if (txtFiles.length === 0) {
42-
throw new Error(`No .txt files found in directory: ${path}`)
49+
if (chatFiles.length === 0) {
50+
throw new Error(`No .txt or result.json files found in directory: ${path}`)
4351
}
4452

4553
// If multiple files, concatenate them (common for iMessage exports)
46-
if (txtFiles.length > 1) {
54+
if (chatFiles.length > 1) {
4755
const contents: string[] = []
48-
for (const file of txtFiles) {
56+
for (const file of chatFiles) {
4957
const content = await readFile(file, 'utf-8')
5058
contents.push(content)
5159
}
5260
return contents.join('\n')
5361
}
5462

5563
// Single file
56-
const singleFile = txtFiles[0]
64+
const singleFile = chatFiles[0]
5765
if (!singleFile) {
58-
throw new Error(`No .txt files found in directory: ${path}`)
66+
throw new Error(`No .txt or result.json files found in directory: ${path}`)
5967
}
6068
return readFile(singleFile, 'utf-8')
6169
}
@@ -66,10 +74,13 @@ export async function readInputFile(path: string): Promise<string> {
6674
const zipBuffer = await readFile(path)
6775
const zip = await JSZip.default.loadAsync(new Uint8Array(zipBuffer))
6876

77+
const fileNames = Object.keys(zip.files)
78+
6979
// Find the chat file in the zip
70-
const chatFile = Object.keys(zip.files).find(
71-
(name) => name.endsWith('.txt') || name === '_chat.txt'
72-
)
80+
const chatFile =
81+
fileNames.find(isTelegramResultJson) ??
82+
fileNames.find((name) => name === '_chat.txt') ??
83+
fileNames.find((name) => name.endsWith('.txt'))
7384

7485
if (!chatFile) {
7586
throw new Error('No chat file found in zip archive')

src/cli/steps/parse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { PipelineContext } from './context'
1414
export interface ParseResult {
1515
/** Parsed messages */
1616
readonly messages: readonly ParsedMessage[]
17-
/** Detected chat source (whatsapp or imessage) */
17+
/** Detected chat source */
1818
readonly source: ChatSource
1919
/** Whether result was from cache */
2020
readonly fromCache: boolean

src/cli/steps/read.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function sanitizeForDirectory(name: string): string {
3939
return name
4040
.replace(/\.zip$/i, '')
4141
.replace(/\.txt$/i, '')
42+
.replace(/\.json$/i, '')
4243
.replace(/[^a-zA-Z0-9_-]/g, '_')
4344
.replace(/_+/g, '_')
4445
.replace(/^_|_$/g, '')

src/core/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,13 @@ export {
151151
export {
152152
detectChatSource,
153153
detectFormat,
154+
isTelegramExport,
154155
parseChat,
155156
parseChatStream,
156157
parseChatWithStats,
157158
parseIMessageChat,
158159
parseIMessageChatStream,
160+
parseTelegramExport,
159161
parseWhatsAppChat,
160162
parseWhatsAppChatStream
161163
} from '../parser/index'

src/parser/imessage.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,12 @@
1616
*/
1717

1818
import type { ParsedMessage } from '../types'
19-
import { chunkMessage, createChunkedMessages, normalizeApostrophes } from './index'
19+
import { chunkMessage, createChunkedMessages, extractUrls, normalizeApostrophes } from './index'
2020

2121
// Timestamp line pattern: Apr 02, 2025 8:52:29 AM (optional read receipt)
2222
const TIMESTAMP_PATTERN =
2323
/^([A-Z][a-z]{2} \d{1,2}, \d{4})\s+(\d{1,2}:\d{2}:\d{2} [AP]M)(?:\s*\(.*\))?$/
2424

25-
// URL extraction pattern
26-
const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi
27-
2825
// Month name to number mapping
2926
const MONTHS: Record<string, number> = {
3027
Jan: 0,
@@ -78,18 +75,6 @@ function parseTimestamp(dateStr: string, timeStr: string): Date {
7875
)
7976
}
8077

81-
/**
82-
* Extract all URLs from message content.
83-
*/
84-
function extractUrls(content: string): string[] {
85-
const matches = content.match(URL_PATTERN)
86-
if (!matches) {
87-
return []
88-
}
89-
90-
return matches.map((url) => url.replace(/[.,;:!?]+$/, '')).filter((url) => url.length > 0)
91-
}
92-
9378
interface MessageBuilder {
9479
timestamp: Date
9580
sender: string

0 commit comments

Comments
 (0)