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 '
'
+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 ''
+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 = "";
+ const result = await sendMessage(client, "19:abc", html, { html: true });
+ expect(result.OriginalArrivalTime).toBe("789");
+ const fetchFn = client.fetch as ReturnType;
+ const [url, init] = fetchFn.mock.calls[0];
+ expect(url).toBe(`${BASE}/conversations/19:abc/messages`);
+ const body = JSON.parse(init.body);
+ expect(body.content).toBe(html);
+ expect(body.messagetype).toBe("RichText/Html");
+ expect(body.contenttype).toBe("text");
+ expect(body.clientmessageid).toMatch(/^\d{19}$/);
+ });
+});
+
+describe("buildMessageBody", () => {
+ it("does not wrap or transform html", () => {
+ const html = "";
+ expect(buildMessageBody(html, { html: true }).content).toBe(html);
+ });
+
+ it("generates a fresh clientmessageid each call", () => {
+ const a = buildMessageBody("x").clientmessageid;
+ const b = buildMessageBody("x").clientmessageid;
+ expect(a).toMatch(/^\d{19}$/);
+ expect(a).not.toBe(b);
+ });
});
describe("getThreadMembers", () => {
diff --git a/tests/send.test.ts b/tests/send.test.ts
new file mode 100644
index 0000000..f02bb17
--- /dev/null
+++ b/tests/send.test.ts
@@ -0,0 +1,164 @@
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const BASE = "https://teams.cloud.microsoft/api/chatsvc/amer/v1/users/ME";
+const fetchMock = vi.fn();
+const getChatClient = vi.fn(async () => ({ baseUrl: BASE, fetch: fetchMock }));
+
+vi.mock("../src/client.js", () => ({
+ getChatClient: () => getChatClient(),
+ getSearchClient: vi.fn(),
+ getStreamClient: vi.fn(),
+}));
+
+const { createProgram, resolveSendContent } = await import("../src/cli.js");
+
+const noIo = {
+ readFile: () => {
+ throw new Error("readFile should not be called");
+ },
+ readStdin: () => Promise.reject(new Error("readStdin should not be called")),
+};
+
+describe("resolveSendContent", () => {
+ it("returns plain text for a positional message", async () => {
+ expect(await resolveSendContent("hi", {}, noIo)).toEqual({
+ content: "hi",
+ html: false,
+ });
+ });
+
+ it("returns --html verbatim", async () => {
+ expect(
+ await resolveSendContent(undefined, { html: "a
" }, noIo),
+ ).toEqual({ content: "a
", html: true });
+ });
+
+ it("reads html from stdin when --html is '-'", async () => {
+ const io = { ...noIo, readStdin: async () => "\n" };
+ expect(await resolveSendContent(undefined, { html: "-" }, io)).toEqual({
+ content: "\n",
+ html: true,
+ });
+ });
+
+ it("reads html from --html-file", async () => {
+ const io = { ...noIo, readFile: (p: string) => `- ${p}
` };
+ expect(await resolveSendContent(undefined, { htmlFile: "f" }, io)).toEqual({
+ content: "- f
",
+ html: true,
+ });
+ });
+
+ it("errors when both a positional and an html flag are given", async () => {
+ const r = await resolveSendContent("hi", { html: "a
" }, noIo);
+ expect(r).toHaveProperty("error");
+ expect((r as { error: string }).error).toMatch(/only one of/);
+ });
+
+ it("errors when --html and --html-file are both given", async () => {
+ const r = await resolveSendContent(
+ undefined,
+ { html: "a
", htmlFile: "f" },
+ noIo,
+ );
+ expect(r).toHaveProperty("error");
+ });
+
+ it("errors when nothing is given", async () => {
+ expect(await resolveSendContent(undefined, {}, noIo)).toHaveProperty(
+ "error",
+ );
+ });
+});
+
+describe("send command", () => {
+ let out: string[];
+ let err: string[];
+
+ beforeEach(() => {
+ out = [];
+ err = [];
+ fetchMock.mockReset();
+ getChatClient.mockClear();
+ vi.spyOn(console, "log").mockImplementation((s) => out.push(String(s)));
+ vi.spyOn(console, "error").mockImplementation((s) => err.push(String(s)));
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`exit ${code}`);
+ }) as never);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ async function run(...args: string[]) {
+ const program = createProgram();
+ program.exitOverride();
+ await program.parseAsync(["node", "teams", "send", ...args]);
+ }
+
+ it("--dry-run prints the html body and makes no client call", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "teams-send-"));
+ const file = join(dir, "msg.html");
+ const html = "";
+ writeFileSync(file, html);
+
+ await run("19:abc@thread.v2", "--html-file", file, "--dry-run");
+
+ expect(getChatClient).not.toHaveBeenCalled();
+ expect(fetchMock).not.toHaveBeenCalled();
+ const printed = JSON.parse(out.join("\n"));
+ expect(printed.conversationId).toBe("19:abc@thread.v2");
+ expect(printed.body.content).toBe(html);
+ expect(printed.body.messagetype).toBe("RichText/Html");
+ expect(printed.body.contenttype).toBe("text");
+ expect(printed.body.clientmessageid).toMatch(/^\d{19}$/);
+ });
+
+ it("--dry-run works in plain mode", async () => {
+ await run("19:abc@thread.v2", "hello", "--dry-run");
+ expect(getChatClient).not.toHaveBeenCalled();
+ const printed = JSON.parse(out.join("\n"));
+ expect(printed.body.content).toBe("hello
");
+ });
+
+ it("errors on positional message plus --html, sending nothing", async () => {
+ await expect(
+ run("19:abc@thread.v2", "hello", "--html", "x
"),
+ ).rejects.toThrow("exit 1");
+ expect(err.join("\n")).toMatch(/only one of/);
+ expect(getChatClient).not.toHaveBeenCalled();
+ });
+
+ it("posts --html verbatim and prints the arrival receipt", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ OriginalArrivalTime: 1700000000000 }),
+ });
+ await run("19:abc@thread.v2", "--html", "- a
");
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(`${BASE}/conversations/19:abc@thread.v2/messages`);
+ const body = JSON.parse(init.body);
+ expect(body.content).toBe("- a
");
+ expect(body.messagetype).toBe("RichText/Html");
+ expect(out.join("\n")).toContain("Arrival: 1700000000000");
+ });
+
+ it("plain send posts the unchanged -wrapped body", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ OriginalArrivalTime: 1 }),
+ });
+ await run("19:abc@thread.v2", "hello");
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(body).toEqual({
+ content: "
hello
",
+ messagetype: "RichText/Html",
+ contenttype: "text",
+ clientmessageid: expect.stringMatching(/^\d{19}$/),
+ });
+ });
+});