diff --git a/CLAUDE.md b/CLAUDE.md
index a20383f..511573c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,14 @@ This is a TypeScript CLI for Microsoft Teams that talks directly to the internal
### Key design details
-- `sendMessage()` wraps content in `
` tags and generates a random 19-digit `clientmessageid` using BigInt
+- `sendMessage()` wraps content in `
` tags and generates a random 19-digit `clientmessageid` using BigInt. Both plain and HTML sends use `messagetype: "RichText/Html"`; `send --html/--html-file` skip the `
` wrap and post the caller's HTML untouched (no escaping — caller's job). Body construction (`buildMessageBody`) is split from the POST (`postMessage`) so `--dry-run` can print the exact body
+- **`send-file`** (`upload.ts`) = upload to the sender's OneDrive `Microsoft Teams Chat Files/` → `sharepointIds` → `createLink` → chat POST via `postMessage()` with `buildMessageBody(caption, {html, amsreferences: [], properties})`. Gotchas:
+ - The file object in `properties.files` is **field-proven** against the Teams client (field set + order; golden fixture in `tests/upload.test.ts`) — silent changes break the card render. `properties.files` is a JSON **string**
+ - Site is derived from the SP token (`aud` → `-my.sharepoint.com`, `upn`/`unique_name` → personal-site slug); never hardcode tenant/user
+ - `conflictBehavior=rename` (never `replace`, which would overwrite a file already shared elsewhere); use the server-returned name downstream
+ - OneDrive calls use plain `fetch` with explicit SP headers — never the chat client (it attaches ic3 auth)
+ - Token ladder (`getOneDriveToken`): `TEAMS_SP_TOKEN` env → cached `tokens.sharepoint[*-my host]` → MSAL localStorage in the CLI profile → `getSharepointToken()` interception. Token is never logged or put in argv
+ - Stages `upload → ids → link → message` each log a receipt to stderr; a failure exits 1 listing which stages succeeded (orphans). Both tokens are resolved before the upload. `--dry-run` makes no network call and opens no browser
- `createDmThread()` uses `POST /threads` (without `/users/ME`) with `uniquerosterthread: true` for idempotent 1:1 chat creation
- `searchPeople()` uses Substrate Suggestions API (`/search/api/v1/suggestions?scenario=peoplepicker.newChat`), same endpoint as the Teams web client people picker
- `getMyMri()` extracts the user's MRI from the ic3 JWT token's `oid` claim — no extra API call needed
diff --git a/README.md b/README.md
index a5a5a7d..0468d0f 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,20 @@ teams messages a1b2
# Send a message
teams send a1b2 "Hello from the CLI"
+# Send raw HTML (multi-line, lists, code) - posted verbatim, you escape <>& yourself
+teams send a1b2 --html ''
+teams send a1b2 --html-file msg.html
+cat msg.html | teams send a1b2 --html -
+
+# Preview the request body without sending
+teams send a1b2 --html-file msg.html --dry-run
+
+# Send a file (uploaded to your OneDrive "Microsoft Teams Chat Files" folder,
+# shared with your organization by default)
+teams send-file a1b2 ./report.pdf --caption "Q3 numbers"
+teams send-file a1b2 ./report.pdf --link-scope anonymous # anyone with the link
+teams send-file a1b2 ./report.pdf --dry-run # print the plan, no network
+
# Find a person by name or email
teams find "Jane Smith"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3ee3b4e..d16df3d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -38,6 +38,7 @@ Handles token persistence, automatic refresh, and the Playwright login flow.
- `saveTokens()` / `loadTokens()` — JSON round-trip to `~/.teams-cli/tokens.json`
- `getToken()` — check token expiry, attempt silent refresh if expired, and retrieve by name
- `getSharepointToken(host, recordingUrl)` — returns a cached SharePoint token for `host`, or acquires one via `acquireSharepointToken()` (drives the headless profile to the host root to bootstrap a SharePoint session via SSO, intercepts the `Bearer` token off an `/_api/` request, and caches it with the JWT's `exp`; the recording link is a fallback warmup)
+- `getOneDriveToken({headed})` — the `send-file` token ladder: `TEAMS_SP_TOKEN` env var → cached `tokens.sharepoint[-my.sharepoint.com]` → MSAL `accesstoken` entry for a `-my.sharepoint.com` scope in the CLI profile's localStorage (`scanProfileForOneDrive()`, which also discovers the OneDrive host — from any key naming it, else by following the `portal.office.com/onedrive` redirect) → `getSharepointToken(host, …)` interception. `peekOneDriveToken()` is the offline subset (env + cache) used by `--dry-run`
- `getRegion()` — returns the user's region code (e.g. `au`, `amer`) extracted during login
- `getMyMri()` — extracts the current user's MRI (`8:orgid:{oid}`) by decoding the `oid` claim from the ic3 JWT token using `Buffer.from(payload, 'base64url')`. Used by the `dm` command to construct thread creation requests.
- `tryRefresh()` — silently refreshes expired tokens by launching a headless Chromium with the persistent browser profile (`~/.teams-cli/browser-profile/`). MSAL re-acquires tokens using the stored session cookies — no user interaction needed. Returns `true` on success, `false` if the session itself has expired.
@@ -71,7 +72,7 @@ All clients use `AbortSignal.timeout(30000)` for request timeouts. If the requir
Async functions that make HTTP calls via the `ApiClient` wrapper. Each function takes an `ApiClient` as its first argument, making them easy to test with mocked `fetch` functions. No formatting or display logic lives here.
Key design decisions:
-- `sendMessage()` wraps content in `` tags and generates a random 19-digit `clientmessageid` using BigInt arithmetic, matching the Teams web client's behaviour
+- `sendMessage()` wraps content in `
` tags and generates a random 19-digit `clientmessageid` using BigInt arithmetic, matching the Teams web client's behaviour. It is `buildMessageBody()` (pure; `{ html: true }` skips the `
` wrap and posts the caller's HTML verbatim; optional `amsreferences`/`properties` are only added when given) + `postMessage()` (the POST). `send --dry-run` builds the body and prints it without calling `postMessage()`
- `getThreadMembers()` uses the `/threads/{id}` endpoint rather than `/conversations/{id}/members` (which returns 404)
- `searchMessages()` builds the full Substrate Search API request body including correlation IDs and dimension metadata
- `searchPeople()` uses the Substrate Suggestions API (`/search/api/v1/suggestions?scenario=peoplepicker.newChat`) — the same people picker endpoint the Teams web client uses when composing a new chat
@@ -80,6 +81,16 @@ Key design decisions:
- `getActivity()` is a thin wrapper around `getMessages()` that targets the system conversations (`48:notifications`, `48:mentions`, `48:calllogs`)
- Transcript download is a three-step SharePoint Stream chain: `resolveDriveItem()` (file URL → `{driveId, itemId}` via the `/_api/v2.0/shares` endpoint), `getTranscriptMetadata()` (drive item → `temporaryDownloadUrl` via `/_api/v2.1/.../?$expand=media/transcripts`), and `downloadTranscriptJson()` (fetch the URL with `?format=json`)
+### upload.ts
+
+The `send-file` flow: upload a local file to the sender's OneDrive, share it, and post a message carrying a Teams file object.
+
+- `deriveOneDriveSite(token, fallbackHost?)` — nothing is hardcoded: the token's `aud` claim gives `-my.sharepoint.com` (falling back to the host the token was captured on when `aud` isn't a URL), the `upn`/`unique_name` claim gives the personal-site slug (`a.b@tenant.com` → `a_b_tenant_com`)
+- `uploadToChatFiles()` / `getItemSharepointIds()` / `createShareLink()` — plain `fetch` with explicit SharePoint `Bearer` headers (never the chat client, which attaches ic3 auth). Upload uses `conflictBehavior=rename`, and the **server-returned** name is used downstream. The upload is the first drive call, so its error names the derived site URL
+- `buildFileObject()` / `buildFileMessageBody()` — pure; the file object's field set and order are field-proven against the Teams client (golden fixture in `tests/upload.test.ts`). `properties.files` is a JSON **string**. `permissionScope` is `"Anyone"` for anonymous links, `"Organization"` for organization links
+- `sendFile()` — runs the stages `upload → ids → link → message`, reporting a receipt per stage; a failure throws `StageError` carrying the stages that already succeeded so orphaned uploads/links are visible
+- `buildSendFilePlan()` — the offline `--dry-run` plan (ids as `""`)
+
### formatting.ts
Pure functions that transform API response dicts into display-ready objects with TypeScript interfaces (`FormattedChat`, `FormattedMessage`, `FormattedPerson`, `FormattedMember`). No I/O, no HTTP calls, no side effects. This makes them trivially testable.
@@ -119,7 +130,7 @@ The primary API for conversations, messages, and members.
- Base URL: `https://teams.cloud.microsoft/api/chatsvc/{region}/v1/users/ME`
- Token audience: `ic3.teams.office.com`
-- Used by: `chats`, `messages`, `send`, `dm` (thread creation + send), `watch`, `activity`, `members`
+- Used by: `chats`, `messages`, `send`, `send-file` (final message POST), `dm` (thread creation + send), `watch`, `activity`, `members`
### Substrate Search API
@@ -145,6 +156,7 @@ Where Teams stores meeting recordings and their transcripts. Used to download tr
- Token audience: the SharePoint host itself; cached per host as `tokens.sharepoint[host]`
- Used by: `recordings` (lists recordings from the meeting chat — chatsvc only), `transcript` (resolves the recording file and downloads its transcript)
- Endpoints: `GET /_api/v2.0/shares/u!{base64url(fileUrl)}/driveItem` (resolve file → drive item) and `GET /_api/v2.1/drives/{driveId}/items/{itemId}?$expand=media/transcripts` (drive item → transcript download URL)
+- Also used by `send-file` against the user's own OneDrive (`https://{tenant}-my.sharepoint.com/personal/{slug}`): `PUT /_api/v2.1/drive/root:/Microsoft Teams Chat Files/{name}:/content?@name.conflictBehavior=rename`, `GET /_api/v2.1/drive/items/{id}?select=id,sharepointIds`, `POST /_api/v2.1/drive/items/{id}/microsoft.graph.createLink`
- Token acquisition: SharePoint tokens are **not** in localStorage (the SPA keeps them in memory). `acquireSharepointToken()` headlessly navigates the persistent profile to the host root (bootstrapping a SharePoint session via SSO, since the Teams login never visits SharePoint), then intercepts the `Bearer` token off the resulting `/_api/` request and caches it. The first transcript download for a host therefore launches a short headless browser session.
## Testing strategy
@@ -155,6 +167,8 @@ Tests are organized to match the module structure:
- `auth.test.ts` — tests token persistence, expiry, and JWT parsing using `vi.mock` to redirect file I/O to temp directories (9 tests). Does not test the Playwright login/refresh flows directly.
- `api.test.ts` — tests API functions using mocked `ApiClient.fetch` (6 tests). The chatsvc base URL is patched to a fixed value.
- `cli.test.ts` — tests command registration and structure (2 tests).
+- `upload.test.ts` — golden-fixture test of the `send-file` file object, encoding/escaping/site derivation, and the staged flow with a stubbed global `fetch` (rename, per-stage failure reporting).
+- `send-file.test.ts` — the `send-file` command end to end with mocked network: `--dry-run` makes no network call, `TEAMS_SP_TOKEN` precedence, stage-failure output, and the offline part of the token ladder.
The Playwright login flow is not unit-tested since it requires a real browser and user interaction. It's verified manually.
diff --git a/skills/teams-cli/SKILL.md b/skills/teams-cli/SKILL.md
index 8dd4d51..c31a729 100644
--- a/skills/teams-cli/SKILL.md
+++ b/skills/teams-cli/SKILL.md
@@ -49,6 +49,20 @@ teams send "Your message here"
```
Sends a message to an existing conversation. Always confirm with the user before sending.
+Plain text collapses to one line in Teams. For multi-line text, lists or code, send HTML instead. It is posted exactly as given, so escape `<>&` inside `` yourself:
+```bash
+teams send --html ''
+teams send --html-file msg.html # or: ... | teams send --html -
+teams send --html-file msg.html --dry-run # print body, send nothing
+```
+
+### Send a file
+```bash
+teams send-file ./report.pdf --caption "Q3 numbers"
+teams send-file ./report.pdf --dry-run # print plan, no network
+```
+Uploads to the user's OneDrive (`Microsoft Teams Chat Files`), creates a share link (`--link-scope organization` by default; `anonymous` = anyone with the link) and posts it as a file card. Always confirm the file and recipient with the user first. If a stage fails, the output lists which stages succeeded — tell the user about any upload/link left behind.
+
### Search messages
```bash
teams search "quarterly report" # Search across all chats
diff --git a/src/api.ts b/src/api.ts
index 7bd5193..01d3ab6 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -61,28 +61,62 @@ export async function getMessages(
return (data.messages as Record[]) ?? [];
}
-export async function sendMessage(
+export interface MessageBody {
+ content: string;
+ messagetype: string;
+ contenttype: string;
+ amsreferences?: string[];
+ clientmessageid: string;
+ properties?: Record;
+}
+
+// Plain mode wraps the text in a single ; html mode posts the caller's
+// HTML verbatim (caller owns structure and escaping). `amsreferences` and
+// `properties` (e.g. file attachments) are only included when given, so the
+// plain/html bodies stay unchanged.
+export function buildMessageBody(
+ content: string,
+ opts: {
+ html?: boolean;
+ amsreferences?: string[];
+ properties?: Record;
+ } = {},
+): MessageBody {
+ return {
+ content: opts.html ? content : `${content}
`,
+ messagetype: "RichText/Html",
+ contenttype: "text",
+ ...(opts.amsreferences && { amsreferences: opts.amsreferences }),
+ clientmessageid: randomClientMessageId(),
+ ...(opts.properties && { properties: opts.properties }),
+ };
+}
+
+export async function postMessage(
client: ApiClient,
conversationId: string,
- content: string,
+ body: MessageBody,
): Promise> {
- const clientMsgId = randomClientMessageId();
const resp = await client.fetch(
`${client.baseUrl}/conversations/${conversationId}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- content: `${content}
`,
- messagetype: "RichText/Html",
- contenttype: "text",
- clientmessageid: clientMsgId,
- }),
+ body: JSON.stringify(body),
},
);
return (await jsonOrThrow(resp)) as Record;
}
+export async function sendMessage(
+ client: ApiClient,
+ conversationId: string,
+ content: string,
+ opts: { html?: boolean } = {},
+): Promise> {
+ return postMessage(client, conversationId, buildMessageBody(content, opts));
+}
+
export async function getThreadMembers(
client: ApiClient,
conversationId: string,
diff --git a/src/auth.ts b/src/auth.ts
index 12be274..9c2564e 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -209,6 +209,7 @@ async function tryRefresh(): Promise {
async function acquireSharepointToken(
host: string,
warmupUrl: string,
+ opts: { headed?: boolean } = {},
): Promise {
let playwright: typeof import("playwright");
try {
@@ -222,7 +223,7 @@ async function acquireSharepointToken(
mkdirSync(BROWSER_PROFILE_DIR, { recursive: true });
context = await playwright.chromium.launchPersistentContext(
BROWSER_PROFILE_DIR,
- { headless: true },
+ { headless: !opts.headed },
);
const page = await context.newPage();
@@ -299,18 +300,160 @@ function readSharepointEntry(host: string): TokenEntry | null {
export async function getSharepointToken(
host: string,
warmupUrl: string,
+ opts: { headed?: boolean } = {},
): Promise {
let entry = readSharepointEntry(host);
if (entry && !isExpired(entry)) return entry.secret;
// Missing or expired: open the recording to intercept a fresh token, re-read.
- if (await acquireSharepointToken(host, warmupUrl)) {
+ if (await acquireSharepointToken(host, warmupUrl, opts)) {
entry = readSharepointEntry(host);
if (entry && !isExpired(entry)) return entry.secret;
}
return null;
}
+// OneDrive (send-file) token ladder:
+// 1. TEAMS_SP_TOKEN env var
+// 2. cached tokens.sharepoint[-my host]
+// 3. MSAL localStorage of the CLI's own browser profile
+// 4. intercept a Bearer off an `/_api/` request on the OneDrive host
+// (getSharepointToken, which also caches it for step 2 next time)
+
+export interface OneDriveToken {
+ token: string;
+ // Host the token was found for; undefined for TEAMS_SP_TOKEN (use `aud`).
+ host?: string;
+ source: "env" | "cache" | "profile" | "intercept";
+}
+
+const ONEDRIVE_HOST = /^[a-z0-9-]+-my\.sharepoint\.com$/;
+
+function cachedOneDriveHosts(): { host: string; entry: TokenEntry }[] {
+ const map = loadTokens()?.sharepoint ?? {};
+ return Object.entries(map)
+ .filter(([host]) => ONEDRIVE_HOST.test(host))
+ .map(([host, entry]) => ({ host, entry }));
+}
+
+/** Env var or cached token only — never opens a browser (used by --dry-run). */
+export function peekOneDriveToken(): OneDriveToken | null {
+ const env = process.env.TEAMS_SP_TOKEN?.trim();
+ if (env) return { token: env, source: "env" };
+ const cached = cachedOneDriveHosts().find(({ entry }) => !isExpired(entry));
+ if (cached) {
+ return { token: cached.entry.secret, host: cached.host, source: "cache" };
+ }
+ return null;
+}
+
+export async function getOneDriveToken(
+ opts: { headed?: boolean } = {},
+): Promise {
+ const peeked = peekOneDriveToken();
+ if (peeked) return peeked;
+
+ const scan = await scanProfileForOneDrive(opts);
+ if (scan.token && scan.host) {
+ saveSharepointToken(scan.host, scan.token);
+ return { token: scan.token, host: scan.host, source: "profile" };
+ }
+ // No usable token in localStorage: intercept one on the OneDrive host. The
+ // host comes from the scan (an expired/encrypted entry still names it) or a
+ // stale cache entry.
+ const host = scan.host ?? cachedOneDriveHosts()[0]?.host;
+ if (!host) return null;
+ const secret = await getSharepointToken(
+ host,
+ `https://${host}/_layouts/15/onedrive.aspx`,
+ opts,
+ );
+ return secret ? { token: secret, host, source: "intercept" } : null;
+}
+
+// Load Teams in the CLI's browser profile and look for an MSAL access token
+// scoped to a `-my.sharepoint.com` host. Returns the token (when a
+// usable one exists) and the OneDrive host (whenever any key names one). If no
+// key names a host, follow the OneDrive launcher redirect to discover it.
+/* v8 ignore start -- requires live Playwright browser */
+async function scanProfileForOneDrive(opts: {
+ headed?: boolean;
+}): Promise<{ token?: string; host?: string }> {
+ let playwright: typeof import("playwright");
+ try {
+ playwright = await import("playwright");
+ } catch {
+ return {};
+ }
+
+ let context: import("playwright").BrowserContext | undefined;
+ try {
+ mkdirSync(BROWSER_PROFILE_DIR, { recursive: true });
+ context = await playwright.chromium.launchPersistentContext(
+ BROWSER_PROFILE_DIR,
+ { headless: !opts.headed },
+ );
+ const page = await context.newPage();
+ await page.goto(TEAMS_URL);
+ if (!(await waitForToken(page, IC3_AUDIENCE, 30_000, 1000))) return {};
+
+ let host: string | undefined;
+ // OneDrive tokens are acquired lazily after boot; poll for a while.
+ for (let i = 0; i < 15; i++) {
+ const found = await page.evaluate(
+ (now: number) => {
+ let hit: { secret: string; host: string } | null = null;
+ let seen: string | null = null;
+ for (let k = 0; k < localStorage.length; k++) {
+ const key = localStorage.key(k);
+ if (key === null) continue;
+ const lk = key.toLowerCase();
+ const m = lk.match(/([a-z0-9-]+-my\.sharepoint\.com)/);
+ if (!m) continue;
+ seen = seen ?? m[1];
+ if (hit || !lk.includes("accesstoken")) continue;
+ try {
+ const v = JSON.parse(localStorage.getItem(key) ?? "");
+ const secret = v.secret ?? v.accessToken;
+ const exp = Number(v.expiresOn ?? v.expires_on ?? 0);
+ if (typeof secret === "string" && (!exp || exp > now + 60)) {
+ hit = { secret, host: m[1] };
+ }
+ } catch {
+ // encrypted or non-JSON cache entry
+ }
+ }
+ return { hit, seen };
+ },
+ Math.floor(Date.now() / 1000),
+ );
+ if (found.hit) return { token: found.hit.secret, host: found.hit.host };
+ host = host ?? found.seen ?? undefined;
+ await page.waitForTimeout(1000);
+ }
+ if (host) return { host };
+
+ // The OneDrive launcher redirects to the user's personal site.
+ await page
+ .goto("https://portal.office.com/onedrive", {
+ waitUntil: "domcontentloaded",
+ })
+ .catch(() => {});
+ await page
+ .waitForURL((u) => ONEDRIVE_HOST.test(u.host.toLowerCase()), {
+ timeout: 30_000,
+ })
+ .catch(() => {});
+ const landed = new URL(page.url()).host.toLowerCase();
+ return ONEDRIVE_HOST.test(landed) ? { host: landed } : {};
+ } catch {
+ return {};
+ } finally {
+ await context?.close().catch(() => {});
+ }
+}
+/* v8 ignore stop */
+
export function getMyMri(): string | null {
const tokens = loadTokens();
if (!tokens) return null;
diff --git a/src/cli.ts b/src/cli.ts
index 9a8750a..e3e51f1 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,10 +1,17 @@
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
import Table from "cli-table3";
import { Command } from "commander";
import {
+ buildMessageBody,
createDmThread,
downloadTranscriptJson,
getActivity,
@@ -14,12 +21,20 @@ import {
listConversations,
pollConversations,
pollMessages,
+ postMessage,
resolveDriveItem,
searchMessages,
searchPeople,
sendMessage,
} from "./api.js";
-import { getMyMri, login, logout } from "./auth.js";
+import {
+ getMyMri,
+ getOneDriveToken,
+ getToken,
+ login,
+ logout,
+ peekOneDriveToken,
+} from "./auth.js";
import { getChatClient, getSearchClient, getStreamClient } from "./client.js";
import {
DATA_DIR,
@@ -40,6 +55,18 @@ import {
stripHtml,
transcriptFilename,
} from "./formatting.js";
+import {
+ buildSendFilePlan,
+ CHAT_FILES_FOLDER,
+ deriveOneDriveSite,
+ MAX_SIMPLE_UPLOAD_BYTES,
+ type OneDriveSite,
+ parseLinkScope,
+ STAGES,
+ StageError,
+ sendFile,
+ uploadFileName,
+} from "./upload.js";
function saveChatIndex(chats: FormattedChat[]): void {
mkdirSync(DATA_DIR, { recursive: true });
@@ -63,6 +90,50 @@ function resolveConversationId(idOrShort: string): string {
process.exit(1);
}
+export interface SendOptions {
+ html?: string;
+ htmlFile?: string;
+}
+
+async function readStdin(): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
+ return Buffer.concat(chunks).toString("utf-8");
+}
+
+// Decide what `send` posts: the positional text (plain) or caller-owned HTML
+// from --html / --html-file. Exactly one source is allowed.
+export async function resolveSendContent(
+ message: string | undefined,
+ opts: SendOptions,
+ io: {
+ readFile: (path: string) => string;
+ readStdin: () => Promise;
+ } = { readFile: (p) => readFileSync(p, "utf-8"), readStdin },
+): Promise<{ content: string; html: boolean } | { error: string }> {
+ const sources = [
+ message !== undefined && "",
+ opts.html !== undefined && "--html",
+ opts.htmlFile !== undefined && "--html-file",
+ ].filter(Boolean);
+ if (sources.length > 1) {
+ return {
+ error: `Use only one of , --html, --html-file (got ${sources.join(", ")}).`,
+ };
+ }
+ if (opts.htmlFile !== undefined) {
+ return { content: io.readFile(opts.htmlFile), html: true };
+ }
+ if (opts.html !== undefined) {
+ const content = opts.html === "-" ? await io.readStdin() : opts.html;
+ return { content, html: true };
+ }
+ if (message === undefined) {
+ return { error: "Nothing to send: give , --html or --html-file." };
+ }
+ return { content: message, html: false };
+}
+
function printMessages(msgs: FormattedMessage[]): void {
for (const m of msgs) {
console.log(`${chalk.green(m.time)} ${chalk.bold(m.sender)}`);
@@ -168,19 +239,183 @@ export function createProgram(): Command {
// send
program
- .command("send ")
- .description("Send a message to a conversation.")
- .action(async (chat: string, message: string) => {
- const convId = resolveConversationId(chat);
- const client = await getChatClient();
- const result = await sendMessage(client, convId, message);
- console.log(
- chalk.green("Sent.") +
- ` Arrival: ${result.OriginalArrivalTime ?? "ok"}`,
- );
- });
+ .command("send [message]")
+ .description(
+ "Send a message to a conversation. --html/--html-file post raw HTML " +
+ "as-is (no escaping): for code use with <>& escaped.",
+ )
+ .option("--html ", "Send this HTML verbatim ('-' reads from stdin)")
+ .option("--html-file ", "Send the file's contents as HTML verbatim")
+ .option(
+ "--dry-run",
+ "Print the target conversation id and request body; send nothing",
+ )
+ .action(
+ async (
+ chat: string,
+ message: string | undefined,
+ opts: SendOptions & { dryRun?: boolean },
+ ) => {
+ const resolved = await resolveSendContent(message, opts);
+ if ("error" in resolved) {
+ console.error(resolved.error);
+ process.exit(1);
+ }
+ const convId = resolveConversationId(chat);
+ const body = buildMessageBody(resolved.content, {
+ html: resolved.html,
+ });
+ if (opts.dryRun) {
+ console.log(
+ JSON.stringify({ conversationId: convId, body }, null, 2),
+ );
+ return;
+ }
+ const client = await getChatClient();
+ const result = await postMessage(client, convId, body);
+ console.log(
+ chalk.green("Sent.") +
+ ` Arrival: ${result.OriginalArrivalTime ?? "ok"}`,
+ );
+ },
+ );
+
+ // send-file
+ program
+ .command("send-file ")
+ .description(
+ "Upload a file to your OneDrive (Microsoft Teams Chat Files), share it " +
+ "and post it to a conversation as a file card.",
+ )
+ .option("--caption ", "Text shown above the file (HTML-escaped)")
+ .option(
+ "--link-scope ",
+ "Share link scope: organization (org) or anonymous (anon)",
+ "organization",
+ )
+ .option("--dry-run", "Print the plan and message body; no network at all")
+ .option("--headed", "Show the browser window if a token must be fetched")
+ .action(
+ async (
+ chat: string,
+ file: string,
+ opts: {
+ caption?: string;
+ linkScope: string;
+ dryRun?: boolean;
+ headed?: boolean;
+ },
+ ) => {
+ const scope = parseLinkScope(opts.linkScope);
+ if (!scope) {
+ console.error(
+ `Invalid --link-scope '${opts.linkScope}'. Use organization or anonymous.`,
+ );
+ process.exit(1);
+ }
+ let fileName: string;
+ let size: number;
+ try {
+ fileName = uploadFileName(file);
+ const st = statSync(file);
+ if (!st.isFile()) throw new Error(`Not a file: '${file}'.`);
+ size = st.size;
+ } catch (e) {
+ console.error((e as Error).message);
+ process.exit(1);
+ }
+ if (size > MAX_SIMPLE_UPLOAD_BYTES) {
+ console.error(
+ `File is ${size} bytes; send-file supports up to ${MAX_SIMPLE_UPLOAD_BYTES} bytes.`,
+ );
+ process.exit(1);
+ }
+ const convId = resolveConversationId(chat);
+
+ if (opts.dryRun) {
+ let site: OneDriveSite | null = null;
+ const peeked = peekOneDriveToken();
+ if (peeked) {
+ try {
+ site = deriveOneDriveSite(peeked.token, peeked.host);
+ } catch {
+ site = null;
+ }
+ }
+ const plan = buildSendFilePlan({
+ conversationId: convId,
+ path: file,
+ fileName,
+ size,
+ scope,
+ caption: opts.caption,
+ site,
+ });
+ console.log(JSON.stringify(plan, null, 2));
+ return;
+ }
+
+ // Resolve both tokens before uploading, so a missing chat token can't
+ // leave an orphaned upload behind.
+ const client = await getChatClient();
+ const sp = await getOneDriveToken({ headed: opts.headed });
+ if (!sp) {
+ console.error(
+ "Could not obtain a OneDrive (SharePoint) token. Set TEAMS_SP_TOKEN " +
+ "or re-run `teams login`. Nothing was uploaded.",
+ );
+ process.exit(1);
+ }
+ let site: OneDriveSite;
+ try {
+ site = deriveOneDriveSite(sp.token, sp.host);
+ } catch (e) {
+ console.error(`${(e as Error).message} Nothing was uploaded.`);
+ process.exit(1);
+ }
+
+ console.error(
+ chalk.dim(`OneDrive site ${site.siteUrl} (token: ${sp.source})`),
+ );
+ try {
+ const result = await sendFile({
+ chat: client,
+ conversationId: convId,
+ site,
+ token: sp.token,
+ fileName,
+ data: readFileSync(file),
+ scope,
+ caption: opts.caption,
+ onStage: (r) =>
+ console.error(
+ `[${STAGES.indexOf(r.stage) + 1}/${STAGES.length} ${r.stage}] ${r.receipt}`,
+ ),
+ });
+ console.log(`${chalk.green("Sent.")} Arrival: ${result.arrival}`);
+ console.log(`Share URL: ${result.shareUrl}`);
+ } catch (e) {
+ if (!(e instanceof StageError)) throw e;
+ console.error(
+ chalk.red(`Stage '${e.stage}' FAILED: ${e.message}`) +
+ `\nSucceeded: ${
+ e.completed.map((r) => r.stage).join(", ") || "none"
+ }`,
+ );
+ for (const r of e.completed) {
+ console.error(` ${r.stage}: ${r.receipt}`);
+ }
+ if (e.completed.length > 0) {
+ console.error(
+ "The file/link above was created but not posted; remove it " +
+ `from OneDrive (${CHAT_FILES_FOLDER}) if unwanted.`,
+ );
+ }
+ process.exit(1);
+ }
+ },
+ );
- // search
program
.command("search ")
.description("Search messages across all conversations.")
diff --git a/src/upload.ts b/src/upload.ts
new file mode 100644
index 0000000..295af92
--- /dev/null
+++ b/src/upload.ts
@@ -0,0 +1,464 @@
+import { basename } from "node:path";
+import { buildMessageBody, type MessageBody, postMessage } from "./api.js";
+import type { ApiClient } from "./client.js";
+
+// Sending a file = upload it to the sender's OneDrive (the folder Teams itself
+// uses), mint a share link, then post a chat message whose `properties.files`
+// carries a file object the Teams client renders as a file card. The file
+// object shape below is field-proven against the real client — keep it exact.
+
+export const CHAT_FILES_FOLDER = "Microsoft Teams Chat Files";
+
+// OneDrive simple upload (single PUT) is capped at 250 MB; larger files need an
+// upload session, which this command does not implement.
+export const MAX_SIMPLE_UPLOAD_BYTES = 250 * 1024 * 1024;
+
+export const POST_UPLOAD = "";
+
+export type LinkScope = "organization" | "anonymous";
+
+const LINK_SCOPE_ALIASES: Record = {
+ organization: "organization",
+ org: "organization",
+ anonymous: "anonymous",
+ anon: "anonymous",
+};
+
+export function parseLinkScope(value: string): LinkScope | null {
+ return LINK_SCOPE_ALIASES[value.toLowerCase()] ?? null;
+}
+
+// The file object's `permissionScope`. "Anyone" is what the client sent for an
+// anonymous link in the proven payload.
+export const PERMISSION_SCOPE: Record = {
+ anonymous: "Anyone",
+ organization: "Organization",
+};
+
+// ── Naming / encoding ───────────────────────────────────────────────────────
+
+/** The upload name for a local path: its basename. Throws when empty. */
+export function uploadFileName(path: string): string {
+ const name = basename(path);
+ if (!name || name === "." || name === "..") {
+ throw new Error(`Invalid file name: '${path}'.`);
+ }
+ return name;
+}
+
+/** Lowercased extension without the dot ("" when there is none). */
+export function fileExtension(name: string): string {
+ const dot = name.lastIndexOf(".");
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
+}
+
+/** URL-encode each path segment on its own (survives Unicode / Arabic names). */
+export function encodePathSegments(...segments: string[]): string {
+ return segments.map((s) => encodeURIComponent(s)).join("/");
+}
+
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/** Message content for an optional caption: escaped into . */
+export function captionHtml(caption?: string): string {
+ return caption ? `` : "";
+}
+
+// ── Site derivation ─────────────────────────────────────────────────────────
+
+export interface OneDriveSite {
+ host: string; // "-my.sharepoint.com"
+ siteUrl: string; // "https:///personal/" (API prefix)
+ base: string; // siteUrl + "/" (file object baseUrl)
+}
+
+export function decodeJwtClaims(token: string): Record {
+ try {
+ const payload = token.split(".")[1];
+ const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
+ return JSON.parse(Buffer.from(padded, "base64url").toString());
+ } catch {
+ throw new Error("SharePoint token is not a decodable JWT.");
+ }
+}
+
+/** Personal-site slug for a UPN: `a.b@tenant.com` → `a_b_tenant_com`. */
+export function personalSiteSlug(upn: string): string {
+ return upn.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
+}
+
+const ONEDRIVE_HOST = /^[a-z0-9-]+-my\.sharepoint\.com$/;
+
+/**
+ * Derive the user's OneDrive personal site from the SharePoint token: the
+ * `aud` claim gives the host (falling back to `fallbackHost`, the host the
+ * token was captured on, when `aud` is the SharePoint app id rather than a
+ * URL), the `upn`/`unique_name` claim gives the personal-site slug.
+ */
+export function deriveOneDriveSite(
+ token: string,
+ fallbackHost?: string,
+): OneDriveSite {
+ const claims = decodeJwtClaims(token);
+ let host = "";
+ const aud = typeof claims.aud === "string" ? claims.aud : "";
+ try {
+ host = new URL(aud).host.toLowerCase();
+ } catch {
+ host = (fallbackHost ?? "").toLowerCase();
+ }
+ if (!ONEDRIVE_HOST.test(host)) {
+ throw new Error(
+ `SharePoint token audience '${aud || "(none)"}' is not a OneDrive ` +
+ "host (-my.sharepoint.com).",
+ );
+ }
+ const upn =
+ (typeof claims.upn === "string" && claims.upn) ||
+ (typeof claims.unique_name === "string" && claims.unique_name) ||
+ "";
+ if (!upn.includes("@")) {
+ throw new Error("SharePoint token carries no upn/unique_name claim.");
+ }
+ const siteUrl = `https://${host}/personal/${personalSiteSlug(upn)}`;
+ return { host, siteUrl, base: `${siteUrl}/` };
+}
+
+// ── File object + message body ──────────────────────────────────────────────
+
+export interface FileObjectInput {
+ fileName: string; // server-returned (final) name
+ base: string;
+ listItemUniqueId: string;
+ listId: string;
+ siteId: string;
+ shareUrl: string;
+ shareId: string;
+ scope: LinkScope;
+}
+
+export function objectUrlFor(base: string, fileName: string): string {
+ return `${base}Documents/${encodePathSegments(CHAT_FILES_FOLDER, fileName)}`;
+}
+
+/** The `properties.files[]` entry, in the exact field order the client sends. */
+export function buildFileObject(f: FileObjectInput): Record {
+ const objectUrl = objectUrlFor(f.base, f.fileName);
+ const ext = fileExtension(f.fileName);
+ return {
+ itemid: f.listItemUniqueId,
+ fileName: f.fileName,
+ fileType: ext,
+ fileInfo: {
+ itemId: null,
+ fileUrl: objectUrl,
+ siteUrl: f.base,
+ serverRelativeUrl: "",
+ shareUrl: f.shareUrl,
+ shareId: f.shareId,
+ },
+ fileChicletState: { serviceName: "p2p", state: "active" },
+ "@type": "http://schema.skype.com/File",
+ version: 2,
+ id: f.listItemUniqueId,
+ baseUrl: f.base,
+ objectUrl,
+ openUrl: null,
+ type: ext,
+ title: f.fileName,
+ state: "active",
+ chicletBreadcrumbs: null,
+ providerData: "",
+ botFileProperties: {},
+ isUploadError: null,
+ progressComplete: null,
+ permissionScope: PERMISSION_SCOPE[f.scope],
+ filePreview: { previewUrl: "", previewHeight: 0, previewWidth: 0 },
+ sharepointIds: {
+ listId: f.listId,
+ listItemUniqueId: f.listItemUniqueId,
+ siteId: f.siteId,
+ siteUrl: null,
+ webId: null,
+ driveId: null,
+ },
+ publication: null,
+ site: null,
+ };
+}
+
+/** Chat message carrying file objects; `files` is a JSON string, as stored. */
+export function buildFileMessageBody(
+ files: Record[],
+ caption?: string,
+): MessageBody {
+ return buildMessageBody(captionHtml(caption), {
+ html: true,
+ amsreferences: [],
+ properties: {
+ mentions: "[]",
+ cards: "[]",
+ links: "[]",
+ files: JSON.stringify(files),
+ formatVariant: "TEAMS",
+ },
+ });
+}
+
+// ── OneDrive calls (plain fetch, explicit SharePoint headers) ───────────────
+// Never the chat client: its fetch attaches ic3 auth to a different host.
+
+async function spFetch(
+ token: string,
+ url: string,
+ init: RequestInit & { timeoutMs?: number } = {},
+): Promise {
+ const { timeoutMs = 30_000, headers, ...rest } = init;
+ const resp = await fetch(url, {
+ ...rest,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/json",
+ ...(headers as Record),
+ },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!resp.ok) {
+ const body = await resp.text().catch(() => "");
+ throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`);
+ }
+ return resp.json();
+}
+
+export async function uploadToChatFiles(
+ site: OneDriveSite,
+ token: string,
+ fileName: string,
+ data: Uint8Array,
+): Promise<{ id: string; name: string }> {
+ const path = encodePathSegments(CHAT_FILES_FOLDER, fileName);
+ const url =
+ `${site.siteUrl}/_api/v2.1/drive/root:/${path}:/content` +
+ "?@name.conflictBehavior=rename";
+ let res: Record;
+ try {
+ res = (await spFetch(token, url, {
+ method: "PUT",
+ headers: { "Content-Type": "application/octet-stream" },
+ body: data,
+ timeoutMs: 300_000,
+ })) as Record;
+ } catch (e) {
+ // First drive call: a wrong derived site shows up here.
+ throw new Error(
+ `${(e as Error).message} (derived OneDrive site: ${site.siteUrl})`,
+ );
+ }
+ const id = typeof res.id === "string" ? res.id : "";
+ if (!id) throw new Error("Upload response carried no item id.");
+ // With conflictBehavior=rename the server may suffix the name — use its name.
+ const name = typeof res.name === "string" && res.name ? res.name : fileName;
+ return { id, name };
+}
+
+export interface SharepointIds {
+ listItemUniqueId: string;
+ siteId: string;
+ listId: string;
+}
+
+export async function getItemSharepointIds(
+ site: OneDriveSite,
+ token: string,
+ itemId: string,
+): Promise {
+ const res = (await spFetch(
+ token,
+ `${site.siteUrl}/_api/v2.1/drive/items/${itemId}?select=id,sharepointIds`,
+ )) as Record;
+ const ids = (res.sharepointIds as Record) ?? {};
+ const out = {
+ listItemUniqueId: String(ids.listItemUniqueId ?? ""),
+ siteId: String(ids.siteId ?? ""),
+ listId: String(ids.listId ?? ""),
+ };
+ if (!out.listItemUniqueId || !out.siteId || !out.listId) {
+ throw new Error("Item response is missing sharepointIds.");
+ }
+ return out;
+}
+
+export async function createShareLink(
+ site: OneDriveSite,
+ token: string,
+ itemId: string,
+ scope: LinkScope,
+): Promise<{ shareUrl: string; shareId: string }> {
+ const res = (await spFetch(
+ token,
+ `${site.siteUrl}/_api/v2.1/drive/items/${itemId}/microsoft.graph.createLink`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type: "view", scope }),
+ },
+ )) as Record;
+ const link = (res.link as Record) ?? {};
+ const shareUrl = typeof link.webUrl === "string" ? link.webUrl : "";
+ const shareId = typeof res.id === "string" ? res.id : "";
+ if (!shareUrl || !shareId) {
+ throw new Error("createLink response is missing link.webUrl or id.");
+ }
+ return { shareUrl, shareId };
+}
+
+// ── Staged send ─────────────────────────────────────────────────────────────
+
+export type Stage = "upload" | "ids" | "link" | "message";
+export const STAGES: Stage[] = ["upload", "ids", "link", "message"];
+
+export interface StageReceipt {
+ stage: Stage;
+ receipt: string;
+}
+
+/** A stage failed; `completed` lists the stages that already succeeded. */
+export class StageError extends Error {
+ constructor(
+ readonly stage: Stage,
+ readonly completed: StageReceipt[],
+ cause: Error,
+ ) {
+ super(cause.message);
+ }
+}
+
+export interface SendFileResult {
+ fileName: string;
+ itemId: string;
+ shareUrl: string;
+ arrival: string;
+ receipts: StageReceipt[];
+}
+
+export async function sendFile(opts: {
+ chat: ApiClient;
+ conversationId: string;
+ site: OneDriveSite;
+ token: string;
+ fileName: string;
+ data: Uint8Array;
+ scope: LinkScope;
+ caption?: string;
+ onStage?: (r: StageReceipt) => void;
+}): Promise {
+ const receipts: StageReceipt[] = [];
+ async function stage(
+ name: Stage,
+ run: () => Promise,
+ receipt: (v: T) => string,
+ ): Promise {
+ let value: T;
+ try {
+ value = await run();
+ } catch (e) {
+ throw new StageError(name, [...receipts], e as Error);
+ }
+ const r = { stage: name, receipt: receipt(value) };
+ receipts.push(r);
+ opts.onStage?.(r);
+ return value;
+ }
+
+ const { site, token } = opts;
+ const uploaded = await stage(
+ "upload",
+ () => uploadToChatFiles(site, token, opts.fileName, opts.data),
+ (u) => `${u.name} (item ${u.id}) at ${objectUrlFor(site.base, u.name)}`,
+ );
+ const ids = await stage(
+ "ids",
+ () => getItemSharepointIds(site, token, uploaded.id),
+ (i) => `listItemUniqueId ${i.listItemUniqueId}`,
+ );
+ const link = await stage(
+ "link",
+ () => createShareLink(site, token, uploaded.id, opts.scope),
+ (l) => `${l.shareUrl} (shareId ${l.shareId}, scope ${opts.scope})`,
+ );
+ const body = buildFileMessageBody(
+ [
+ buildFileObject({
+ fileName: uploaded.name,
+ base: site.base,
+ ...ids,
+ ...link,
+ scope: opts.scope,
+ }),
+ ],
+ opts.caption,
+ );
+ const posted = await stage(
+ "message",
+ () => postMessage(opts.chat, opts.conversationId, body),
+ (p) => `arrival ${p.OriginalArrivalTime ?? "ok"}`,
+ );
+ return {
+ fileName: uploaded.name,
+ itemId: uploaded.id,
+ shareUrl: link.shareUrl,
+ arrival: String(posted.OriginalArrivalTime ?? "ok"),
+ receipts,
+ };
+}
+
+/** Offline plan for --dry-run: no network, ids left as placeholders. */
+export function buildSendFilePlan(opts: {
+ conversationId: string;
+ path: string;
+ fileName: string;
+ size: number;
+ scope: LinkScope;
+ caption?: string;
+ site: OneDriveSite | null;
+}): Record {
+ const base = opts.site?.base ?? POST_UPLOAD;
+ const fileObject = buildFileObject({
+ fileName: opts.fileName,
+ base,
+ listItemUniqueId: POST_UPLOAD,
+ listId: POST_UPLOAD,
+ siteId: POST_UPLOAD,
+ shareUrl: POST_UPLOAD,
+ shareId: POST_UPLOAD,
+ scope: opts.scope,
+ });
+ return {
+ conversationId: opts.conversationId,
+ site: opts.site
+ ? { host: opts.site.host, base: opts.site.base }
+ : "not derivable offline (no TEAMS_SP_TOKEN or cached OneDrive token)",
+ file: {
+ path: opts.path,
+ name: opts.fileName,
+ size: opts.size,
+ fileType: fileExtension(opts.fileName),
+ note: "the server may rename on conflict; the returned name is used",
+ },
+ upload: {
+ method: "PUT",
+ path:
+ `/_api/v2.1/drive/root:/${encodePathSegments(CHAT_FILES_FOLDER, opts.fileName)}` +
+ ":/content?@name.conflictBehavior=rename",
+ },
+ linkScope: opts.scope,
+ permissionScope: PERMISSION_SCOPE[opts.scope],
+ body: buildFileMessageBody([fileObject], opts.caption),
+ };
+}
diff --git a/tests/api.test.ts b/tests/api.test.ts
index aa0c02b..16cadf8 100644
--- a/tests/api.test.ts
+++ b/tests/api.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
+ buildMessageBody,
createDmThread,
downloadTranscriptJson,
getActivity,
@@ -76,6 +77,54 @@ describe("sendMessage", () => {
const body = JSON.parse(init.body);
expect(body.content).toBe('He said "hello" & {test}
');
});
+
+ it("keeps the plain body shape unchanged", async () => {
+ const client = mockClient({});
+ await sendMessage(client, "19:abc", "line1\nline2");
+ const fetchFn = client.fetch as ReturnType;
+ const [url, init] = fetchFn.mock.calls[0];
+ expect(url).toBe(`${BASE}/conversations/19:abc/messages`);
+ const body = JSON.parse(init.body);
+ expect(Object.keys(body).sort()).toEqual([
+ "clientmessageid",
+ "content",
+ "contenttype",
+ "messagetype",
+ ]);
+ expect(body.content).toBe("line1\nline2
");
+ expect(body.messagetype).toBe("RichText/Html");
+ expect(body.contenttype).toBe("text");
+ expect(body.clientmessageid).toMatch(/^\d{19}$/);
+ });
+
+ it("posts html content verbatim in html mode", async () => {
+ const client = mockClient({ OriginalArrivalTime: "789" });
+ const html = "";
+ const result = await sendMessage(client, "19:abc", html, { html: true });
+ expect(result.OriginalArrivalTime).toBe("789");
+ const fetchFn = client.fetch as ReturnType;
+ const [url, init] = fetchFn.mock.calls[0];
+ expect(url).toBe(`${BASE}/conversations/19:abc/messages`);
+ const body = JSON.parse(init.body);
+ expect(body.content).toBe(html);
+ expect(body.messagetype).toBe("RichText/Html");
+ expect(body.contenttype).toBe("text");
+ expect(body.clientmessageid).toMatch(/^\d{19}$/);
+ });
+});
+
+describe("buildMessageBody", () => {
+ it("does not wrap or transform html", () => {
+ const html = "";
+ expect(buildMessageBody(html, { html: true }).content).toBe(html);
+ });
+
+ it("generates a fresh clientmessageid each call", () => {
+ const a = buildMessageBody("x").clientmessageid;
+ const b = buildMessageBody("x").clientmessageid;
+ expect(a).toMatch(/^\d{19}$/);
+ expect(a).not.toBe(b);
+ });
});
describe("getThreadMembers", () => {
diff --git a/tests/send-file.test.ts b/tests/send-file.test.ts
new file mode 100644
index 0000000..0b977d8
--- /dev/null
+++ b/tests/send-file.test.ts
@@ -0,0 +1,284 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const hoisted = vi.hoisted(() => ({ playwrightImports: 0 }));
+
+// Any fall-through to the browser ladder must not launch a real browser.
+vi.mock("playwright", () => {
+ hoisted.playwrightImports++;
+ throw new Error("playwright not available in tests");
+});
+
+vi.mock("../src/config.js", async (importOriginal) => {
+ const orig = await importOriginal();
+ const { join } = await import("node:path");
+ const { tmpdir } = await import("node:os");
+ const dir = join(tmpdir(), "teams-cli-test-send-file");
+ return {
+ ...orig,
+ DATA_DIR: dir,
+ TOKENS_FILE: join(dir, "tokens.json"),
+ LAST_CHATS_FILE: join(dir, "last_chats.json"),
+ BROWSER_PROFILE_DIR: join(dir, "browser-profile"),
+ };
+});
+
+const CHAT_BASE = "https://teams.cloud.microsoft/api/chatsvc/amer/v1/users/ME";
+const chatFetch = vi.fn();
+const getChatClient = vi.fn(async () => ({
+ baseUrl: CHAT_BASE,
+ fetch: chatFetch,
+}));
+
+vi.mock("../src/client.js", () => ({
+ getChatClient: () => getChatClient(),
+ getSearchClient: vi.fn(),
+ getStreamClient: vi.fn(),
+}));
+
+const { createProgram } = await import("../src/cli.js");
+const { getOneDriveToken, peekOneDriveToken, saveTokens } = await import(
+ "../src/auth.js"
+);
+
+const DATA_DIR = join(tmpdir(), "teams-cli-test-send-file");
+const CONV = "19:abc@thread.v2";
+const FUTURE = Math.floor(Date.now() / 1000) + 3600;
+
+function fakeJwt(claims: Record): string {
+ const enc = (o: unknown) =>
+ Buffer.from(JSON.stringify(o)).toString("base64url");
+ return `${enc({ alg: "none" })}.${enc(claims)}.sig`;
+}
+
+const ENV_TOKEN = fakeJwt({
+ aud: "https://contoso-my.sharepoint.com",
+ upn: "a.b@contoso.com",
+ exp: FUTURE,
+});
+const CACHED_TOKEN = fakeJwt({
+ aud: "https://fabrikam-my.sharepoint.com",
+ upn: "c.d@fabrikam.com",
+ exp: FUTURE,
+});
+const CONTOSO_SITE =
+ "https://contoso-my.sharepoint.com/personal/a_b_contoso_com";
+
+function jsonResp(status: number, body: unknown) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+}
+
+function stubOneDrive(failAt?: "link") {
+ const spFetch = vi.fn(async (url: string, init?: RequestInit) => {
+ if (init?.method === "PUT") {
+ return jsonResp(201, { id: "ITEM1", name: "notes.txt" });
+ }
+ if (url.includes("createLink")) {
+ return failAt === "link"
+ ? jsonResp(403, { error: "accessDenied" })
+ : jsonResp(201, {
+ id: "share-1",
+ link: { webUrl: "https://contoso-my.sharepoint.com/:t:/g/x" },
+ });
+ }
+ return jsonResp(200, {
+ sharepointIds: {
+ listItemUniqueId: "11111111-2222-3333-4444-555555555555",
+ siteId: "site-1",
+ listId: "list-1",
+ },
+ });
+ });
+ vi.stubGlobal("fetch", spFetch);
+ return spFetch;
+}
+
+describe("send-file command", () => {
+ let out: string[];
+ let err: string[];
+ let file: string;
+
+ beforeEach(() => {
+ out = [];
+ err = [];
+ rmSync(DATA_DIR, { recursive: true, force: true });
+ mkdirSync(DATA_DIR, { recursive: true });
+ file = join(mkdtempSync(join(tmpdir(), "teams-send-file-")), "notes.txt");
+ writeFileSync(file, "abc");
+ chatFetch.mockReset();
+ getChatClient.mockClear();
+ delete process.env.TEAMS_SP_TOKEN;
+ vi.spyOn(console, "log").mockImplementation((s) => out.push(String(s)));
+ vi.spyOn(console, "error").mockImplementation((s) => err.push(String(s)));
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`exit ${code}`);
+ }) as never);
+ });
+
+ afterEach(() => {
+ delete process.env.TEAMS_SP_TOKEN;
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ async function run(...args: string[]) {
+ const program = createProgram();
+ program.exitOverride();
+ await program.parseAsync(["node", "teams", "send-file", ...args]);
+ }
+
+ it("--dry-run touches no network and prints a plan", async () => {
+ process.env.TEAMS_SP_TOKEN = ENV_TOKEN;
+ const netFetch = vi.fn(() => {
+ throw new Error("network must not be touched");
+ });
+ vi.stubGlobal("fetch", netFetch);
+
+ await run(CONV, file, "--caption", "a & b", "--dry-run");
+
+ expect(netFetch).not.toHaveBeenCalled();
+ expect(getChatClient).not.toHaveBeenCalled();
+ expect(hoisted.playwrightImports).toBe(0);
+ const plan = JSON.parse(out.join("\n"));
+ expect(plan.conversationId).toBe(CONV);
+ expect(plan.site.base).toBe(`${CONTOSO_SITE}/`);
+ expect(plan.file).toMatchObject({ name: "notes.txt", size: 3 });
+ expect(plan.linkScope).toBe("organization");
+ expect(plan.permissionScope).toBe("Organization");
+ expect(plan.body.content).toBe("");
+ const [fo] = JSON.parse(plan.body.properties.files);
+ expect(fo.id).toBe("");
+ expect(JSON.stringify(plan)).not.toContain(ENV_TOKEN);
+ });
+
+ it("--dry-run without any offline token still makes no network call", async () => {
+ const netFetch = vi.fn();
+ vi.stubGlobal("fetch", netFetch);
+ await run(CONV, file, "--link-scope", "anon", "--dry-run");
+ expect(netFetch).not.toHaveBeenCalled();
+ expect(hoisted.playwrightImports).toBe(0);
+ const plan = JSON.parse(out.join("\n"));
+ expect(plan.site).toMatch(/not derivable offline/);
+ expect(plan.permissionScope).toBe("Anyone");
+ });
+
+ it("sends with TEAMS_SP_TOKEN taking precedence over a cached token", async () => {
+ saveTokens({
+ sharepoint: {
+ "fabrikam-my.sharepoint.com": {
+ secret: CACHED_TOKEN,
+ expires_on: FUTURE,
+ },
+ },
+ });
+ process.env.TEAMS_SP_TOKEN = ENV_TOKEN;
+ const spFetch = stubOneDrive();
+ chatFetch.mockResolvedValue(
+ jsonResp(201, { OriginalArrivalTime: 1700000000000 }),
+ );
+
+ await run(CONV, file);
+
+ for (const [url, init] of spFetch.mock.calls) {
+ expect(url.startsWith(`${CONTOSO_SITE}/_api/`)).toBe(true);
+ expect(init.headers.Authorization).toBe(`Bearer ${ENV_TOKEN}`);
+ }
+ expect(chatFetch).toHaveBeenCalledTimes(1);
+ expect(out.join("\n")).toContain("Arrival: 1700000000000");
+ expect(out.join("\n")).toContain(
+ "Share URL: https://contoso-my.sharepoint.com/:t:/g/x",
+ );
+ const log = err.join("\n");
+ for (const s of [
+ "[1/4 upload]",
+ "[2/4 ids]",
+ "[3/4 link]",
+ "[4/4 message]",
+ ])
+ expect(log).toContain(s);
+ expect(log).toContain("token: env");
+ expect(log).not.toContain(ENV_TOKEN);
+ });
+
+ it("reports succeeded stages and exits nonzero when a stage fails", async () => {
+ process.env.TEAMS_SP_TOKEN = ENV_TOKEN;
+ stubOneDrive("link");
+ await expect(run(CONV, file)).rejects.toThrow("exit 1");
+ const log = err.join("\n");
+ expect(log).toContain("Stage 'link' FAILED: HTTP 403");
+ expect(log).toContain("Succeeded: upload, ids");
+ expect(log).toContain("upload: notes.txt (item ITEM1)");
+ expect(log).toMatch(/created but not posted/);
+ expect(chatFetch).not.toHaveBeenCalled();
+ });
+
+ it("rejects an unknown --link-scope before any network", async () => {
+ const netFetch = vi.fn();
+ vi.stubGlobal("fetch", netFetch);
+ await expect(run(CONV, file, "--link-scope", "users")).rejects.toThrow(
+ "exit 1",
+ );
+ expect(err.join("\n")).toMatch(/Invalid --link-scope/);
+ expect(netFetch).not.toHaveBeenCalled();
+ });
+
+ it("fails before uploading when no OneDrive token is available", async () => {
+ const netFetch = vi.fn();
+ vi.stubGlobal("fetch", netFetch);
+ await expect(run(CONV, file)).rejects.toThrow("exit 1");
+ expect(err.join("\n")).toMatch(/Nothing was uploaded/);
+ expect(netFetch).not.toHaveBeenCalled();
+ });
+});
+
+describe("OneDrive token ladder", () => {
+ beforeEach(() => {
+ rmSync(DATA_DIR, { recursive: true, force: true });
+ mkdirSync(DATA_DIR, { recursive: true });
+ delete process.env.TEAMS_SP_TOKEN;
+ });
+ afterEach(() => {
+ delete process.env.TEAMS_SP_TOKEN;
+ });
+
+ function cache(host: string, secret: string, expires_on: number) {
+ saveTokens({ sharepoint: { [host]: { secret, expires_on } } });
+ }
+
+ it("prefers TEAMS_SP_TOKEN over the cache", async () => {
+ cache("fabrikam-my.sharepoint.com", CACHED_TOKEN, FUTURE);
+ process.env.TEAMS_SP_TOKEN = ` ${ENV_TOKEN}\n`;
+ expect(await getOneDriveToken()).toEqual({
+ token: ENV_TOKEN,
+ source: "env",
+ });
+ });
+
+ it("falls back to a cached OneDrive-host token", async () => {
+ cache("fabrikam-my.sharepoint.com", CACHED_TOKEN, FUTURE);
+ expect(peekOneDriveToken()).toEqual({
+ token: CACHED_TOKEN,
+ host: "fabrikam-my.sharepoint.com",
+ source: "cache",
+ });
+ });
+
+ it("ignores expired and non-OneDrive cache entries", async () => {
+ saveTokens({
+ sharepoint: {
+ "fabrikam-my.sharepoint.com": { secret: CACHED_TOKEN, expires_on: 1 },
+ "fabrikam.sharepoint.com": { secret: CACHED_TOKEN, expires_on: FUTURE },
+ },
+ });
+ expect(peekOneDriveToken()).toBeNull();
+ // Browser steps are unavailable in tests, so the ladder yields nothing.
+ expect(await getOneDriveToken()).toBeNull();
+ });
+});
diff --git a/tests/send.test.ts b/tests/send.test.ts
new file mode 100644
index 0000000..f02bb17
--- /dev/null
+++ b/tests/send.test.ts
@@ -0,0 +1,164 @@
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const BASE = "https://teams.cloud.microsoft/api/chatsvc/amer/v1/users/ME";
+const fetchMock = vi.fn();
+const getChatClient = vi.fn(async () => ({ baseUrl: BASE, fetch: fetchMock }));
+
+vi.mock("../src/client.js", () => ({
+ getChatClient: () => getChatClient(),
+ getSearchClient: vi.fn(),
+ getStreamClient: vi.fn(),
+}));
+
+const { createProgram, resolveSendContent } = await import("../src/cli.js");
+
+const noIo = {
+ readFile: () => {
+ throw new Error("readFile should not be called");
+ },
+ readStdin: () => Promise.reject(new Error("readStdin should not be called")),
+};
+
+describe("resolveSendContent", () => {
+ it("returns plain text for a positional message", async () => {
+ expect(await resolveSendContent("hi", {}, noIo)).toEqual({
+ content: "hi",
+ html: false,
+ });
+ });
+
+ it("returns --html verbatim", async () => {
+ expect(
+ await resolveSendContent(undefined, { html: "a
" }, noIo),
+ ).toEqual({ content: "a
", html: true });
+ });
+
+ it("reads html from stdin when --html is '-'", async () => {
+ const io = { ...noIo, readStdin: async () => "\n" };
+ expect(await resolveSendContent(undefined, { html: "-" }, io)).toEqual({
+ content: "\n",
+ html: true,
+ });
+ });
+
+ it("reads html from --html-file", async () => {
+ const io = { ...noIo, readFile: (p: string) => `- ${p}
` };
+ expect(await resolveSendContent(undefined, { htmlFile: "f" }, io)).toEqual({
+ content: "- f
",
+ html: true,
+ });
+ });
+
+ it("errors when both a positional and an html flag are given", async () => {
+ const r = await resolveSendContent("hi", { html: "a
" }, noIo);
+ expect(r).toHaveProperty("error");
+ expect((r as { error: string }).error).toMatch(/only one of/);
+ });
+
+ it("errors when --html and --html-file are both given", async () => {
+ const r = await resolveSendContent(
+ undefined,
+ { html: "a
", htmlFile: "f" },
+ noIo,
+ );
+ expect(r).toHaveProperty("error");
+ });
+
+ it("errors when nothing is given", async () => {
+ expect(await resolveSendContent(undefined, {}, noIo)).toHaveProperty(
+ "error",
+ );
+ });
+});
+
+describe("send command", () => {
+ let out: string[];
+ let err: string[];
+
+ beforeEach(() => {
+ out = [];
+ err = [];
+ fetchMock.mockReset();
+ getChatClient.mockClear();
+ vi.spyOn(console, "log").mockImplementation((s) => out.push(String(s)));
+ vi.spyOn(console, "error").mockImplementation((s) => err.push(String(s)));
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`exit ${code}`);
+ }) as never);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ async function run(...args: string[]) {
+ const program = createProgram();
+ program.exitOverride();
+ await program.parseAsync(["node", "teams", "send", ...args]);
+ }
+
+ it("--dry-run prints the html body and makes no client call", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "teams-send-"));
+ const file = join(dir, "msg.html");
+ const html = "";
+ writeFileSync(file, html);
+
+ await run("19:abc@thread.v2", "--html-file", file, "--dry-run");
+
+ expect(getChatClient).not.toHaveBeenCalled();
+ expect(fetchMock).not.toHaveBeenCalled();
+ const printed = JSON.parse(out.join("\n"));
+ expect(printed.conversationId).toBe("19:abc@thread.v2");
+ expect(printed.body.content).toBe(html);
+ expect(printed.body.messagetype).toBe("RichText/Html");
+ expect(printed.body.contenttype).toBe("text");
+ expect(printed.body.clientmessageid).toMatch(/^\d{19}$/);
+ });
+
+ it("--dry-run works in plain mode", async () => {
+ await run("19:abc@thread.v2", "hello", "--dry-run");
+ expect(getChatClient).not.toHaveBeenCalled();
+ const printed = JSON.parse(out.join("\n"));
+ expect(printed.body.content).toBe("hello
");
+ });
+
+ it("errors on positional message plus --html, sending nothing", async () => {
+ await expect(
+ run("19:abc@thread.v2", "hello", "--html", "x
"),
+ ).rejects.toThrow("exit 1");
+ expect(err.join("\n")).toMatch(/only one of/);
+ expect(getChatClient).not.toHaveBeenCalled();
+ });
+
+ it("posts --html verbatim and prints the arrival receipt", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ OriginalArrivalTime: 1700000000000 }),
+ });
+ await run("19:abc@thread.v2", "--html", "- a
");
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(`${BASE}/conversations/19:abc@thread.v2/messages`);
+ const body = JSON.parse(init.body);
+ expect(body.content).toBe("- a
");
+ expect(body.messagetype).toBe("RichText/Html");
+ expect(out.join("\n")).toContain("Arrival: 1700000000000");
+ });
+
+ it("plain send posts the unchanged -wrapped body", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ OriginalArrivalTime: 1 }),
+ });
+ await run("19:abc@thread.v2", "hello");
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body).toEqual({
+ content: "
hello
",
+ messagetype: "RichText/Html",
+ contenttype: "text",
+ clientmessageid: expect.stringMatching(/^\d{19}$/),
+ });
+ });
+});
diff --git a/tests/upload.test.ts b/tests/upload.test.ts
new file mode 100644
index 0000000..94e0d84
--- /dev/null
+++ b/tests/upload.test.ts
@@ -0,0 +1,424 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ buildFileMessageBody,
+ buildFileObject,
+ buildSendFilePlan,
+ captionHtml,
+ deriveOneDriveSite,
+ encodePathSegments,
+ fileExtension,
+ type OneDriveSite,
+ objectUrlFor,
+ PERMISSION_SCOPE,
+ parseLinkScope,
+ personalSiteSlug,
+ StageError,
+ sendFile,
+ uploadFileName,
+} from "../src/upload.js";
+
+function fakeJwt(claims: Record): string {
+ const enc = (o: unknown) =>
+ Buffer.from(JSON.stringify(o)).toString("base64url");
+ return `${enc({ alg: "none" })}.${enc(claims)}.sig`;
+}
+
+const SITE: OneDriveSite = {
+ host: "contoso-my.sharepoint.com",
+ siteUrl: "https://contoso-my.sharepoint.com/personal/a_b_contoso_com",
+ base: "https://contoso-my.sharepoint.com/personal/a_b_contoso_com/",
+};
+const GUID = "11111111-2222-3333-4444-555555555555";
+const LIST_ID = "aaaaaaaa-0000-0000-0000-000000000001";
+const SITE_ID = "bbbbbbbb-0000-0000-0000-000000000002";
+const SHARE_URL = "https://contoso-my.sharepoint.com/:t:/g/personal/x/AbCdEf";
+const SHARE_ID = "share-123";
+
+// Golden fixture: the field-proven file object, dynamic values parameterized.
+function goldenFileObject(p: {
+ fileName: string;
+ ext: string;
+ objectUrl: string;
+ permissionScope: string;
+}) {
+ return {
+ itemid: GUID,
+ fileName: p.fileName,
+ fileType: p.ext,
+ fileInfo: {
+ itemId: null,
+ fileUrl: p.objectUrl,
+ siteUrl: SITE.base,
+ serverRelativeUrl: "",
+ shareUrl: SHARE_URL,
+ shareId: SHARE_ID,
+ },
+ fileChicletState: { serviceName: "p2p", state: "active" },
+ "@type": "http://schema.skype.com/File",
+ version: 2,
+ id: GUID,
+ baseUrl: SITE.base,
+ objectUrl: p.objectUrl,
+ openUrl: null,
+ type: p.ext,
+ title: p.fileName,
+ state: "active",
+ chicletBreadcrumbs: null,
+ providerData: "",
+ botFileProperties: {},
+ isUploadError: null,
+ progressComplete: null,
+ permissionScope: p.permissionScope,
+ filePreview: { previewUrl: "", previewHeight: 0, previewWidth: 0 },
+ sharepointIds: {
+ listId: LIST_ID,
+ listItemUniqueId: GUID,
+ siteId: SITE_ID,
+ siteUrl: null,
+ webId: null,
+ driveId: null,
+ },
+ publication: null,
+ site: null,
+ };
+}
+
+function fileInput(fileName: string, scope: "anonymous" | "organization") {
+ return {
+ fileName,
+ base: SITE.base,
+ listItemUniqueId: GUID,
+ listId: LIST_ID,
+ siteId: SITE_ID,
+ shareUrl: SHARE_URL,
+ shareId: SHARE_ID,
+ scope,
+ };
+}
+
+describe("buildFileObject (golden fixture)", () => {
+ it("matches the proven shape field-for-field, in order", () => {
+ const built = buildFileObject(fileInput("notes.txt", "anonymous"));
+ const golden = goldenFileObject({
+ fileName: "notes.txt",
+ ext: "txt",
+ objectUrl: `${SITE.base}Documents/Microsoft%20Teams%20Chat%20Files/notes.txt`,
+ permissionScope: "Anyone",
+ });
+ expect(built).toStrictEqual(golden);
+ // Key order too (the payload is serialized as-is).
+ expect(JSON.stringify(built)).toBe(JSON.stringify(golden));
+ });
+
+ it("maps link scope to permissionScope", () => {
+ expect(PERMISSION_SCOPE).toEqual({
+ anonymous: "Anyone",
+ organization: "Organization",
+ });
+ expect(
+ buildFileObject(fileInput("a.txt", "organization")).permissionScope,
+ ).toBe("Organization");
+ expect(
+ buildFileObject(fileInput("a.txt", "anonymous")).permissionScope,
+ ).toBe("Anyone");
+ });
+});
+
+describe("buildFileMessageBody", () => {
+ it("builds the proven message body with files as a JSON string", () => {
+ const fo = buildFileObject(fileInput("notes.txt", "anonymous"));
+ const body = buildFileMessageBody([fo]);
+ expect(body).toStrictEqual({
+ content: "",
+ messagetype: "RichText/Html",
+ contenttype: "text",
+ amsreferences: [],
+ clientmessageid: expect.stringMatching(/^\d{19}$/),
+ properties: {
+ mentions: "[]",
+ cards: "[]",
+ links: "[]",
+ files: JSON.stringify([fo]),
+ formatVariant: "TEAMS",
+ },
+ });
+ expect(typeof body.properties?.files).toBe("string");
+ });
+
+ it("escapes the caption into ", () => {
+ expect(captionHtml('a & b "x"')).toBe(
+ "",
+ );
+ expect(captionHtml(undefined)).toBe("");
+ expect(captionHtml("")).toBe("");
+ expect(buildFileMessageBody([], "").content).toBe(
+ "",
+ );
+ });
+});
+
+describe("names and encoding", () => {
+ it("takes the basename and rejects empty names", () => {
+ expect(uploadFileName("/tmp/dir/report.pdf")).toBe("report.pdf");
+ expect(uploadFileName("report.pdf")).toBe("report.pdf");
+ expect(() => uploadFileName("")).toThrow(/Invalid file name/);
+ expect(() => uploadFileName("/")).toThrow(/Invalid file name/);
+ expect(() => uploadFileName("..")).toThrow(/Invalid file name/);
+ });
+
+ it("lowercases the extension", () => {
+ expect(fileExtension("Report.PDF")).toBe("pdf");
+ expect(fileExtension("archive.tar.GZ")).toBe("gz");
+ expect(fileExtension("README")).toBe("");
+ expect(fileExtension(".env")).toBe("");
+ });
+
+ it("encodes Unicode / Arabic names per segment", () => {
+ const name = "تقرير #1 ?.pdf";
+ const enc = encodePathSegments("Microsoft Teams Chat Files", name);
+ expect(enc).toBe(
+ "Microsoft%20Teams%20Chat%20Files/" +
+ "%D8%AA%D9%82%D8%B1%D9%8A%D8%B1%20%231%20%3F.pdf",
+ );
+ expect(decodeURIComponent(enc.split("/")[1])).toBe(name);
+ expect(objectUrlFor(SITE.base, name)).toBe(`${SITE.base}Documents/${enc}`);
+ });
+
+ it("parses link scopes and aliases", () => {
+ expect(parseLinkScope("org")).toBe("organization");
+ expect(parseLinkScope("Organization")).toBe("organization");
+ expect(parseLinkScope("anon")).toBe("anonymous");
+ expect(parseLinkScope("anonymous")).toBe("anonymous");
+ expect(parseLinkScope("users")).toBeNull();
+ });
+});
+
+describe("deriveOneDriveSite", () => {
+ it("derives host from aud and slug from upn", () => {
+ const token = fakeJwt({
+ aud: "https://contoso-my.sharepoint.com",
+ upn: "A.B@contoso.com",
+ });
+ expect(deriveOneDriveSite(token)).toEqual(SITE);
+ });
+
+ it("falls back to unique_name and the capture host for a GUID aud", () => {
+ const token = fakeJwt({
+ aud: "00000003-0000-0ff1-ce00-000000000000",
+ unique_name: "a.b@contoso.com",
+ });
+ expect(deriveOneDriveSite(token, "contoso-my.sharepoint.com")).toEqual(
+ SITE,
+ );
+ });
+
+ it("rejects non-OneDrive audiences and missing upn", () => {
+ expect(() =>
+ deriveOneDriveSite(
+ fakeJwt({ aud: "https://contoso.sharepoint.com", upn: "a@b.com" }),
+ ),
+ ).toThrow(/not a OneDrive host/);
+ expect(() =>
+ deriveOneDriveSite(fakeJwt({ aud: "https://contoso-my.sharepoint.com" })),
+ ).toThrow(/no upn/);
+ expect(() => deriveOneDriveSite("garbage")).toThrow(/not a decodable JWT/);
+ });
+
+ it("slugs a UPN like SharePoint", () => {
+ expect(personalSiteSlug("a.b@tenant.com")).toBe("a_b_tenant_com");
+ });
+});
+
+// ── Staged flow with mocked network ──────────────────────────────────────
+
+const CHAT_BASE = "https://teams.cloud.microsoft/api/chatsvc/amer/v1/users/ME";
+
+function jsonResp(status: number, body: unknown) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+}
+
+function setupNetwork(opts: { failAt?: string; serverName?: string } = {}): {
+ spFetch: ReturnType;
+ chatFetch: ReturnType;
+} {
+ const fail = (stage: string) => opts.failAt === stage;
+ const spFetch = vi.fn(async (url: string, init?: RequestInit) => {
+ if (init?.method === "PUT") {
+ return fail("upload")
+ ? jsonResp(404, { error: "notFound" })
+ : jsonResp(201, { id: "ITEM1", name: opts.serverName ?? "notes.txt" });
+ }
+ if (url.includes("createLink")) {
+ return fail("link")
+ ? jsonResp(403, { error: "accessDenied" })
+ : jsonResp(201, { id: SHARE_ID, link: { webUrl: SHARE_URL } });
+ }
+ return fail("ids")
+ ? jsonResp(500, { error: "boom" })
+ : jsonResp(200, {
+ id: "ITEM1",
+ sharepointIds: {
+ listItemUniqueId: GUID,
+ siteId: SITE_ID,
+ listId: LIST_ID,
+ },
+ });
+ });
+ const chatFetch = vi.fn(async () =>
+ fail("message")
+ ? jsonResp(400, { message: "bad" })
+ : jsonResp(201, { OriginalArrivalTime: 1700000000000 }),
+ );
+ vi.stubGlobal("fetch", spFetch);
+ return { spFetch, chatFetch };
+}
+
+function run(chatFetch: ReturnType, fileName = "notes.txt") {
+ return sendFile({
+ chat: { baseUrl: CHAT_BASE, fetch: chatFetch },
+ conversationId: "19:abc@thread.v2",
+ site: SITE,
+ token: "SP-TOKEN",
+ fileName,
+ data: new Uint8Array([1, 2, 3]),
+ scope: "organization",
+ caption: "see ",
+ });
+}
+
+describe("sendFile", () => {
+ beforeEach(() => vi.unstubAllGlobals());
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("runs upload → ids → link → message with explicit SP headers", async () => {
+ const { spFetch, chatFetch } = setupNetwork();
+ const stages: string[] = [];
+ const result = await sendFile({
+ chat: { baseUrl: CHAT_BASE, fetch: chatFetch },
+ conversationId: "19:abc@thread.v2",
+ site: SITE,
+ token: "SP-TOKEN",
+ fileName: "notes.txt",
+ data: new Uint8Array([1, 2, 3]),
+ scope: "organization",
+ onStage: (r) => stages.push(r.stage),
+ });
+ expect(stages).toEqual(["upload", "ids", "link", "message"]);
+ expect(result.shareUrl).toBe(SHARE_URL);
+ expect(result.arrival).toBe("1700000000000");
+
+ const [putUrl, putInit] = spFetch.mock.calls[0];
+ expect(putUrl).toBe(
+ `${SITE.siteUrl}/_api/v2.1/drive/root:/Microsoft%20Teams%20Chat%20Files/notes.txt:/content?@name.conflictBehavior=rename`,
+ );
+ expect(putInit.method).toBe("PUT");
+ expect(putInit.headers).toMatchObject({
+ Authorization: "Bearer SP-TOKEN",
+ "Content-Type": "application/octet-stream",
+ });
+ expect(spFetch.mock.calls[1][0]).toBe(
+ `${SITE.siteUrl}/_api/v2.1/drive/items/ITEM1?select=id,sharepointIds`,
+ );
+ const [linkUrl, linkInit] = spFetch.mock.calls[2];
+ expect(linkUrl).toBe(
+ `${SITE.siteUrl}/_api/v2.1/drive/items/ITEM1/microsoft.graph.createLink`,
+ );
+ expect(JSON.parse(linkInit.body)).toEqual({
+ type: "view",
+ scope: "organization",
+ });
+
+ // Message goes through the chat client, never the SP fetch.
+ const [msgUrl, msgInit] = chatFetch.mock.calls[0];
+ expect(msgUrl).toBe(`${CHAT_BASE}/conversations/19:abc@thread.v2/messages`);
+ const files = JSON.parse(JSON.parse(msgInit.body).properties.files);
+ expect(files).toStrictEqual([
+ goldenFileObject({
+ fileName: "notes.txt",
+ ext: "txt",
+ objectUrl: objectUrlFor(SITE.base, "notes.txt"),
+ permissionScope: "Organization",
+ }),
+ ]);
+ });
+
+ it("uses the server-returned (renamed) name downstream", async () => {
+ const { spFetch, chatFetch } = setupNetwork({
+ serverName: "Report 1.PDF",
+ });
+ const result = await run(chatFetch, "Report.PDF");
+ expect(spFetch.mock.calls[0][0]).toContain("/Report.PDF:/content");
+ expect(result.fileName).toBe("Report 1.PDF");
+ const body = JSON.parse(chatFetch.mock.calls[0][1].body);
+ const [fo] = JSON.parse(body.properties.files);
+ expect(fo.fileName).toBe("Report 1.PDF");
+ expect(fo.title).toBe("Report 1.PDF");
+ expect(fo.fileType).toBe("pdf");
+ expect(fo.type).toBe("pdf");
+ expect(fo.objectUrl).toBe(
+ `${SITE.base}Documents/Microsoft%20Teams%20Chat%20Files/Report%201.PDF`,
+ );
+ expect(fo.fileInfo.fileUrl).toBe(fo.objectUrl);
+ expect(body.content).toBe("");
+ });
+
+ it("encodes a Unicode name in the upload URL", async () => {
+ const name = "تقرير.txt";
+ const { spFetch, chatFetch } = setupNetwork({ serverName: name });
+ await run(chatFetch, name);
+ expect(spFetch.mock.calls[0][0]).toContain(
+ `/Microsoft%20Teams%20Chat%20Files/${encodeURIComponent(name)}:/content`,
+ );
+ });
+
+ it.each([
+ ["upload", []],
+ ["ids", ["upload"]],
+ ["link", ["upload", "ids"]],
+ ["message", ["upload", "ids", "link"]],
+ ])("a failure at %s reports the stages that succeeded", async (failAt, done) => {
+ const { spFetch, chatFetch } = setupNetwork({ failAt });
+ const err = await run(chatFetch).catch((e) => e);
+ expect(err).toBeInstanceOf(StageError);
+ expect(err.stage).toBe(failAt);
+ expect(
+ (err as StageError).completed.map((r: { stage: string }) => r.stage),
+ ).toEqual(done);
+ // Nothing runs past the failed stage.
+ expect(chatFetch).toHaveBeenCalledTimes(failAt === "message" ? 1 : 0);
+ expect(spFetch).toHaveBeenCalledTimes(Math.min(done.length + 1, 3));
+ });
+
+ it("names the derived site when the first drive call fails", async () => {
+ const { chatFetch } = setupNetwork({ failAt: "upload" });
+ const err = await run(chatFetch).catch((e) => e);
+ expect(err.message).toContain("HTTP 404");
+ expect(err.message).toContain(SITE.siteUrl);
+ });
+});
+
+describe("buildSendFilePlan", () => {
+ it("uses placeholders for post-upload ids", () => {
+ const plan = buildSendFilePlan({
+ conversationId: "19:abc@thread.v2",
+ path: "./notes.txt",
+ fileName: "notes.txt",
+ size: 3,
+ scope: "anonymous",
+ site: SITE,
+ });
+ expect(plan.site).toEqual({ host: SITE.host, base: SITE.base });
+ expect(plan.permissionScope).toBe("Anyone");
+ const body = plan.body as { properties: { files: string } };
+ const [fo] = JSON.parse(body.properties.files);
+ expect(fo.id).toBe("");
+ expect(fo.fileInfo.shareUrl).toBe("");
+ expect(fo.sharepointIds.siteId).toBe("");
+ expect(fo.baseUrl).toBe(SITE.base);
+ });
+});