// Export a Teams 1:1 chat in frenfoil's chat import format (chats.json + photos/, source "Teams"). // See docs/CHAT-IMPORT-FORMAT.md in frenfoil. // // Restarts the new Teams desktop app with its WebView2 debug port open, borrows the auth headers Teams sends // while it starts up, then fetches the chat and its images directly. // // Run again into a folder that holds an export to extend it: the chat is picked from its chats.json, only // the last week before its newest message onward is fetched (--full for everything), and what Teams no // longer has is kept. // // node teams-export.mjs [--with ] [--since YYYY-MM-DD] [--full] [--yes] [--no-restart] [--port 9222] import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline/promises'; import * as fs from 'node:fs'; import * as path from 'node:path'; const args = process.argv.slice(2); const flag = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? (args.splice(i, 1), true) : false; }; const opt = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? args.splice(i, 2)[1] : undefined; }; const port = opt('port') ?? '9222'; const sinceArg = opt('since'); const withName = opt('with'); const yes = flag('yes'); const full = flag('full'); const noRestart = flag('no-restart'); const outDir = args[0]; if (!outDir) { console.log('usage: node teams-export.mjs [--with ] [--since YYYY-MM-DD] [--full] [--yes] [--no-restart] [--port 9222]'); process.exit(1); } let since = sinceArg ? Date.parse(sinceArg) : 0; if (isNaN(since)) throw new Error(`Invalid --since date: ${sinceArg}`); const FORMAT = 'frenfoil-chat-import'; // Frenfoil recognises a re-imported message by source, chat id and message id, so this never changes. const SOURCE = 'Teams'; const outPath = path.join(outDir, 'chats.json'); const previous = fs.existsSync(outPath) ? JSON.parse(fs.readFileSync(outPath, 'utf8')) : null; const previousChat = previous?.chats?.[0]; if (previous && (previous.format !== FORMAT || previous.source !== SOURCE || !Array.isArray(previousChat?.messages))) throw new Error(`${outPath} is not an export this tool wrote.`); // Edits and reactions land on recent messages, so an extension re-reads the week before its newest one. const OVERLAP_DAYS = 7; if (previous && !sinceArg && !full) { const newest = previousChat.messages.reduce((n, m) => Math.max(n, +m.time || 0), 0); since = Math.max(0, newest - OVERLAP_DAYS * 86_400_000); } const PARALLEL = 4; const ONE_ON_ONE_RE = /^19:([0-9a-f-]{36})_([0-9a-f-]{36})@unq\.gbl\.spaces$/i; const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp' }; const VIEW = 'msnp24Equivalent|supportsMessageProperties'; const sleep = ms => new Promise(r => setTimeout(r, ms)); const rl = createInterface({ input: process.stdin, output: process.stdout }); const ask = async q => (yes ? '' : (await rl.question(q)).trim()); // --- borrowing Teams' auth -------------------------------------------------------------------------- function restartTeams() { console.log('Restarting Teams to borrow its login…'); const env = { ...process.env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${port}` }; spawn('powershell.exe', ['-NoProfile', '-Command', 'Get-Process ms-teams -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep 3; Start-Process ms-teams.exe'], { env, stdio: 'ignore' }); } const cleanHeaders = h => Object.fromEntries(Object.entries(h) .map(([k, v]) => [k.toLowerCase(), v]) .filter(([k]) => !/^(:|host$|cookie$|content-length$|content-type$|connection$|accept-encoding$)/.test(k))); // Attach to every Teams page and worker as it is created, and keep the headers of its first chat-service // and image requests. async function captureAuth() { const got = {}; const deadline = Date.now() + 180_000; const done = () => got.chatsvc && (got.objects || Date.now() > got.chatsvcAt + 20_000); while (!done()) { if (Date.now() > deadline) throw new Error('Did not see Teams sign in within 3 minutes.'); let ws; try { const ver = await (await fetch(`http://localhost:${port}/json/version`)).json(); ws = new WebSocket(ver.webSocketDebuggerUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); } catch { await sleep(300); continue; } let n = 0; const urls = new Map(); const send = (method, params, sessionId) => ws.send(JSON.stringify({ id: ++n, method, params: params ?? {}, ...sessionId ? { sessionId } : {} })); const see = (url, headers) => { if (!url) return; const h = cleanHeaders(headers); if (h['x-skypetoken']) got.skype ??= h['x-skypetoken']; if (!h.authorization) return; if (!got.chatsvc && /\/api\/chatsvc\/[^/]+\/v1\//.test(url)) { got.chatsvc = { url, headers: h }; got.chatsvcAt = Date.now(); } else if (!got.objects && /\/v1\/objects\//.test(url)) { got.objects = { url, headers: h }; } }; ws.onmessage = e => { const m = JSON.parse(e.data); if (m.method === 'Target.attachedToTarget') { const s = m.params.sessionId; send('Network.enable', {}, s); send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, s); send('Runtime.runIfWaitingForDebugger', {}, s); } else if (m.method === 'Network.requestWillBeSent') { urls.set(m.params.requestId, m.params.request.url); see(m.params.request.url, m.params.request.headers); } else if (m.method === 'Network.requestWillBeSentExtraInfo') { see(urls.get(m.params.requestId), m.params.headers); } }; send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }); await new Promise(res => { ws.onclose = res; const t = setInterval(() => { if (done() || Date.now() > deadline) { clearInterval(t); res(); } }, 500); }); try { ws.close(); } catch {} } return got; } // --- HTTP ------------------------------------------------------------------------------------------ async function get(url, headers) { for (let i = 0; ; i++) { let r; try { r = await fetch(url, { headers }); } catch (e) { if (i >= 5) throw e; await sleep(2 ** i * 1000); continue; } if ((r.status === 429 || r.status >= 500) && i < 5) { await sleep((+r.headers.get('retry-after') || 2 ** i) * 1000); continue; } return r; } } async function getJson(url, headers) { const r = await get(url, headers); if (r.status === 401) throw new Error('Teams login expired. Run again (it restarts Teams for a fresh one).'); if (!r.ok) throw new Error(`HTTP ${r.status} for ${url.split('?')[0]}`); return r.json(); } // --- Teams message → import message --------------------------------------------------------------- const decode = s => s .replace(/ /g, ' ') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n)) .replace(/&/g, '&'); const toText = html => decode((html ?? '') .replace(/|<\/(p|div|li)>/gi, '\n') .replace(/<[^>]+>/g, '')) .replace(/\n{3,}/g, '\n\n') .trim(); const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); // Teams' formatting in the message-styling syntax frenfoil renders. A span only styles when it hugs its // words on one line; anything else stays plain rather than wrong. const span = marker => (all, tag, inner) => { const t = decode(inner.replace(/<[^>]+>/g, '')); return t && t === t.trim() && !/ html .replace(/]*>([\s\S]*?)<\/pre>/gi, (_, inner) => `
${esc(`\`\`\`\n${toText(inner)}\n\`\`\``)}
`) .replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_, inner) => `
${esc(toText(inner).split('\n').map(line => `> ${line}`).join('\n'))}
`) .replace(/<(b|strong)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('*')) .replace(/<(i|em)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('_')) .replace(/<(s|strike|del)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('~')) .replace(/<(code)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('`')); const fnv = s => { let h = 0x811c9dc5; for (const c of s) h = Math.imul(h ^ c.codePointAt(0), 0x01000193) >>> 0; return h; }; const imageId = src => (/\/objects\/([^/?#]+)/.exec(src)?.[1] || 'x' + fnv(src).toString(36)).replace(/[^\w.-]/g, '_'); const guidOf = mri => /8:orgid:([0-9a-f-]{36})/i.exec(mri ?? '')?.[1]?.toLowerCase(); const jsonProp = v => { try { return typeof v === 'string' ? JSON.parse(v) : v; } catch { return null; } }; const timeOf = m => Date.parse(m.composetime || m.originalarrivaltime); const EMOTION = { like: '👍', heart: '❤️', laugh: '😆', surprised: '😮', sad: '😢', angry: '😡' }; function emojiOf(key) { if (EMOTION[key]) return EMOTION[key]; const hex = /^([0-9a-f]{4,6}(?:-[0-9a-f]{4,6})*)_/i.exec(key)?.[1]; return hex ? String.fromCodePoint(...hex.split('-').map(h => parseInt(h, 16))) : null; } function prepare(m) { let content = m.content ?? ''; let replyTo; content = content.replace(/]*)>[\s\S]*?<\/blockquote>/gi, (all, attrs) => { if (!/schema\.skype\.com\/Reply/i.test(attrs)) return all; replyTo = /itemid="(\d+)"/i.exec(attrs)?.[1]; return ''; }); const srcs = []; content = content.replace(/]*>/gi, tag => { const attr = k => (new RegExp(`\\s${k}="([^"]*)"`, 'i').exec(tag) || [])[1] || ''; if (/emoji/i.test(attr('itemtype'))) return attr('alt'); const src = decode(attr('src')); if (/^https:/.test(src)) srcs.push(src); return ''; }); content = content.replace(/]*?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, label) => { const h = decode(href); const l = toText(label); return esc(!l || l === h ? h : `${l} (${h})`); }); const files = (jsonProp(m.properties?.files) ?? []).map(f => { const name = f.fileName || f.title || 'file'; const url = f.objectUrl || f.fileInfo?.shareUrl || ''; return `📎 ${name}${url ? ` (${url})` : ''}`; }); const reactions = (jsonProp(m.properties?.emotions) ?? []).flatMap(e => { const emoji = emojiOf(e.key); return emoji ? (e.users ?? []).map(u => guidOf(u.mri)).filter(Boolean).map(user => ({ emoji, user })) : []; }); return { id: String(m.id), time: timeOf(m), edited: +m.properties?.edittime > 0, from: guidOf(m.from), name: m.imdisplayname || '', text: [toText(styled(content)), ...files].filter(Boolean).join('\n'), srcs, replyTo, reactions, }; } // The signed-in user's AAD object id, read from whichever JWT the borrowed headers carry. function selfGuid(...headerSets) { for (const headers of headerSets) { for (const v of Object.values(headers ?? {})) { for (const t of String(v).match(/[\w-]{10,}\.[\w-]{10,}\.[\w-]+/g) ?? []) { try { const c = JSON.parse(Buffer.from(t.split('.')[1], 'base64url').toString()); const id = c.oid || (typeof c.skypeid === 'string' && c.skypeid.split(':').pop()); if (id) return String(id).toLowerCase(); } catch {} } } } } // --- main ------------------------------------------------------------------------------------------ if (!previous && fs.existsSync(path.join(outDir, 'result.json'))) console.log('That folder holds an older Telegram-format export (result.json). Writing a full chats.json beside it.'); if (!noRestart) restartTeams(); else console.log('Waiting for Teams to sign in… (restart it with the debug port if nothing happens)'); const auth = await captureAuth(); console.log('Got Teams login.'); const H = auth.chatsvc.headers; const base = /^(.*\/api\/chatsvc\/[^/]+\/v1)\//.exec(auth.chatsvc.url)[1]; const self = selfGuid(H, auth.objects?.headers, auth.skype && { t: auth.skype }); if (!self) throw new Error('Could not tell which account is signed in.'); const messagesUrl = conv => `${base}/users/ME/conversations/${encodeURIComponent(conv)}/messages`; const peerOf = conv => { const [, a, b] = ONE_ON_ONE_RE.exec(conv); return a.toLowerCase() === self ? b.toLowerCase() : a.toLowerCase(); }; async function oneOnOnes() { const out = []; let next = `${base}/users/ME/conversations?view=${encodeURIComponent(VIEW)}&pageSize=200`; for (let page = 0; next && page < 10; page++) { const j = await getJson(next, H); const list = j.conversations ?? []; out.push(...list.filter(c => ONE_ON_ONE_RE.test(c.id))); const link = j._metadata?.backwardLink; next = list.length && link !== next ? link : null; } return out.sort((a, b) => (timeOf(b.lastMessage ?? {}) || 0) - (timeOf(a.lastMessage ?? {}) || 0)); } const peerNames = new Map(); async function peerName(c) { const peer = peerOf(c.id); if (peerNames.has(peer)) return peerNames.get(peer); let name = guidOf(c.lastMessage?.from) === peer ? c.lastMessage.imdisplayname : ''; if (!name) { const j = await getJson(`${messagesUrl(c.id)}?view=${encodeURIComponent(VIEW)}&pageSize=100&startTime=1`, H); name = (j.messages ?? []).find(m => guidOf(m.from) === peer && m.imdisplayname)?.imdisplayname ?? ''; } peerNames.set(peer, name); return name; } const chats = await oneOnOnes(); let chat; if (previous) { chat = chats.find(c => c.id === previousChat.id); if (!chat) throw new Error(`The chat in ${outPath} is no longer in Teams. The export there is left as it is.`); if (withName && !(await peerName(chat)).toLowerCase().includes(withName.toLowerCase())) throw new Error(`${outPath} holds the chat with ${await peerName(chat)}, not "${withName}". Use another folder.`); } else if (withName) { for (const c of chats) { if ((await peerName(c)).toLowerCase().includes(withName.toLowerCase())) { chat = c; break; } } if (!chat) throw new Error(`No 1:1 chat with "${withName}" among ${chats.length} chats.`); } else { const top = chats.slice(0, 20); for (const [i, c] of top.entries()) console.log(`${String(i + 1).padStart(2)}. ${await peerName(c) || '(unknown)'}`); if (yes) throw new Error('Pass --with together with --yes.'); chat = top[+(await ask('Which chat? ')) - 1]; if (!chat) throw new Error('No such chat.'); } const peer = peerOf(chat.id); const peerDisplay = await peerName(chat) || previousChat?.name || 'Teams chat'; console.log(`Chat with ${peerDisplay}`); if (previous && since) console.log(`Extending the export in ${path.resolve(outDir)} (${previousChat.messages.length} messages), from ${new Date(since).toISOString().slice(0, 10)}.`); async function fetchAll() { const all = new Map(); let next = `${messagesUrl(chat.id)}?view=${encodeURIComponent(VIEW)}&pageSize=200&startTime=1`; while (next) { const j = await getJson(next, H); const page = j.messages ?? []; page.forEach(m => all.set(m.id, m)); process.stdout.write(`\r${all.size} messages…`); // Pages come newest-changed first, so an old message with a fresh reaction can sit among new ones: // only a page changed entirely before `since` means everything after it was already seen. const oldest = Math.min(...page.map(m => +m.version || timeOf(m) || Infinity)); const link = j._metadata?.backwardLink; next = page.length && !(since && oldest < since) && link !== next ? link : null; } process.stdout.write('\n'); return [...all.values()]; } const fetched = await fetchAll(); const kept = new Set(fetched.filter(m => /^(Text|RichText)/.test(m.messagetype) && !m.properties?.deletetime)); const leftOut = new Map(); for (const m of fetched) { if (kept.has(m)) continue; const why = m.properties?.deletetime ? 'deleted' : m.messagetype; leftOut.set(why, (leftOut.get(why) ?? 0) + 1); } const prepared = [...kept] .map(prepare) .filter(p => p.time > 0 && p.time >= since) .sort((a, b) => a.time - b.time || Number(a.id) - Number(b.id)); const unique = [...new Set(prepared.flatMap(p => p.srcs))]; const day = ms => new Date(ms).toISOString().slice(0, 10); const span2 = prepared.length ? ` (${day(prepared[0].time)} → ${day(prepared.at(-1).time)})` : ''; console.log(`${prepared.length} messages${span2}, ${unique.length} images.`); if (leftOut.size) console.log(`Left out: ${[...leftOut].map(([k, n]) => `${n} ${k}`).join(', ')}.`); if (/^n/i.test(await ask(`Export to ${path.resolve(outDir)}? [Y/n] `))) process.exit(0); const photosDir = path.join(outDir, 'photos'); fs.mkdirSync(photosDir, { recursive: true }); const onDisk = new Map(fs.readdirSync(photosDir).map(n => [n.replace(/\.[^.]+$/, ''), n])); const objectsHost = auth.objects && new URL(auth.objects.url).host; const headerSets = [ auth.objects?.headers, auth.skype && { authorization: `skype_token ${auth.skype}` }, H, ].filter(Boolean); // The view a message links (imgo) is an 800px preview; the full-size views are tried first. const FULL_VIEWS = ['imgpsh_fullsize_anim', 'imgpsh_fullsize']; async function downloadImage(src) { const hosts = [src]; if (objectsHost && /\/v1\/objects\//.test(src) && new URL(src).host !== objectsHost) { const u = new URL(src); u.host = objectsHost; hosts.push(u.href); } const urls = [...new Set(hosts.flatMap(h => [ ...FULL_VIEWS.map(v => h.replace(/\/views\/[^/?#]+/, `/views/${v}`)), h, ]))]; let status = 0; for (const url of urls) { for (const [i, headers] of headerSets.entries()) { try { const r = await get(url, headers); status = r.status; const type = (r.headers.get('content-type') || '').split(';')[0]; if (!r.ok || !type.startsWith('image/')) continue; if (i > 0) headerSets.unshift(...headerSets.splice(i, 1)); return { type, bytes: Buffer.from(await r.arrayBuffer()) }; } catch {} } } return { status }; } const saved = new Map(); const failures = new Map(); let done = 0; let next = 0; await Promise.all(Array.from({ length: PARALLEL }, async () => { while (next < unique.length) { const src = unique[next++]; const id = imageId(src); let name = onDisk.get(id); if (!name) { const r = await downloadImage(src); if (r.bytes) { name = `${id}.${EXT[r.type] || 'jpg'}`; fs.writeFileSync(path.join(photosDir, name), r.bytes); onDisk.set(id, name); } else { failures.set(r.status, (failures.get(r.status) ?? 0) + 1); } } if (name) saved.set(src, `photos/${name}`); process.stdout.write(`\rImages ${++done}/${unique.length}…`); } })); if (unique.length) process.stdout.write('\n'); const side = guid => (guid === peer ? 'them' : 'me'); const messages = prepared.map(p => { // An image without a path is one the export could not include; frenfoil says so in the message. const files = p.srcs.map(src => (saved.has(src) ? { path: saved.get(src) } : { name: 'Image' })); return { id: p.id, time: p.time, from: side(p.from), ...p.text ? { text: p.text } : {}, ...files.length ? { files } : {}, ...p.replyTo ? { replyTo: p.replyTo } : {}, ...p.edited ? { edited: true } : {}, ...p.reactions.length ? { reactions: p.reactions.map(r => ({ emoji: r.emoji, from: side(r.user) })) } : {}, }; }); // Merged over what an earlier run saved: nothing is dropped, and an image saved then is not lost to a // download that failed now. const pathed = m => (m.files ?? []).filter(f => f.path).length; const merged = new Map((previousChat?.messages ?? []).map(m => [String(m.id), m])); let added = 0; let updated = 0; for (const m of messages) { const old = merged.get(m.id); if (old && pathed(old) > pathed(m)) continue; if (!old) added++; else if (JSON.stringify(old) !== JSON.stringify(m)) updated++; merged.set(m.id, m); } const all = [...merged.values()].sort((a, b) => a.time - b.time || Number(a.id) - Number(b.id)); if (previous) fs.copyFileSync(outPath, `${outPath}.bak`); fs.writeFileSync(`${outPath}.tmp`, JSON.stringify({ format: FORMAT, version: 1, source: SOURCE, chats: [{ id: chat.id, kind: 'direct', name: peerDisplay, messages: all }], }, null, 1)); fs.renameSync(`${outPath}.tmp`, outPath); const failed = unique.length - saved.size; console.log(`Done: ${added} new, ${updated} updated, ${all.length} in total; ${saved.size}/${unique.length} images → ${path.resolve(outPath)}`); if (failed) console.log(`${failed} images failed (HTTP ${[...failures].map(([s, n]) => `${s || 'error'}×${n}`).join(', ')}). Run again with --full into the same folder to retry them.`); rl.close(); process.exit(0);