From 17335492c3f37fdb4144fa003c3fde202e6583b4 Mon Sep 17 00:00:00 2001 From: Khaldoon Sinjab Date: Thu, 15 Oct 2026 16:19:50 +0400 Subject: [PATCH] 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}$/), + }); + }); +});