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
9 changes: 9 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"

# 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"

Expand Down Expand Up @@ -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

Expand Down
37 changes: 10 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -180,6 +188,108 @@ export function createProgram(): Command {
);
});

// react
program
.command("react <chat> [emoji]")
.description(
"React to a message (default: like on the newest message). " +
"Emoji: like, heart, laugh, surprised.",
)
.option(
"-m, --msg <id>",
"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 <ms>", "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 <query>")
Expand Down
Loading