| 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 | |
| 13 | import { spawn } from 'node:child_process'; |
| 14 | import { createInterface } from 'node:readline/promises'; |
| 15 | import * as fs from 'node:fs'; |
| 16 | import * as path from 'node:path'; |
| 17 | |
| 18 | const args = process.argv.slice(2); |
| 19 | const flag = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? (args.splice(i, 1), true) : false; }; |
| 20 | const opt = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? args.splice(i, 2)[1] : undefined; }; |
| 21 | const port = opt('port') ?? '9222'; |
| 22 | const sinceArg = opt('since'); |
| 23 | const withName = opt('with'); |
| 24 | const yes = flag('yes'); |
| 25 | const full = flag('full'); |
| 26 | const noRestart = flag('no-restart'); |
| 27 | const outDir = args[0]; |
| 28 | if (!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 | } |
| 32 | let since = sinceArg ? Date.parse(sinceArg) : 0; |
| 33 | if (isNaN(since)) throw new Error(`Invalid --since date: ${sinceArg}`); |
| 34 | |
| 35 | const FORMAT = 'frenfoil-chat-import'; |
| 36 | // Frenfoil recognises a re-imported message by source, chat id and message id, so this never changes. |
| 37 | const SOURCE = 'Teams'; |
| 38 | |
| 39 | const outPath = path.join(outDir, 'chats.json'); |
| 40 | const previous = fs.existsSync(outPath) ? JSON.parse(fs.readFileSync(outPath, 'utf8')) : null; |
| 41 | const previousChat = previous?.chats?.[0]; |
| 42 | if (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. |
| 46 | const OVERLAP_DAYS = 7; |
| 47 | if (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 | |
| 52 | const PARALLEL = 4; |
| 53 | const ONE_ON_ONE_RE = /^19:([0-9a-f-]{36})_([0-9a-f-]{36})@unq\.gbl\.spaces$/i; |
| 54 | const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp' }; |
| 55 | const VIEW = 'msnp24Equivalent|supportsMessageProperties'; |
| 56 | |
| 57 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 58 | const rl = createInterface({ input: process.stdin, output: process.stdout }); |
| 59 | const ask = async q => (yes ? '' : (await rl.question(q)).trim()); |
| 60 | |
| 61 | // --- borrowing Teams' auth -------------------------------------------------------------------------- |
| 62 | |
| 63 | function 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 | |
| 71 | const 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. |
| 77 | async 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 | |
| 138 | async 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 | |
| 156 | async 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 | |
| 165 | const decode = s => s |
| 166 | .replace(/ /g, ' ') |
| 167 | .replace(/</g, '<') |
| 168 | .replace(/>/g, '>') |
| 169 | .replace(/"/g, '"') |
| 170 | .replace(/'|'/g, "'") |
| 171 | .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n)) |
| 172 | .replace(/&/g, '&'); |
| 173 | |
| 174 | const 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 | |
| 180 | const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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. |
| 184 | const 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 | }; |
| 188 | const 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 | |
| 197 | const fnv = s => { let h = 0x811c9dc5; for (const c of s) h = Math.imul(h ^ c.codePointAt(0), 0x01000193) >>> 0; return h; }; |
| 198 | const imageId = src => (/\/objects\/([^/?#]+)/.exec(src)?.[1] || 'x' + fnv(src).toString(36)).replace(/[^\w.-]/g, '_'); |
| 199 | const guidOf = mri => /8:orgid:([0-9a-f-]{36})/i.exec(mri ?? '')?.[1]?.toLowerCase(); |
| 200 | const jsonProp = v => { try { return typeof v === 'string' ? JSON.parse(v) : v; } catch { return null; } }; |
| 201 | const timeOf = m => Date.parse(m.composetime || m.originalarrivaltime); |
| 202 | |
| 203 | const EMOTION = { like: '👍', heart: '❤️', laugh: '😆', surprised: '😮', sad: '😢', angry: '😡' }; |
| 204 | function 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 | |
| 210 | function 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. |
| 257 | function 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 | |
| 273 | if (!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 | |
| 276 | if (!noRestart) restartTeams(); |
| 277 | else console.log('Waiting for Teams to sign in… (restart it with the debug port if nothing happens)'); |
| 278 | const auth = await captureAuth(); |
| 279 | console.log('Got Teams login.'); |
| 280 | |
| 281 | const H = auth.chatsvc.headers; |
| 282 | const base = /^(.*\/api\/chatsvc\/[^/]+\/v1)\//.exec(auth.chatsvc.url)[1]; |
| 283 | const self = selfGuid(H, auth.objects?.headers, auth.skype && { t: auth.skype }); |
| 284 | if (!self) throw new Error('Could not tell which account is signed in.'); |
| 285 | |
| 286 | const messagesUrl = conv => `${base}/users/ME/conversations/${encodeURIComponent(conv)}/messages`; |
| 287 | const peerOf = conv => { const [, a, b] = ONE_ON_ONE_RE.exec(conv); return a.toLowerCase() === self ? b.toLowerCase() : a.toLowerCase(); }; |
| 288 | |
| 289 | async 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 | |
| 302 | const peerNames = new Map(); |
| 303 | async 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 | |
| 315 | const chats = await oneOnOnes(); |
| 316 | let chat; |
| 317 | if (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 | } |
| 334 | const peer = peerOf(chat.id); |
| 335 | const peerDisplay = await peerName(chat) || previousChat?.name || 'Teams chat'; |
| 336 | console.log(`Chat with ${peerDisplay}`); |
| 337 | if (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 | |
| 340 | async 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 | |
| 358 | const fetched = await fetchAll(); |
| 359 | const kept = new Set(fetched.filter(m => /^(Text|RichText)/.test(m.messagetype) && !m.properties?.deletetime)); |
| 360 | const leftOut = new Map(); |
| 361 | for (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 | |
| 367 | const 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 | |
| 372 | const unique = [...new Set(prepared.flatMap(p => p.srcs))]; |
| 373 | const day = ms => new Date(ms).toISOString().slice(0, 10); |
| 374 | const span2 = prepared.length ? ` (${day(prepared[0].time)} → ${day(prepared.at(-1).time)})` : ''; |
| 375 | console.log(`${prepared.length} messages${span2}, ${unique.length} images.`); |
| 376 | if (leftOut.size) console.log(`Left out: ${[...leftOut].map(([k, n]) => `${n} ${k}`).join(', ')}.`); |
| 377 | if (/^n/i.test(await ask(`Export to ${path.resolve(outDir)}? [Y/n] `))) process.exit(0); |
| 378 | |
| 379 | const photosDir = path.join(outDir, 'photos'); |
| 380 | fs.mkdirSync(photosDir, { recursive: true }); |
| 381 | const onDisk = new Map(fs.readdirSync(photosDir).map(n => [n.replace(/\.[^.]+$/, ''), n])); |
| 382 | |
| 383 | const objectsHost = auth.objects && new URL(auth.objects.url).host; |
| 384 | const 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. |
| 391 | const FULL_VIEWS = ['imgpsh_fullsize_anim', 'imgpsh_fullsize']; |
| 392 | |
| 393 | async 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 | |
| 420 | const saved = new Map(); |
| 421 | const failures = new Map(); |
| 422 | let done = 0; |
| 423 | let next = 0; |
| 424 | await 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 | })); |
| 443 | if (unique.length) process.stdout.write('\n'); |
| 444 | |
| 445 | const side = guid => (guid === peer ? 'them' : 'me'); |
| 446 | |
| 447 | const 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. |
| 464 | const pathed = m => (m.files ?? []).filter(f => f.path).length; |
| 465 | const merged = new Map((previousChat?.messages ?? []).map(m => [String(m.id), m])); |
| 466 | let added = 0; |
| 467 | let updated = 0; |
| 468 | for (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 | } |
| 475 | const all = [...merged.values()].sort((a, b) => a.time - b.time || Number(a.id) - Number(b.id)); |
| 476 | |
| 477 | if (previous) fs.copyFileSync(outPath, `${outPath}.bak`); |
| 478 | fs.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)); |
| 484 | fs.renameSync(`${outPath}.tmp`, outPath); |
| 485 | |
| 486 | const failed = unique.length - saved.size; |
| 487 | console.log(`Done: ${added} new, ${updated} updated, ${all.length} in total; ${saved.size}/${unique.length} images → ${path.resolve(outPath)}`); |
| 488 | if (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 | |
| 491 | rl.close(); |
| 492 | process.exit(0); |
| 493 |
josefandersson / teams-export.mjs
Last active 23 hours ago
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 3a898eee646ee7e5bebec61008e616b62a370fe6