diff --git a/src/cli.ts b/src/cli.ts index 9a8750a..46ae1f1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { searchPeople, sendMessage, } from "./api.js"; -import { getMyMri, login, logout } from "./auth.js"; +import { getMyMri, getToken, login, logout } from "./auth.js"; import { getChatClient, getSearchClient, getStreamClient } from "./client.js"; import { DATA_DIR, @@ -29,6 +29,10 @@ import { import { convertTranscriptToGrouped, convertTranscriptToVtt, + extractFile, + extractImage, + extractMedia, + type FileInfo, type FormattedChat, type FormattedMessage, formatChatList, @@ -36,6 +40,7 @@ import { formatMessage, formatPerson, getConversationDisplayName, + type MediaInfo, parseRecordings, stripHtml, transcriptFilename, @@ -166,6 +171,118 @@ export function createProgram(): Command { printMessages(msgs.reverse()); }); + // media + program + .command("media [id]") + .description( + "Download a voice note / media / image message (default: newest media message).", + ) + .option("-o, --out ", "Output file") + .action(async (chat: string, id: string | undefined, opts) => { + const convId = resolveConversationId(chat); + const client = await getChatClient(); + const raw = await getMessages(client, convId, 60); + let target: { msg: Record; media: MediaInfo } | null = + null; + for (const m of raw) { + const media = extractMedia(m) ?? extractImage(m); + if (!media) continue; + if (id && m.id !== id) continue; + target = { msg: m, media }; + break; + } + if (!target || (id && target.msg.id !== id)) { + console.error( + id ? `No media message with id ${id}.` : "No media messages found.", + ); + process.exit(1); + } + const token = await getToken("ic3"); + if (!token) { + console.error( + "No valid Teams chat token found. Run `teams login` to authenticate.", + ); + process.exit(1); + } + const resp = await fetch(target.media.url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) { + console.error(`Download failed: HTTP ${resp.status}`); + process.exit(1); + } + const buf = Buffer.from(await resp.arrayBuffer()); + const ct = resp.headers.get("content-type") ?? ""; + const ext = ct.includes("webm") + ? "webm" + : ct.includes("jpeg") || ct.includes("jpg") + ? "jpg" + : ct.includes("png") + ? "png" + : "mp4"; + const out = opts.out || `teams-media-${target.msg.id}.${ext}`; + writeFileSync(out, buf); + console.log( + `Saved ${out} (${buf.length} bytes, ${target.media.duration}).`, + ); + }); + + // file + program + .command("file [match]") + .description( + "Download a file attachment (match = file-name substring or message id; default: newest file).", + ) + .option("-o, --out ", "Output file") + .action(async (chat: string, match: string | undefined, opts) => { + const convId = resolveConversationId(chat); + const client = await getChatClient(); + const raw = await getMessages(client, convId, 60); + let target: { msg: Record; file: FileInfo } | null = + null; + for (const m of raw) { + const file = extractFile(m); + if (!file) continue; + if ( + match && + !( + m.id === match || + String(file.fileName) + .toLowerCase() + .includes(String(match).toLowerCase()) + ) + ) + continue; + target = { msg: m, file }; + break; + } + if (!target) { + console.error( + match ? `No file matching '${match}'.` : "No file messages found.", + ); + process.exit(1); + } + console.log(`File: ${target.file.fileName}`); + if (!target.file.url) { + console.error("No download URL on this message."); + process.exit(1); + } + // tempauth links are pre-authed; they expire shortly after the message + // is sent - a 403 means the sender has to re-share the file. + const resp = await fetch(target.file.url, { redirect: "follow" }); + if (!resp.ok) { + console.error( + `Download failed: HTTP ${resp.status}${resp.status === 403 ? " (tempauth link likely EXPIRED - ask the sender to re-send the file)" : ""}`, + ); + process.exit(1); + } + const buf = Buffer.from(await resp.arrayBuffer()); + const safe = String(target.file.fileName).replace(/[/\\?%*:|"<>]/g, "_"); + const out = opts.out || safe; + writeFileSync(out, buf); + console.log(`Saved ${out} (${buf.length} bytes).`); + }); + // send program .command("send ") diff --git a/src/formatting.ts b/src/formatting.ts index 3327fdf..0ed4018 100644 --- a/src/formatting.ts +++ b/src/formatting.ts @@ -131,6 +131,82 @@ export interface FormattedMessage { id: string; } +export interface MediaInfo { + url: string; + duration: string; + kind: string; +} + +// Voice notes and other card-based media: the playable URL and duration live +// in the message's `properties.cards` JSON payload. +export function extractMedia(msg: Record): MediaInfo | null { + try { + const props = (msg.properties as Record) ?? {}; + const cards = props.cards ? JSON.parse(props.cards) : []; + for (const c of cards as Record[]) { + const content = c?.content as Record | undefined; + const media = content?.media as Record[] | undefined; + if (Array.isArray(media) && media[0]?.url) { + const dm = String(content?.duration ?? "").match(/PT(?:(\d+)M)?(\d+)S/); + const dur = dm ? `${dm[1] ? `${dm[1]}m` : ""}${dm[2] ?? "?"}s` : "?"; + const kind = String(content?.contentType ?? "").includes("audio") + ? "voice note" + : "media"; + return { url: media[0].url, duration: dur, kind }; + } + } + } catch { + // malformed cards JSON - not a media message + } + return null; +} + +// Inline images: the downloadable object URL sits in the raw HTML `` +// of the message content (e.g. .../v1/objects//views/imgo). +export function extractImage(msg: Record): MediaInfo | null { + try { + const html = (msg.content as string) ?? ""; + const m = html.match(/]+src="(https:\/\/[^"]+\/objects\/[^"]+)"/i); + if (!m) return null; + const url = m[1].replace(/&/g, "&"); + const dim = html.match(/]+width="(\d+)"/i); + return { url, duration: dim ? `${dim[1]}w` : "?", kind: "image" }; + } catch { + // fall through + } + return null; +} + +export interface FileInfo { + fileName: string; + objectUrl: string; + url: string; +} + +// File attachments: `properties.files` carries the name plus a pre-authed +// tempauth download URL (botFileProperties.url) with the SharePoint objectUrl +// as fallback. +export function extractFile(msg: Record): FileInfo | null { + try { + const props = (msg.properties as Record) ?? {}; + const files = props.files ? JSON.parse(props.files) : []; + for (const f of files as Record[]) { + if (f?.fileName) { + const bot = f?.botFileProperties as Record | undefined; + const objectUrl = (f.objectUrl as string) ?? ""; + return { + fileName: f.fileName as string, + objectUrl, + url: bot?.url ?? objectUrl, + }; + } + } + } catch { + // malformed files JSON - not a file message + } + return null; +} + export function formatMessage( msg: Record, ): FormattedMessage | null { @@ -138,14 +214,50 @@ export function formatMessage( if (!["RichText/Html", "Text", "RichText"].includes(msgType)) return null; const sender = (msg.imdisplayname as string) ?? "Unknown"; + const time = formatTimestamp(msg.originalarrivaltime as string); + const id = (msg.id as string) ?? ""; const content = stripHtml((msg.content as string) ?? ""); - if (!content) return null; + if (!content) { + // Media/file/image-only messages carry no text - render a marker row + // instead of dropping the message entirely. + const media = extractMedia(msg); + if (media) { + return { + time, + sender, + body: `[${media.kind} ${media.duration} — media id ${id}]`, + id, + }; + } + const file = extractFile(msg); + if (file) { + return { + time, + sender, + body: `[file: ${file.fileName} — file id ${id}]`, + id, + }; + } + const image = extractImage(msg); + if (image) { + return { + time, + sender, + body: `[${image.kind} ${image.duration} — img id ${id}]`, + id, + }; + } + return null; + } + const img = extractImage(msg); return { - time: formatTimestamp(msg.originalarrivaltime as string), + time, sender, - body: content, - id: (msg.id as string) ?? "", + body: img + ? `${content} [${img.kind} ${img.duration} — img id ${id}]` + : content, + id, }; } diff --git a/tests/formatting.test.ts b/tests/formatting.test.ts index 904b4b8..ea1ad6b 100644 --- a/tests/formatting.test.ts +++ b/tests/formatting.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { convertTranscriptToGrouped, convertTranscriptToVtt, + extractFile, + extractImage, + extractMedia, formatChatList, formatMember, formatMessage, @@ -406,3 +409,170 @@ describe("transcriptFilename", () => { expect(transcriptFilename("", "vtt")).toBe("recording_transcript.vtt"); }); }); + +describe("extractMedia", () => { + it("extracts a voice note url and duration from cards", () => { + const msg = { + messagetype: "RichText/Html", + content: "", + properties: { + cards: JSON.stringify([ + { + content: { + contentType: "audio", + duration: "PT12S", + media: [{ url: "https://example.objects/views/audio" }], + }, + }, + ]), + }, + }; + const media = extractMedia(msg); + expect(media).not.toBeNull(); + expect(media?.url).toBe("https://example.objects/views/audio"); + expect(media?.duration).toBe("12s"); + expect(media?.kind).toBe("voice note"); + }); + it("handles multi-minute durations", () => { + const msg = { + properties: { + cards: JSON.stringify([ + { + content: { + contentType: "audio", + duration: "PT1M05S", + media: [{ url: "u" }], + }, + }, + ]), + }, + }; + expect(extractMedia(msg)?.duration).toBe("1m05s"); + }); + it("returns null for messages without cards", () => { + expect(extractMedia({ properties: {} })).toBeNull(); + expect(extractMedia({})).toBeNull(); + }); +}); + +describe("extractImage", () => { + it("extracts the object url from an img tag", () => { + const msg = { + content: + '
', + }; + const img = extractImage(msg); + expect(img).not.toBeNull(); + expect(img?.url).toBe("https://eu.example.com/v1/objects/0-abc/views/imgo"); + expect(img?.kind).toBe("image"); + expect(img?.duration).toBe("800w"); + }); + it("decodes entity-escaped urls", () => { + const msg = { + content: '', + }; + expect(extractImage(msg)?.url).toBe( + "https://x/v1/objects/1-a&b/views/imgo", + ); + }); + it("returns null for non-object img srcs", () => { + expect( + extractImage({ content: '' }), + ).toBeNull(); + expect(extractImage({ content: "no image" })).toBeNull(); + }); +}); + +describe("extractFile", () => { + it("extracts file name and tempauth url", () => { + const msg = { + properties: { + files: JSON.stringify([ + { + fileName: "report.pdf", + objectUrl: "https://sharepoint/doc", + botFileProperties: { url: "https://tempauth/report.pdf" }, + }, + ]), + }, + }; + const file = extractFile(msg); + expect(file?.fileName).toBe("report.pdf"); + expect(file?.url).toBe("https://tempauth/report.pdf"); + expect(file?.objectUrl).toBe("https://sharepoint/doc"); + }); + it("falls back to objectUrl without botFileProperties", () => { + const msg = { + properties: { + files: JSON.stringify([ + { fileName: "a.txt", objectUrl: "https://sp/a" }, + ]), + }, + }; + expect(extractFile(msg)?.url).toBe("https://sp/a"); + }); + it("returns null for messages without files", () => { + expect(extractFile({ properties: {} })).toBeNull(); + }); +}); + +describe("formatMessage media rows", () => { + it("renders a voice note marker instead of dropping the message", () => { + const msg = { + messagetype: "RichText/Html", + imdisplayname: "Osama", + id: "1791627568451", + content: "", + properties: { + cards: JSON.stringify([ + { + content: { + contentType: "audio", + duration: "PT28S", + media: [{ url: "u" }], + }, + }, + ]), + }, + }; + const m = formatMessage(msg); + expect(m?.body).toBe("[voice note 28s \u2014 media id 1791627568451]"); + }); + it("renders an image marker for image-only messages", () => { + const msg = { + messagetype: "RichText/Html", + imdisplayname: "Osama", + id: "1791629797097", + content: + '
', + }; + const m = formatMessage(msg); + expect(m?.body).toBe("[image ? \u2014 img id 1791629797097]"); + }); + it("appends the image marker to captioned images", () => { + const msg = { + messagetype: "RichText/Html", + imdisplayname: "X", + id: "7", + content: + '

look at this

', + }; + const m = formatMessage(msg); + expect(m?.body).toBe("look at this [image 640w \u2014 img id 7]"); + }); + it("renders a file marker for file-only messages", () => { + const msg = { + messagetype: "RichText/Html", + imdisplayname: "X", + id: "9", + content: "", + properties: { + files: JSON.stringify([ + { fileName: "spec.docx", objectUrl: "https://sp" }, + ]), + }, + }; + const m = formatMessage(msg); + expect(m?.body).toBe("[file: spec.docx \u2014 file id 9]"); + }); +});