From fe3abeda29dc8db7bb12e08e3cba8dcc6de6c0d0 Mon Sep 17 00:00:00 2001 From: Khaldoon Sinjab Date: Sun, 11 Oct 2026 15:04:35 +0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20react=20command=20=E2=80=94=20add/?= =?UTF-8?q?remove=20message=20reactions=20via=20the=20web=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chatsvc REST API rejects reaction writes (the emotions property setter returns 400 for every body shape) and the native protocol rides a WebSocket inside a SharedWorker, so reactions are driven through the logged-in Playwright browser profile (same one uses): - opens the chat natively from the chat list (accessibility-name match, candidate loop — the same 1:1 can render as several rows) - locates the message row by snippet text (group row or DOM rect; the role=heading match can be a 1x1 accessibility marker) - hovers the row's top edge (the quick bar does not trigger from the row center) with an offset sweep + retries - clicks the reaction on the hover quick bar: like, heart, laugh, surprised. The expanded 'More reactions' picker is deliberately not wired: its flyout can anchor to an adjacent row and react to the wrong message. - verifies server-side by re-reading the message emotions property; re-reacting with an emoji you already set is refused (the UI toggle would remove it) Reacted 2 live cycles end-to-end (like add/remove, heart add/remove) with server-side verification. --- README.md | 9 ++ package-lock.json | 37 ++---- package.json | 2 +- src/cli.ts | 110 +++++++++++++++++ src/react.ts | 291 ++++++++++++++++++++++++++++++++++++++++++++ tests/react.test.ts | 132 ++++++++++++++++++++ 6 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 src/react.ts create mode 100644 tests/react.test.ts diff --git a/README.md b/README.md index a5a5a7d..4d7cc24 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ teams messages a1b2 # Send a message teams send a1b2 "Hello from the CLI" +# React to the newest message (like by default) +teams react a1b2 +teams react a1b2 heart + +# React to a specific message, or remove your reaction +teams react a1b2 like --msg 1791700000000 +teams react a1b2 like --msg 1791700000000 --remove + # Find a person by name or email teams find "Jane Smith" @@ -144,6 +152,7 @@ npm run dev -- chats 3. CLI commands use these tokens to call the Teams Chat Service API (`teams.cloud.microsoft/api/chatsvc/`) and the Substrate Search API (`substrate.office.com`) directly via `fetch` 4. The `teams chats` command caches the conversation list locally so you can reference chats by short ID in subsequent commands 5. The `teams dm` command searches for a user via the Substrate Suggestions API, creates a 1:1 thread via `POST /threads`, and sends the message — all in one step +6. `teams react` drives the logged-in browser profile (headless by default) through the Teams web UI: it opens the chat, hovers the message row, and clicks the reaction on the hover quick bar (`like`, `heart`, `laugh`, `surprised`). The chatsvc REST API rejects reaction writes and the native protocol rides a WebSocket inside a SharedWorker, so the web UI is the only scriptable path. Every reaction is verified server-side by re-reading the message's `emotions` property; reacting again with the same emoji is refused (the UI would toggle it off) ## Project structure diff --git a/package-lock.json b/package-lock.json index ab80f41..653d269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^14.0.3", - "playwright": "^1.59.1" + "playwright": "^1.63.0" }, "bin": { "teams": "dist/index.js", @@ -7116,47 +7116,30 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/postcss": { diff --git a/package.json b/package.json index 299f614..a4316dd 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^14.0.3", - "playwright": "^1.59.1" + "playwright": "^1.63.0" }, "devDependencies": { "@biomejs/biome": "^2.4.11", diff --git a/src/cli.ts b/src/cli.ts index 9a8750a..83b527f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,6 +40,14 @@ import { stripHtml, transcriptFilename, } from "./formatting.js"; +import { + driveReaction, + hasMyReaction, + isReactionName, + pickTargetMessage, + REACTIONS, + searchSnippet, +} from "./react.js"; function saveChatIndex(chats: FormattedChat[]): void { mkdirSync(DATA_DIR, { recursive: true }); @@ -180,6 +188,108 @@ export function createProgram(): Command { ); }); + // react + program + .command("react [emoji]") + .description( + "React to a message (default: like on the newest message). " + + "Emoji: like, heart, laugh, surprised.", + ) + .option( + "-m, --msg ", + "React to a specific message id (from `messages`) instead of the newest", + ) + .option("-r, --remove", "Toggle the reaction OFF instead of ON") + .option("--headed", "Show the browser window (debugging)") + .option("-t, --timeout ", "Per-step timeout in milliseconds", "60000") + .action(async (chat: string, emoji: string | undefined, opts) => { + const name = (emoji ?? "like").toLowerCase(); + if (!isReactionName(name)) { + console.error( + `Unknown reaction '${name}'. Use: ${Object.keys(REACTIONS).join(", ")}.`, + ); + process.exit(1); + } + + const myMri = getMyMri(); + if (!myMri) { + console.error("Could not determine your user ID. Run `teams login`."); + process.exit(1); + } + + const convId = resolveConversationId(chat); + const client = await getChatClient(); + const messages = await getMessages(client, convId, 30); + const target = pickTargetMessage(messages, opts.msg); + if (!target) { + console.error( + opts.msg + ? `Message '${opts.msg}' not found in this conversation.` + : "No reactable message found in this conversation.", + ); + process.exit(1); + } + const messageId = target.id as string; + const snippet = searchSnippet(target); + + // The UI chat list shows the counterpart's name ("Chat with X" in the + // CLI index becomes "X"). + const chatName = ( + loadChatIndex().find((c) => c.id === convId)?.name ?? "" + ).replace(/^Chat with /, ""); + if (!chatName) { + console.error("Chat display name unknown. Run `teams chats` first."); + process.exit(1); + } + + // Clicking a reaction you already set toggles it OFF - refuse no-ops. + const already = hasMyReaction(target, name, myMri); + if (already && !opts.remove) { + console.log( + `Already reacted with '${name}' on message ${messageId}. Nothing to do.`, + ); + return; + } + if (!already && opts.remove) { + console.log( + `No '${name}' reaction of yours on message ${messageId}. Nothing to do.`, + ); + return; + } + + console.log( + `${opts.remove ? "Removing" : "Adding"} '${name}' on "${snippet.slice(0, 40)}" (message ${messageId})...`, + ); + await driveReaction({ + name, + snippet, + chatName, + headed: opts.headed, + timeoutMs: Number(opts.timeout), + }); + + // Verify server-side: the click is UI-driven, so confirm by state. + let verified = false; + for (let attempt = 0; attempt < 15 && !verified; attempt++) { + await new Promise((r) => setTimeout(r, 1000)); + const recheck = await getMessages(client, convId, 30); + const msg = recheck.find((m) => m.id === messageId); + verified = msg + ? hasMyReaction(msg, name, myMri) === !opts.remove + : false; + } + if (!verified) { + console.error( + "Reaction state could not be verified - re-run with --headed to watch the drive.", + ); + process.exit(1); + } + console.log( + chalk.green(opts.remove ? "Reaction removed." : "Reacted.") + + ` ${name} on message ${messageId} (verified).`, + ); + }); + // search program .command("search ") diff --git a/src/react.ts b/src/react.ts new file mode 100644 index 0000000..1aefa5d --- /dev/null +++ b/src/react.ts @@ -0,0 +1,291 @@ +import type { Page } from "playwright"; +import { BROWSER_PROFILE_DIR, TEAMS_URL } from "./config.js"; +import { stripHtml } from "./formatting.js"; + +/** + * Reaction names accepted on the CLI, mapped to the hover quick-bar buttons. + * The expanded "More reactions" picker is deliberately NOT wired: its flyout + * can anchor to an adjacent message row, which risks reacting to the wrong + * message. + */ +export const REACTIONS = { + like: "like", + heart: "heart", + laugh: "laugh", + surprised: "surprised", +} as const; + +export type ReactionName = keyof typeof REACTIONS; + +export function isReactionName(value: string): value is ReactionName { + return Object.hasOwn(REACTIONS, value); +} + +/** Selectors for the hover quick bar. */ +export function reactionSelectors(name: ReactionName): { + quick: string; +} { + const key = REACTIONS[name]; + return { + quick: `[data-tid="message-actions-${key}"]`, + }; +} + +/** + * Pick the message to react to from a newest-first message list: the one with + * the given id, or the newest renderable message. Skips non-message rows + * (read receipts, reaction events, topic updates) which have no `content`. + */ +export function pickTargetMessage( + messages: Record[], + messageId?: string, +): Record | null { + if (messageId) { + return ( + messages.find( + (m) => m.id === messageId && typeof m.content === "string", + ) ?? null + ); + } + for (const m of messages) { + if (m.type !== "Message") continue; + if (typeof m.content !== "string" || m.content.length === 0) continue; + return m; + } + return null; +} + +/** A short distinctive plain-text snippet of the message, for search + row location. */ +export function searchSnippet( + message: Record, + maxLen = 48, +): string { + const text = stripHtml((message.content as string) ?? "") + // Teams content carries HTML entities the DOM renders decoded; match those. + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim(); + if (!text) throw new Error("Target message has no searchable text content."); + return text.length > maxLen ? text.slice(0, maxLen) : text; +} + +export interface EmotionUser { + mri?: string; +} + +export interface Emotion { + key?: string; + users?: EmotionUser[]; +} + +/** Read the emotions property off a message, tolerating stringified JSON. */ +export function getEmotions(message: Record): Emotion[] { + const props = (message.properties as Record) ?? {}; + const raw = props.emotions; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as Emotion[]) : []; + } catch { + return []; + } + } + return Array.isArray(raw) ? (raw as Emotion[]) : []; +} + +/** True when `myMri` currently has the given reaction on the message. */ +export function hasMyReaction( + message: Record, + name: ReactionName, + myMri: string, +): boolean { + const key = REACTIONS[name]; + return getEmotions(message).some( + (e) => e.key === key && (e.users ?? []).some((u) => u.mri === myMri), + ); +} + +/** Escape a literal for embedding into a RegExp. */ +export function escapeRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/* v8 ignore start -- requires a live Playwright browser */ + +async function waitForApp(page: Page, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await page.locator('[role="combobox"]').count()) > 0) return; + await page.waitForTimeout(1000); + } + throw new Error( + "Teams app did not finish loading. Try `teams react --headed` to watch it.", + ); +} + +/** + * Drive the Teams web client to add (or toggle off) a reaction on a message. + * + * The chatsvc REST API rejects reaction writes outright, and the native + * protocol rides a WebSocket inside a SharedWorker, so the only scriptable + * path is the web UI itself: open the chat from the chat list (by its + * display name), locate the message row by its text, hover it, and click + * the reaction button on the hover quick bar (falling back to the expanded + * picker). + */ +export async function driveReaction(opts: { + name: ReactionName; + snippet: string; + chatName: string; + headed?: boolean; + timeoutMs: number; +}): Promise { + const playwright = await import("playwright"); + const context = await playwright.chromium.launchPersistentContext( + BROWSER_PROFILE_DIR, + { headless: !opts.headed }, + ); + try { + const page = await context.newPage(); + await page.goto(TEAMS_URL, { waitUntil: "domcontentloaded" }); + await waitForApp(page, opts.timeoutMs); + + // Open the chat natively from the chat list. Match the row's accessible + // NAME (the chat title), never subtree text - previews inside other rows + // can mention the same person and open the wrong chat. (Opening a message + // through the search page renders the thread in an embedded pane that can + // fail with an "Oops" error - the native chat view is the reliable path.) + const rowPattern = new RegExp(escapeRegExp(opts.snippet.slice(0, 20))); + const namePattern = new RegExp( + `^(?:Unread message )?(?:Chat )?${escapeRegExp(opts.chatName)}`, + ); + const candidates = page + .getByRole("treeitem", { name: namePattern }) + .or( + page + .locator('[role="treeitem"], [data-tid="chat-list-item"]') + .filter({ hasText: opts.chatName }), + ); + // The chat list renders after app boot; give it a beat before counting. + await candidates + .first() + .waitFor({ state: "visible", timeout: 15_000 }) + .then( + () => true, + () => false, + ); + const total = await candidates.count(); + if (total === 0) { + throw new Error( + `Chat '${opts.chatName}' not visible in the chat list. ` + + "Open it once in the app so it becomes recent, then retry.", + ); + } + // The same 1:1 can appear as several rows; try each until the target + // message heading shows up. + const chatRow = page.getByRole("heading", { name: rowPattern }).last(); + let found = false; + for (let i = 0; i < total; i++) { + await candidates.nth(i).click({ timeout: 10_000 }); + found = await chatRow.waitFor({ state: "visible", timeout: 12_000 }).then( + () => true, + () => false, + ); + if (found) break; + } + if (!found) { + throw new Error( + `Message "${opts.snippet.slice(0, 24)}..." not found in the opened ` + + `chat (tried ${total} chat-list row(s)). The message may be older ` + + "than the rendered window.", + ); + } + + // Hover the message row to reveal the reaction quick bar. The role=heading + // match can be a 1x1 accessibility marker - hover the visible GROUP row + // (or its DOM rect) instead; then a raw mouse move to its center. The + // hover can silently miss (render timing), so retry re-resolving the row. + const { quick } = reactionSelectors(opts.name); + // Bar-up signal: ANY hover action button - the quick reaction buttons + // and the "More reactions" entry render either/or depending on space. + const barUp = page + .locator( + `${quick}, [data-tid^="message-actions-"], ` + + '[data-tid="add-reaction-picker-entry-point-button"]', + ) + .first(); + const groupRow = page.getByRole("group", { name: rowPattern }).last(); + const prefix = opts.snippet.slice(0, 20); + let hovered = false; + for (let attempt = 0; attempt < 3 && !hovered; attempt++) { + const box = + (await groupRow.boundingBox().catch(() => null)) ?? + (await domRowBox(page, prefix)); + if (box && box.width > 5) { + await chatRow.scrollIntoViewIfNeeded().catch(() => {}); + // The reaction bar hangs on the row's TOP edge: hovering the center + // often does not trigger it. Sweep a few offsets until the bar shows. + const cx = box.x + box.width / 2; + const ys = [box.y - 4, box.y + 4, box.y + box.height / 2, box.y - 12]; + for (const y of ys) { + await page.mouse.move(cx, Math.max(5, y)); + hovered = await barUp + .waitFor({ state: "visible", timeout: 1500 }) + .then( + () => true, + () => false, + ); + if (hovered) break; + } + } else { + hovered = await barUp.waitFor({ state: "visible", timeout: 5000 }).then( + () => true, + () => false, + ); + } + } + if (!hovered) { + throw new Error( + "The reaction hover bar did not appear on the message row. " + + "Retry, or run with --headed to watch the drive.", + ); + } + await page.locator(quick).first().click({ timeout: 5000 }); + } finally { + await context.close().catch(() => {}); + } +} + +// Find the on-screen rect of the message row containing `prefix`: walk up +// from the deepest visible text node carrying the snippet to its row +// container. Returns null when the text is not rendered. +async function domRowBox( + page: Page, + prefix: string, +): Promise<{ x: number; y: number; width: number; height: number } | null> { + return page.evaluate((p) => { + const leaves = [...document.querySelectorAll("div,span,p")].filter( + (el) => + el.children.length === 0 && + (el.textContent ?? "").includes(p) && + el.getBoundingClientRect().width > 20, + ); + if (leaves.length === 0) return null; + const leaf = leaves[leaves.length - 1]; + let node = leaf; + for (let i = 0; i < 8 && node.parentElement; i++) { + const r = node.getBoundingClientRect(); + if (r.height > 30 && r.width > 200) break; + node = node.parentElement; + } + const r = node.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }, prefix); +} + +/* v8 ignore stop */ diff --git a/tests/react.test.ts b/tests/react.test.ts new file mode 100644 index 0000000..c396c5c --- /dev/null +++ b/tests/react.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + getEmotions, + hasMyReaction, + isReactionName, + pickTargetMessage, + REACTIONS, + reactionSelectors, + searchSnippet, +} from "../src/react.js"; + +const ME = "8:orgid:11111111-1111-1111-1111-111111111111"; + +function msg(overrides: Record = {}): Record { + return { + type: "Message", + id: "100", + content: "

hello world

", + ...overrides, + }; +} + +describe("reaction names", () => { + it("accepts the quick-bar reactions", () => { + for (const name of ["like", "heart", "laugh", "surprised"]) { + expect(isReactionName(name)).toBe(true); + } + }); + + it("rejects unknown names", () => { + expect(isReactionName("party")).toBe(false); + expect(isReactionName("sad")).toBe(false); + expect(isReactionName("Like")).toBe(false); + expect(isReactionName("")).toBe(false); + }); + + it("maps selectors to data-tid anchors", () => { + expect(reactionSelectors("like").quick).toBe( + '[data-tid="message-actions-like"]', + ); + expect(Object.keys(REACTIONS)).toHaveLength(4); + }); +}); + +describe("pickTargetMessage", () => { + const list = [ + msg({ id: "3", content: "

newest

" }), + { type: "Message", id: "2", content: "" }, // event row, no content + { type: "Reaction", id: "r1", content: "

x

" }, + msg({ id: "1", content: "

oldest

" }), + ]; + + it("picks the newest message with content by default", () => { + expect(pickTargetMessage(list)?.id).toBe("3"); + }); + + it("skips reaction/event rows when scanning", () => { + const only = [{ type: "Reaction", id: "r1", content: "

x

" }]; + expect(pickTargetMessage(only)).toBeNull(); + }); + + it("finds by explicit id", () => { + expect(pickTargetMessage(list, "1")?.id).toBe("1"); + }); + + it("returns null for an unknown id", () => { + expect(pickTargetMessage(list, "999")).toBeNull(); + }); +}); + +describe("searchSnippet", () => { + it("strips html and collapses whitespace", () => { + expect(searchSnippet(msg({ content: "

a  b

" }))).toBe( + "a b", + ); + }); + + it("truncates to the max length", () => { + const long = "x".repeat(100); + expect(searchSnippet(msg({ content: `

${long}

` }))).toHaveLength(48); + }); + + it("throws on empty content", () => { + expect(() => searchSnippet(msg({ content: "

" }))).toThrow(); + }); +}); + +describe("emotions state", () => { + it("reads a plain array property", () => { + const m = msg({ + properties: { + emotions: [{ key: "like", users: [{ mri: ME }] }], + }, + }); + expect(hasMyReaction(m, "like", ME)).toBe(true); + expect(hasMyReaction(m, "heart", ME)).toBe(false); + }); + + it("reads a stringified emotions property", () => { + const m = msg({ + properties: { + emotions: JSON.stringify([{ key: "like", users: [{ mri: ME }] }]), + }, + }); + expect(hasMyReaction(m, "like", ME)).toBe(true); + }); + + it("tolerates missing, empty, and malformed properties", () => { + expect(getEmotions(msg())).toEqual([]); + expect(getEmotions(msg({ properties: { emotions: [] } }))).toEqual([]); + expect(getEmotions(msg({ properties: { emotions: "not-json" } }))).toEqual( + [], + ); + expect(hasMyReaction(msg(), "like", ME)).toBe(false); + }); + + it("detects other users only", () => { + const m = msg({ + properties: { + emotions: [{ key: "like", users: [{ mri: "8:orgid:other" }] }], + }, + }); + expect(hasMyReaction(m, "like", ME)).toBe(false); + }); + + it("treats an empty users list as no reaction", () => { + const m = msg({ + properties: { emotions: [{ key: "like", users: [] }] }, + }); + expect(hasMyReaction(m, "like", ME)).toBe(false); + }); +});