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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>` tags and generates a random 19-digit `clientmessageid` using BigInt
- `sendMessage()` wraps content in `<p>` 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 `<p>` 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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<div><p>line 1<br>line 2</p></div>'
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"

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>` tags and generates a random 19-digit `clientmessageid` using BigInt arithmetic, matching the Teams web client's behaviour
- `sendMessage()` wraps content in `<p>` 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 `<p>` 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
Expand Down
7 changes: 7 additions & 0 deletions skills/teams-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ teams send <chat_id> "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 `<pre>` yourself:
```bash
teams send <chat_id> --html '<div><p>line 1<br>line 2</p></div>'
teams send <chat_id> --html-file msg.html # or: ... | teams send <chat_id> --html -
teams send <chat_id> --html-file msg.html --dry-run # print body, send nothing
```

### Search messages
```bash
teams search "quarterly report" # Search across all chats
Expand Down
42 changes: 33 additions & 9 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,28 +61,52 @@ export async function getMessages(
return (data.messages as Record<string, unknown>[]) ?? [];
}

export async function sendMessage(
export interface MessageBody {
content: string;
messagetype: string;
contenttype: string;
clientmessageid: string;
}

// Plain mode wraps the text in a single <p>; 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 : `<p>${content}</p>`,
messagetype: "RichText/Html",
contenttype: "text",
clientmessageid: randomClientMessageId(),
};
}

export async function postMessage(
client: ApiClient,
conversationId: string,
content: string,
body: MessageBody,
): Promise<Record<string, unknown>> {
const clientMsgId = randomClientMessageId();
const resp = await client.fetch(
`${client.baseUrl}/conversations/${conversationId}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: `<p>${content}</p>`,
messagetype: "RichText/Html",
contenttype: "text",
clientmessageid: clientMsgId,
}),
body: JSON.stringify(body),
},
);
return (await jsonOrThrow(resp)) as Record<string, unknown>;
}

export async function sendMessage(
client: ApiClient,
conversationId: string,
content: string,
opts: { html?: boolean } = {},
): Promise<Record<string, unknown>> {
return postMessage(client, conversationId, buildMessageBody(content, opts));
}

export async function getThreadMembers(
client: ApiClient,
conversationId: string,
Expand Down
97 changes: 86 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import chalk from "chalk";
import Table from "cli-table3";
import { Command } from "commander";
import {
buildMessageBody,
createDmThread,
downloadTranscriptJson,
getActivity,
Expand All @@ -14,6 +15,7 @@ import {
listConversations,
pollConversations,
pollMessages,
postMessage,
resolveDriveItem,
searchMessages,
searchPeople,
Expand Down Expand Up @@ -63,6 +65,50 @@ function resolveConversationId(idOrShort: string): string {
process.exit(1);
}

export interface SendOptions {
html?: string;
htmlFile?: string;
}

async function readStdin(): Promise<string> {
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<string>;
} = { readFile: (p) => readFileSync(p, "utf-8"), readStdin },
): Promise<{ content: string; html: boolean } | { error: string }> {
const sources = [
message !== undefined && "<message>",
opts.html !== undefined && "--html",
opts.htmlFile !== undefined && "--html-file",
].filter(Boolean);
if (sources.length > 1) {
return {
error: `Use only one of <message>, --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 <message>, --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)}`);
Expand Down Expand Up @@ -168,17 +214,46 @@ export function createProgram(): Command {

// send
program
.command("send <chat> <message>")
.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 <chat> [message]")
.description(
"Send a message to a conversation. --html/--html-file post raw HTML " +
"as-is (no escaping): for code use <div><pre>…</pre></div> with <>& escaped.",
)
.option("--html <str>", "Send this HTML verbatim ('-' reads from stdin)")
.option("--html-file <path>", "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
Expand Down
49 changes: 49 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
buildMessageBody,
createDmThread,
downloadTranscriptJson,
getActivity,
Expand Down Expand Up @@ -76,6 +77,54 @@ describe("sendMessage", () => {
const body = JSON.parse(init.body);
expect(body.content).toBe('<p>He said "hello" & {test}</p>');
});

it("keeps the plain body shape unchanged", async () => {
const client = mockClient({});
await sendMessage(client, "19:abc", "line1\nline2");
const fetchFn = client.fetch as ReturnType<typeof vi.fn>;
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("<p>line1\nline2</p>");
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 = "<div><pre>a &lt;b&gt; &amp;\n c</pre></div>";
const result = await sendMessage(client, "19:abc", html, { html: true });
expect(result.OriginalArrivalTime).toBe("789");
const fetchFn = client.fetch as ReturnType<typeof vi.fn>;
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 = "<div><p>line1<br>line2</p><ol><li>x</li></ol></div>";
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", () => {
Expand Down
Loading