josefandersson / teams-export.mjs

Last active 21 hours ago

Like 0

Export of Teams messages for a specific chat to the Frenfoil generic chat import format so that they can be imported into the Frenfoil history.

teams-export.mjs Raw
1// Export a Teams 1:1 chat in frenfoil's chat import format (chats.json + photos/, source "Teams").
2// See docs/CHAT-IMPORT-FORMAT.md in frenfoil.
3//
4// Restarts the new Teams desktop app with its WebView2 debug port open, borrows the auth headers Teams sends
5// while it starts up, then fetches the chat and its images directly.
6//
7// Run again into a folder that holds an export to extend it: the chat is picked from its chats.json, only
8// the last week before its newest message onward is fetched (--full for everything), and what Teams no
9// longer has is kept.
10//
11// node teams-export.mjs <outDir> [--with <name>] [--since YYYY-MM-DD] [--full] [--yes] [--no-restart] [--port 9222]
12
13import { spawn } from 'node:child_process';
14import { createInterface } from 'node:readline/promises';
15import * as fs from 'node:fs';
16import * as path from 'node:path';
17
18const args = process.argv.slice(2);
19const flag = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? (args.splice(i, 1), true) : false; };
20const opt = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? args.splice(i, 2)[1] : undefined; };
21const port = opt('port') ?? '9222';
22const sinceArg = opt('since');
23const withName = opt('with');
24const yes = flag('yes');
25const full = flag('full');
26const noRestart = flag('no-restart');
27const outDir = args[0];
28if (!outDir) {
29 console.log('usage: node teams-export.mjs <outDir> [--with <name>] [--since YYYY-MM-DD] [--full] [--yes] [--no-restart] [--port 9222]');
30 process.exit(1);
31}
32let since = sinceArg ? Date.parse(sinceArg) : 0;
33if (isNaN(since)) throw new Error(`Invalid --since date: ${sinceArg}`);
34
35const FORMAT = 'frenfoil-chat-import';
36// Frenfoil recognises a re-imported message by source, chat id and message id, so this never changes.
37const SOURCE = 'Teams';
38
39const outPath = path.join(outDir, 'chats.json');
40const previous = fs.existsSync(outPath) ? JSON.parse(fs.readFileSync(outPath, 'utf8')) : null;
41const previousChat = previous?.chats?.[0];
42if (previous && (previous.format !== FORMAT || previous.source !== SOURCE || !Array.isArray(previousChat?.messages)))
43 throw new Error(`${outPath} is not an export this tool wrote.`);
44
45// Edits and reactions land on recent messages, so an extension re-reads the week before its newest one.
46const OVERLAP_DAYS = 7;
47if (previous && !sinceArg && !full) {
48 const newest = previousChat.messages.reduce((n, m) => Math.max(n, +m.time || 0), 0);
49 since = Math.max(0, newest - OVERLAP_DAYS * 86_400_000);
50}
51
52const PARALLEL = 4;
53const ONE_ON_ONE_RE = /^19:([0-9a-f-]{36})_([0-9a-f-]{36})@unq\.gbl\.spaces$/i;
54const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp' };
55const VIEW = 'msnp24Equivalent|supportsMessageProperties';
56
57const sleep = ms => new Promise(r => setTimeout(r, ms));
58const rl = createInterface({ input: process.stdin, output: process.stdout });
59const ask = async q => (yes ? '' : (await rl.question(q)).trim());
60
61// --- borrowing Teams' auth --------------------------------------------------------------------------
62
63function restartTeams() {
64 console.log('Restarting Teams to borrow its login…');
65 const env = { ...process.env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${port}` };
66 spawn('powershell.exe', ['-NoProfile', '-Command',
67 'Get-Process ms-teams -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep 3; Start-Process ms-teams.exe'],
68 { env, stdio: 'ignore' });
69}
70
71const cleanHeaders = h => Object.fromEntries(Object.entries(h)
72 .map(([k, v]) => [k.toLowerCase(), v])
73 .filter(([k]) => !/^(:|host$|cookie$|content-length$|content-type$|connection$|accept-encoding$)/.test(k)));
74
75// Attach to every Teams page and worker as it is created, and keep the headers of its first chat-service
76// and image requests.
77async function captureAuth() {
78 const got = {};
79 const deadline = Date.now() + 180_000;
80 const done = () => got.chatsvc && (got.objects || Date.now() > got.chatsvcAt + 20_000);
81
82 while (!done()) {
83 if (Date.now() > deadline) throw new Error('Did not see Teams sign in within 3 minutes.');
84 let ws;
85 try {
86 const ver = await (await fetch(`http://localhost:${port}/json/version`)).json();
87 ws = new WebSocket(ver.webSocketDebuggerUrl);
88 await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
89 } catch {
90 await sleep(300);
91 continue;
92 }
93
94 let n = 0;
95 const urls = new Map();
96 const send = (method, params, sessionId) =>
97 ws.send(JSON.stringify({ id: ++n, method, params: params ?? {}, ...sessionId ? { sessionId } : {} }));
98 const see = (url, headers) => {
99 if (!url) return;
100 const h = cleanHeaders(headers);
101 if (h['x-skypetoken']) got.skype ??= h['x-skypetoken'];
102 if (!h.authorization) return;
103 if (!got.chatsvc && /\/api\/chatsvc\/[^/]+\/v1\//.test(url)) {
104 got.chatsvc = { url, headers: h };
105 got.chatsvcAt = Date.now();
106 } else if (!got.objects && /\/v1\/objects\//.test(url)) {
107 got.objects = { url, headers: h };
108 }
109 };
110
111 ws.onmessage = e => {
112 const m = JSON.parse(e.data);
113 if (m.method === 'Target.attachedToTarget') {
114 const s = m.params.sessionId;
115 send('Network.enable', {}, s);
116 send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, s);
117 send('Runtime.runIfWaitingForDebugger', {}, s);
118 } else if (m.method === 'Network.requestWillBeSent') {
119 urls.set(m.params.requestId, m.params.request.url);
120 see(m.params.request.url, m.params.request.headers);
121 } else if (m.method === 'Network.requestWillBeSentExtraInfo') {
122 see(urls.get(m.params.requestId), m.params.headers);
123 }
124 };
125 send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
126
127 await new Promise(res => {
128 ws.onclose = res;
129 const t = setInterval(() => { if (done() || Date.now() > deadline) { clearInterval(t); res(); } }, 500);
130 });
131 try { ws.close(); } catch {}
132 }
133 return got;
134}
135
136// --- HTTP ------------------------------------------------------------------------------------------
137
138async function get(url, headers) {
139 for (let i = 0; ; i++) {
140 let r;
141 try {
142 r = await fetch(url, { headers });
143 } catch (e) {
144 if (i >= 5) throw e;
145 await sleep(2 ** i * 1000);
146 continue;
147 }
148 if ((r.status === 429 || r.status >= 500) && i < 5) {
149 await sleep((+r.headers.get('retry-after') || 2 ** i) * 1000);
150 continue;
151 }
152 return r;
153 }
154}
155
156async function getJson(url, headers) {
157 const r = await get(url, headers);
158 if (r.status === 401) throw new Error('Teams login expired. Run again (it restarts Teams for a fresh one).');
159 if (!r.ok) throw new Error(`HTTP ${r.status} for ${url.split('?')[0]}`);
160 return r.json();
161}
162
163// --- Teams message → import message ---------------------------------------------------------------
164
165const decode = s => s
166 .replace(/&nbsp;/g, ' ')
167 .replace(/&lt;/g, '<')
168 .replace(/&gt;/g, '>')
169 .replace(/&quot;/g, '"')
170 .replace(/&#39;|&apos;/g, "'")
171 .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n))
172 .replace(/&amp;/g, '&');
173
174const toText = html => decode((html ?? '')
175 .replace(/<br\s*\/?>|<\/(p|div|li)>/gi, '\n')
176 .replace(/<[^>]+>/g, ''))
177 .replace(/\n{3,}/g, '\n\n')
178 .trim();
179
180const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
181
182// Teams' formatting in the message-styling syntax frenfoil renders. A span only styles when it hugs its
183// words on one line; anything else stays plain rather than wrong.
184const span = marker => (all, tag, inner) => {
185 const t = decode(inner.replace(/<[^>]+>/g, ''));
186 return t && t === t.trim() && !/<br|\n/i.test(inner + t) && !t.includes(marker) ? esc(`${marker}${t}${marker}`) : inner;
187};
188const styled = html => html
189 .replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_, inner) => `<br>${esc(`\`\`\`\n${toText(inner)}\n\`\`\``)}<br>`)
190 .replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, inner) =>
191 `<br>${esc(toText(inner).split('\n').map(line => `> ${line}`).join('\n'))}<br>`)
192 .replace(/<(b|strong)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('*'))
193 .replace(/<(i|em)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('_'))
194 .replace(/<(s|strike|del)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('~'))
195 .replace(/<(code)\b[^>]*>([\s\S]*?)<\/\1>/gi, span('`'));
196
197const fnv = s => { let h = 0x811c9dc5; for (const c of s) h = Math.imul(h ^ c.codePointAt(0), 0x01000193) >>> 0; return h; };
198const imageId = src => (/\/objects\/([^/?#]+)/.exec(src)?.[1] || 'x' + fnv(src).toString(36)).replace(/[^\w.-]/g, '_');
199const guidOf = mri => /8:orgid:([0-9a-f-]{36})/i.exec(mri ?? '')?.[1]?.toLowerCase();
200const jsonProp = v => { try { return typeof v === 'string' ? JSON.parse(v) : v; } catch { return null; } };
201const timeOf = m => Date.parse(m.composetime || m.originalarrivaltime);
202
203const EMOTION = { like: '👍', heart: '❤️', laugh: '😆', surprised: '😮', sad: '😢', angry: '😡' };
204function emojiOf(key) {
205 if (EMOTION[key]) return EMOTION[key];
206 const hex = /^([0-9a-f]{4,6}(?:-[0-9a-f]{4,6})*)_/i.exec(key)?.[1];
207 return hex ? String.fromCodePoint(...hex.split('-').map(h => parseInt(h, 16))) : null;
208}
209
210function prepare(m) {
211 let content = m.content ?? '';
212 let replyTo;
213 content = content.replace(/<blockquote\b([^>]*)>[\s\S]*?<\/blockquote>/gi, (all, attrs) => {
214 if (!/schema\.skype\.com\/Reply/i.test(attrs)) return all;
215 replyTo = /itemid="(\d+)"/i.exec(attrs)?.[1];
216 return '';
217 });
218 const srcs = [];
219 content = content.replace(/<img\b[^>]*>/gi, tag => {
220 const attr = k => (new RegExp(`\\s${k}="([^"]*)"`, 'i').exec(tag) || [])[1] || '';
221 if (/emoji/i.test(attr('itemtype'))) return attr('alt');
222 const src = decode(attr('src'));
223 if (/^https:/.test(src)) srcs.push(src);
224 return '';
225 });
226 content = content.replace(/<a\b[^>]*?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, label) => {
227 const h = decode(href);
228 const l = toText(label);
229 return esc(!l || l === h ? h : `${l} (${h})`);
230 });
231
232 const files = (jsonProp(m.properties?.files) ?? []).map(f => {
233 const name = f.fileName || f.title || 'file';
234 const url = f.objectUrl || f.fileInfo?.shareUrl || '';
235 return `📎 ${name}${url ? ` (${url})` : ''}`;
236 });
237
238 const reactions = (jsonProp(m.properties?.emotions) ?? []).flatMap(e => {
239 const emoji = emojiOf(e.key);
240 return emoji ? (e.users ?? []).map(u => guidOf(u.mri)).filter(Boolean).map(user => ({ emoji, user })) : [];
241 });
242
243 return {
244 id: String(m.id),
245 time: timeOf(m),
246 edited: +m.properties?.edittime > 0,
247 from: guidOf(m.from),
248 name: m.imdisplayname || '',
249 text: [toText(styled(content)), ...files].filter(Boolean).join('\n'),
250 srcs,
251 replyTo,
252 reactions,
253 };
254}
255
256// The signed-in user's AAD object id, read from whichever JWT the borrowed headers carry.
257function selfGuid(...headerSets) {
258 for (const headers of headerSets) {
259 for (const v of Object.values(headers ?? {})) {
260 for (const t of String(v).match(/[\w-]{10,}\.[\w-]{10,}\.[\w-]+/g) ?? []) {
261 try {
262 const c = JSON.parse(Buffer.from(t.split('.')[1], 'base64url').toString());
263 const id = c.oid || (typeof c.skypeid === 'string' && c.skypeid.split(':').pop());
264 if (id) return String(id).toLowerCase();
265 } catch {}
266 }
267 }
268 }
269}
270
271// --- main ------------------------------------------------------------------------------------------
272
273if (!previous && fs.existsSync(path.join(outDir, 'result.json')))
274 console.log('That folder holds an older Telegram-format export (result.json). Writing a full chats.json beside it.');
275
276if (!noRestart) restartTeams();
277else console.log('Waiting for Teams to sign in… (restart it with the debug port if nothing happens)');
278const auth = await captureAuth();
279console.log('Got Teams login.');
280
281const H = auth.chatsvc.headers;
282const base = /^(.*\/api\/chatsvc\/[^/]+\/v1)\//.exec(auth.chatsvc.url)[1];
283const self = selfGuid(H, auth.objects?.headers, auth.skype && { t: auth.skype });
284if (!self) throw new Error('Could not tell which account is signed in.');
285
286const messagesUrl = conv => `${base}/users/ME/conversations/${encodeURIComponent(conv)}/messages`;
287const peerOf = conv => { const [, a, b] = ONE_ON_ONE_RE.exec(conv); return a.toLowerCase() === self ? b.toLowerCase() : a.toLowerCase(); };
288
289async function oneOnOnes() {
290 const out = [];
291 let next = `${base}/users/ME/conversations?view=${encodeURIComponent(VIEW)}&pageSize=200`;
292 for (let page = 0; next && page < 10; page++) {
293 const j = await getJson(next, H);
294 const list = j.conversations ?? [];
295 out.push(...list.filter(c => ONE_ON_ONE_RE.test(c.id)));
296 const link = j._metadata?.backwardLink;
297 next = list.length && link !== next ? link : null;
298 }
299 return out.sort((a, b) => (timeOf(b.lastMessage ?? {}) || 0) - (timeOf(a.lastMessage ?? {}) || 0));
300}
301
302const peerNames = new Map();
303async function peerName(c) {
304 const peer = peerOf(c.id);
305 if (peerNames.has(peer)) return peerNames.get(peer);
306 let name = guidOf(c.lastMessage?.from) === peer ? c.lastMessage.imdisplayname : '';
307 if (!name) {
308 const j = await getJson(`${messagesUrl(c.id)}?view=${encodeURIComponent(VIEW)}&pageSize=100&startTime=1`, H);
309 name = (j.messages ?? []).find(m => guidOf(m.from) === peer && m.imdisplayname)?.imdisplayname ?? '';
310 }
311 peerNames.set(peer, name);
312 return name;
313}
314
315const chats = await oneOnOnes();
316let chat;
317if (previous) {
318 chat = chats.find(c => c.id === previousChat.id);
319 if (!chat) throw new Error(`The chat in ${outPath} is no longer in Teams. The export there is left as it is.`);
320 if (withName && !(await peerName(chat)).toLowerCase().includes(withName.toLowerCase()))
321 throw new Error(`${outPath} holds the chat with ${await peerName(chat)}, not "${withName}". Use another folder.`);
322} else if (withName) {
323 for (const c of chats) {
324 if ((await peerName(c)).toLowerCase().includes(withName.toLowerCase())) { chat = c; break; }
325 }
326 if (!chat) throw new Error(`No 1:1 chat with "${withName}" among ${chats.length} chats.`);
327} else {
328 const top = chats.slice(0, 20);
329 for (const [i, c] of top.entries()) console.log(`${String(i + 1).padStart(2)}. ${await peerName(c) || '(unknown)'}`);
330 if (yes) throw new Error('Pass --with <name> together with --yes.');
331 chat = top[+(await ask('Which chat? ')) - 1];
332 if (!chat) throw new Error('No such chat.');
333}
334const peer = peerOf(chat.id);
335const peerDisplay = await peerName(chat) || previousChat?.name || 'Teams chat';
336console.log(`Chat with ${peerDisplay}`);
337if (previous && since)
338 console.log(`Extending the export in ${path.resolve(outDir)} (${previousChat.messages.length} messages), from ${new Date(since).toISOString().slice(0, 10)}.`);
339
340async function fetchAll() {
341 const all = new Map();
342 let next = `${messagesUrl(chat.id)}?view=${encodeURIComponent(VIEW)}&pageSize=200&startTime=1`;
343 while (next) {
344 const j = await getJson(next, H);
345 const page = j.messages ?? [];
346 page.forEach(m => all.set(m.id, m));
347 process.stdout.write(`\r${all.size} messages…`);
348 // Pages come newest-changed first, so an old message with a fresh reaction can sit among new ones:
349 // only a page changed entirely before `since` means everything after it was already seen.
350 const oldest = Math.min(...page.map(m => +m.version || timeOf(m) || Infinity));
351 const link = j._metadata?.backwardLink;
352 next = page.length && !(since && oldest < since) && link !== next ? link : null;
353 }
354 process.stdout.write('\n');
355 return [...all.values()];
356}
357
358const fetched = await fetchAll();
359const kept = new Set(fetched.filter(m => /^(Text|RichText)/.test(m.messagetype) && !m.properties?.deletetime));
360const leftOut = new Map();
361for (const m of fetched) {
362 if (kept.has(m)) continue;
363 const why = m.properties?.deletetime ? 'deleted' : m.messagetype;
364 leftOut.set(why, (leftOut.get(why) ?? 0) + 1);
365}
366
367const prepared = [...kept]
368 .map(prepare)
369 .filter(p => p.time > 0 && p.time >= since)
370 .sort((a, b) => a.time - b.time || Number(a.id) - Number(b.id));
371
372const unique = [...new Set(prepared.flatMap(p => p.srcs))];
373const day = ms => new Date(ms).toISOString().slice(0, 10);
374const span2 = prepared.length ? ` (${day(prepared[0].time)} → ${day(prepared.at(-1).time)})` : '';
375console.log(`${prepared.length} messages${span2}, ${unique.length} images.`);
376if (leftOut.size) console.log(`Left out: ${[...leftOut].map(([k, n]) => `${n} ${k}`).join(', ')}.`);
377if (/^n/i.test(await ask(`Export to ${path.resolve(outDir)}? [Y/n] `))) process.exit(0);
378
379const photosDir = path.join(outDir, 'photos');
380fs.mkdirSync(photosDir, { recursive: true });
381const onDisk = new Map(fs.readdirSync(photosDir).map(n => [n.replace(/\.[^.]+$/, ''), n]));
382
383const objectsHost = auth.objects && new URL(auth.objects.url).host;
384const headerSets = [
385 auth.objects?.headers,
386 auth.skype && { authorization: `skype_token ${auth.skype}` },
387 H,
388].filter(Boolean);
389
390// The view a message links (imgo) is an 800px preview; the full-size views are tried first.
391const FULL_VIEWS = ['imgpsh_fullsize_anim', 'imgpsh_fullsize'];
392
393async function downloadImage(src) {
394 const hosts = [src];
395 if (objectsHost && /\/v1\/objects\//.test(src) && new URL(src).host !== objectsHost) {
396 const u = new URL(src);
397 u.host = objectsHost;
398 hosts.push(u.href);
399 }
400 const urls = [...new Set(hosts.flatMap(h => [
401 ...FULL_VIEWS.map(v => h.replace(/\/views\/[^/?#]+/, `/views/${v}`)),
402 h,
403 ]))];
404 let status = 0;
405 for (const url of urls) {
406 for (const [i, headers] of headerSets.entries()) {
407 try {
408 const r = await get(url, headers);
409 status = r.status;
410 const type = (r.headers.get('content-type') || '').split(';')[0];
411 if (!r.ok || !type.startsWith('image/')) continue;
412 if (i > 0) headerSets.unshift(...headerSets.splice(i, 1));
413 return { type, bytes: Buffer.from(await r.arrayBuffer()) };
414 } catch {}
415 }
416 }
417 return { status };
418}
419
420const saved = new Map();
421const failures = new Map();
422let done = 0;
423let next = 0;
424await Promise.all(Array.from({ length: PARALLEL }, async () => {
425 while (next < unique.length) {
426 const src = unique[next++];
427 const id = imageId(src);
428 let name = onDisk.get(id);
429 if (!name) {
430 const r = await downloadImage(src);
431 if (r.bytes) {
432 name = `${id}.${EXT[r.type] || 'jpg'}`;
433 fs.writeFileSync(path.join(photosDir, name), r.bytes);
434 onDisk.set(id, name);
435 } else {
436 failures.set(r.status, (failures.get(r.status) ?? 0) + 1);
437 }
438 }
439 if (name) saved.set(src, `photos/${name}`);
440 process.stdout.write(`\rImages ${++done}/${unique.length}…`);
441 }
442}));
443if (unique.length) process.stdout.write('\n');
444
445const side = guid => (guid === peer ? 'them' : 'me');
446
447const messages = prepared.map(p => {
448 // An image without a path is one the export could not include; frenfoil says so in the message.
449 const files = p.srcs.map(src => (saved.has(src) ? { path: saved.get(src) } : { name: 'Image' }));
450 return {
451 id: p.id,
452 time: p.time,
453 from: side(p.from),
454 ...p.text ? { text: p.text } : {},
455 ...files.length ? { files } : {},
456 ...p.replyTo ? { replyTo: p.replyTo } : {},
457 ...p.edited ? { edited: true } : {},
458 ...p.reactions.length ? { reactions: p.reactions.map(r => ({ emoji: r.emoji, from: side(r.user) })) } : {},
459 };
460});
461
462// Merged over what an earlier run saved: nothing is dropped, and an image saved then is not lost to a
463// download that failed now.
464const pathed = m => (m.files ?? []).filter(f => f.path).length;
465const merged = new Map((previousChat?.messages ?? []).map(m => [String(m.id), m]));
466let added = 0;
467let updated = 0;
468for (const m of messages) {
469 const old = merged.get(m.id);
470 if (old && pathed(old) > pathed(m)) continue;
471 if (!old) added++;
472 else if (JSON.stringify(old) !== JSON.stringify(m)) updated++;
473 merged.set(m.id, m);
474}
475const all = [...merged.values()].sort((a, b) => a.time - b.time || Number(a.id) - Number(b.id));
476
477if (previous) fs.copyFileSync(outPath, `${outPath}.bak`);
478fs.writeFileSync(`${outPath}.tmp`, JSON.stringify({
479 format: FORMAT,
480 version: 1,
481 source: SOURCE,
482 chats: [{ id: chat.id, kind: 'direct', name: peerDisplay, messages: all }],
483}, null, 1));
484fs.renameSync(`${outPath}.tmp`, outPath);
485
486const failed = unique.length - saved.size;
487console.log(`Done: ${added} new, ${updated} updated, ${all.length} in total; ${saved.size}/${unique.length} images → ${path.resolve(outPath)}`);
488if (failed)
489 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.`);
490
491rl.close();
492process.exit(0);
493