From 17335492c3f37fdb4144fa003c3fde202e6583b4 Mon Sep 17 00:00:00 2001 From: Khaldoon Sinjab Date: Thu, 15 Oct 2026 16:19:50 +0400 Subject: [PATCH 1/2] feat(cli): send --html/--html-file/--dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTML mode posts caller-owned HTML verbatim (RichText/Html, no

wrap); '-' reads stdin; --dry-run prints the exact body without sending. Plain mode byte-for-byte unchanged. Send path split into buildMessageBody (pure) + postMessage (transport) — the foundation for the attachments PR. Implemented via claude relay lane (dispatch 62DFF778), reviewed + live-tested by the calling session (receipt 1792066772884). --- CLAUDE.md | 2 +- README.md | 8 ++ docs/ARCHITECTURE.md | 2 +- skills/teams-cli/SKILL.md | 7 ++ src/api.ts | 42 +++++++--- src/cli.ts | 97 +++++++++++++++++++--- tests/api.test.ts | 49 ++++++++++++ tests/send.test.ts | 164 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 349 insertions(+), 22 deletions(-) create mode 100644 tests/send.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a20383f..067759a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ 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 - `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..45bf156 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ 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 '

line 1
line 2

' +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 + # Find a person by name or email teams find "Jane Smith" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ee3b4e..4c07785 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -71,7 +71,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) + `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 diff --git a/skills/teams-cli/SKILL.md b/skills/teams-cli/SKILL.md index 8dd4d51..9778a5f 100644 --- a/skills/teams-cli/SKILL.md +++ b/skills/teams-cli/SKILL.md @@ -49,6 +49,13 @@ 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 '

line 1
line 2

' +teams send --html-file msg.html # or: ... | teams send --html - +teams send --html-file msg.html --dry-run # print body, send nothing +``` + ### Search messages ```bash teams search "quarterly report" # Search across all chats diff --git a/src/api.ts b/src/api.ts index 7bd5193..6f847d9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -61,28 +61,52 @@ export async function getMessages( return (data.messages as Record[]) ?? []; } -export async function sendMessage( +export interface MessageBody { + content: string; + messagetype: string; + contenttype: string; + clientmessageid: string; +} + +// Plain mode wraps the text in a single

; html mode posts the caller's +// HTML verbatim (caller owns structure and escaping). +export function buildMessageBody( + content: string, + opts: { html?: boolean } = {}, +): MessageBody { + return { + content: opts.html ? content : `

${content}

`, + messagetype: "RichText/Html", + contenttype: "text", + clientmessageid: randomClientMessageId(), + }; +} + +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/cli.ts b/src/cli.ts index 9a8750a..199a4dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import Table from "cli-table3"; import { Command } from "commander"; import { + buildMessageBody, createDmThread, downloadTranscriptJson, getActivity, @@ -14,6 +15,7 @@ import { listConversations, pollConversations, pollMessages, + postMessage, resolveDriveItem, searchMessages, searchPeople, @@ -63,6 +65,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,17 +214,46 @@ 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"}`, + ); + }, + ); // search program 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 = "
a <b> &\n  c
"; + 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 = "

line1
line2

  1. x
"; + 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.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 () => "

x
y

\n" }; + expect(await resolveSendContent(undefined, { html: "-" }, io)).toEqual({ + content: "

x
y

\n", + html: true, + }); + }); + + it("reads html from --html-file", async () => { + const io = { ...noIo, readFile: (p: string) => `
  1. ${p}
` }; + expect(await resolveSendContent(undefined, { htmlFile: "f" }, io)).toEqual({ + content: "
  1. 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 = "

line1
line2

"; + 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", "
  1. 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("
  1. 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}$/), + }); + }); +}); From 685e7ea90fc65638f6fd834ac3ae65e702ec42f2 Mon Sep 17 00:00:00 2001 From: Khaldoon Sinjab Date: Thu, 15 Oct 2026 16:31:21 +0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(cli):=20send-file=20=E2=80=94=20upload?= =?UTF-8?q?=20to=20OneDrive,=20share,=20post=20file=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native SP-token ladder (env TEAMS_SP_TOKEN -> cached -> profile localStorage -> /_api/ interception), dynamic tenant/site derivation from token claims, conflictBehavior=rename with server-name use, organization-scoped links by default (--link-scope anon for the field-proven Anyone shape), staged receipts with partial-failure reporting, --dry-run, golden-fixture tests (187 total). Implemented via claude relay lane (dispatch 5553A777), reviewed + live-tested by the calling session: native ladder e2e on test thread, both scopes, properties.files round-trip verified (arrivals 1792067458237, 1792067475606). --- CLAUDE.md | 7 + README.md | 6 + docs/ARCHITECTURE.md | 18 +- skills/teams-cli/SKILL.md | 7 + src/api.ts | 14 +- src/auth.ts | 147 +++++++++++- src/cli.ts | 166 +++++++++++++- src/upload.ts | 464 ++++++++++++++++++++++++++++++++++++++ tests/send-file.test.ts | 284 +++++++++++++++++++++++ tests/upload.test.ts | 424 ++++++++++++++++++++++++++++++++++ 10 files changed, 1528 insertions(+), 9 deletions(-) create mode 100644 src/upload.ts create mode 100644 tests/send-file.test.ts create mode 100644 tests/upload.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 067759a..511573c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,13 @@ 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. 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 45bf156..0468d0f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ 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 4c07785..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. It is `buildMessageBody()` (pure; `{ html: true }` skips the `

` wrap and posts the caller's HTML verbatim) + `postMessage()` (the POST). `send --dry-run` builds the body and prints it without calling `postMessage()` +- `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 9778a5f..c31a729 100644 --- a/skills/teams-cli/SKILL.md +++ b/skills/teams-cli/SKILL.md @@ -56,6 +56,13 @@ teams send --html-file msg.html # or: ... | teams send - 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 6f847d9..01d3ab6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -65,20 +65,30 @@ 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). +// 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 } = {}, + 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 }), }; } 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 199a4dd..e3e51f1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,10 @@ -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"; @@ -21,7 +27,14 @@ import { 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, @@ -42,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 }); @@ -255,7 +280,142 @@ export function createProgram(): Command { }, ); - // search + // 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); + } + }, + ); + 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 ? `

${escapeHtml(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/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("

a & b

"); + 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/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( + "

a & b <i>"x"</i>

", + ); + expect(captionHtml(undefined)).toBe(""); + expect(captionHtml("")).toBe(""); + expect(buildFileMessageBody([], "").content).toBe( + "

<b>

", + ); + }); +}); + +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("

see <this>

"); + }); + + 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); + }); +});