josefandersson / teams-export.mjs

Last active 23 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.

Revision 6e989e8fed3426e7de8fb42ad9c025facf791f27

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