Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,13 +29,18 @@ import {
import {
convertTranscriptToGrouped,
convertTranscriptToVtt,
extractFile,
extractImage,
extractMedia,
type FileInfo,
type FormattedChat,
type FormattedMessage,
formatChatList,
formatMember,
formatMessage,
formatPerson,
getConversationDisplayName,
type MediaInfo,
parseRecordings,
stripHtml,
transcriptFilename,
Expand Down Expand Up @@ -166,6 +171,118 @@ export function createProgram(): Command {
printMessages(msgs.reverse());
});

// media
program
.command("media <chat> [id]")
.description(
"Download a voice note / media / image message (default: newest media message).",
)
.option("-o, --out <file>", "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<string, unknown>; 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 <chat> [match]")
.description(
"Download a file attachment (match = file-name substring or message id; default: newest file).",
)
.option("-o, --out <file>", "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<string, unknown>; 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 <chat> <message>")
Expand Down
120 changes: 116 additions & 4 deletions src/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,21 +131,133 @@ 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<string, unknown>): MediaInfo | null {
try {
const props = (msg.properties as Record<string, string>) ?? {};
const cards = props.cards ? JSON.parse(props.cards) : [];
for (const c of cards as Record<string, unknown>[]) {
const content = c?.content as Record<string, unknown> | undefined;
const media = content?.media as Record<string, string>[] | 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 `<img src>`
// of the message content (e.g. .../v1/objects/<ref>/views/imgo).
export function extractImage(msg: Record<string, unknown>): MediaInfo | null {
try {
const html = (msg.content as string) ?? "";
const m = html.match(/<img[^>]+src="(https:\/\/[^"]+\/objects\/[^"]+)"/i);
if (!m) return null;
const url = m[1].replace(/&amp;/g, "&");
const dim = html.match(/<img[^>]+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<string, unknown>): FileInfo | null {
try {
const props = (msg.properties as Record<string, string>) ?? {};
const files = props.files ? JSON.parse(props.files) : [];
for (const f of files as Record<string, unknown>[]) {
if (f?.fileName) {
const bot = f?.botFileProperties as Record<string, string> | 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<string, unknown>,
): FormattedMessage | null {
const msgType = (msg.messagetype as string) ?? "";
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,
};
}

Expand Down
Loading