Files
openclaw/extensions/msteams/src/graph-messages.read.test.ts
sudie-codes 0f19271092 msteams: add message actions — pin, unpin, read, react, reactions (#53432)
* msteams: add pin/unpin, list-pins, and read message actions

Wire up Graph API endpoints for message read, pin, unpin, and list-pins
in the MS Teams extension, following the same patterns as edit/delete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: address PR review comments for pin/unpin/read actions

- Handle 204 No Content in postGraphJson (Graph mutations may return empty body)
- Strip conversation:/user: prefixes in resolveConversationPath to avoid Graph 404s
- Remove dead variable in channel pin branch
- Rename unpin param from messageId to pinnedMessageId for semantic clarity
- Accept both pinnedMessageId and messageId in unpin action handler for compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: resolve user targets + add User-Agent to Graph helpers

- Resolve user:<aadId> targets to actual conversation IDs via conversation
  store before Graph API calls (fixes 404 for DM-context actions)
- Add User-Agent header to postGraphJson/deleteGraphRequest for consistency
  with fetchGraphJson after rebase onto main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: resolve DM targets to Graph chat IDs + expose pin IDs

- Prefer cached graphChatId over Bot Framework conversation IDs for user
  targets; throw descriptive error when no Graph-compatible ID is available
- Add `id` field to list-pins rows so default formatters surface the pinned
  resource ID needed for the unpin flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: add react and reactions (list) message actions

* msteams: fix reaction count undercount and remove unpin messageId fallback

* msteams: wire pinnedMessageId through CLI/tool schema, add channel pin beta warnings, add list-pins pagination

* msteams: address PR #53432 remaining review feedback

* fix(msteams): route channel actions via teamId/channelId path (#53432)

* msteams: add unpin pinnedMessageId test coverage (#53432)

* fix(msteams): keep graph routing scoped to graph actions

* fix(msteams): align graph routing context types

* msteams: route fetchGraphAbsoluteUrl through fetchWithSsrFGuard

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
2026-04-10 20:25:57 -05:00

296 lines
8.5 KiB
TypeScript

import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
CHANNEL_TO,
CHAT_ID,
TOKEN,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let getMessageMSTeams: GraphMessagesTestModule["getMessageMSTeams"];
let listPinsMSTeams: GraphMessagesTestModule["listPinsMSTeams"];
let listReactionsMSTeams: GraphMessagesTestModule["listReactionsMSTeams"];
beforeAll(async () => {
({ getMessageMSTeams, listPinsMSTeams, listReactionsMSTeams } =
await loadGraphMessagesTestModule());
});
describe("getMessageMSTeams", () => {
it("resolves user: target using graphChatId from store", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-framework-dm-id",
reference: { graphChatId: "19:graph-native-chat@thread.tacv2" },
});
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "From user DM" },
createdDateTime: "2026-03-23T12:00:00Z",
});
await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:aad-object-id-123",
messageId: "msg-1",
});
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-object-id-123");
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent("19:graph-native-chat@thread.tacv2")}/messages/msg-1`,
});
});
it("falls back to conversationId when it starts with 19:", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "19:resolved-chat@thread.tacv2",
reference: {},
});
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
createdDateTime: "2026-03-23T10:00:00Z",
});
await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:aad-id",
messageId: "msg-1",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent("19:resolved-chat@thread.tacv2")}/messages/msg-1`,
});
});
it("throws when user: target has no stored conversation", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue(null);
await expect(
getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:unknown-user",
messageId: "msg-1",
}),
).rejects.toThrow("No conversation found for user:unknown-user");
});
it("throws when user: target has Bot Framework ID and no graphChatId", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-framework-dm-id",
reference: {},
});
await expect(
getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:some-user",
messageId: "msg-1",
}),
).rejects.toThrow("Bot Framework ID");
});
it("strips conversation: prefix from target", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
from: undefined,
createdDateTime: "2026-03-23T10:00:00Z",
});
await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: `conversation:${CHAT_ID}`,
messageId: "msg-1",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
});
});
it("reads a message from a chat conversation", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello world", contentType: "text" },
from: { user: { id: "user-1", displayName: "Alice" } },
createdDateTime: "2026-03-23T10:00:00Z",
});
const result = await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result).toEqual({
id: "msg-1",
text: "Hello world",
from: { user: { id: "user-1", displayName: "Alice" } },
createdAt: "2026-03-23T10:00:00Z",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
});
});
it("reads a message from a channel conversation", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-2",
body: { content: "Channel message" },
from: { application: { id: "app-1", displayName: "Bot" } },
createdDateTime: "2026-03-23T11:00:00Z",
});
const result = await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
});
expect(result).toEqual({
id: "msg-2",
text: "Channel message",
from: { application: { id: "app-1", displayName: "Bot" } },
createdAt: "2026-03-23T11:00:00Z",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
});
});
});
describe("listPinsMSTeams", () => {
it("lists pinned messages in a chat", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "pinned-1",
message: { id: "msg-1", body: { content: "Pinned msg" } },
},
{
id: "pinned-2",
message: { id: "msg-2", body: { content: "Another pin" } },
},
],
});
const result = await listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
});
expect(result.pins).toEqual([
{ id: "pinned-1", pinnedMessageId: "pinned-1", messageId: "msg-1", text: "Pinned msg" },
{ id: "pinned-2", pinnedMessageId: "pinned-2", messageId: "msg-2", text: "Another pin" },
]);
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages?$expand=message`,
});
});
it("returns empty array when no pins exist", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
const result = await listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
});
expect(result.pins).toEqual([]);
});
});
describe("listReactionsMSTeams", () => {
it("lists reactions grouped by type with user details", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
reactions: [
{ reactionType: "like", user: { id: "u1", displayName: "Alice" } },
{ reactionType: "like", user: { id: "u2", displayName: "Bob" } },
{ reactionType: "heart", user: { id: "u1", displayName: "Alice" } },
],
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result.reactions).toEqual([
{
reactionType: "like",
name: "like",
emoji: "\u{1F44D}",
count: 2,
users: [
{ id: "u1", displayName: "Alice" },
{ id: "u2", displayName: "Bob" },
],
},
{
reactionType: "heart",
name: "heart",
emoji: "\u2764\uFE0F",
count: 1,
users: [{ id: "u1", displayName: "Alice" }],
},
]);
});
it("returns empty array when message has no reactions", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "No reactions" },
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result.reactions).toEqual([]);
});
it("fetches from channel path for channel targets", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-2",
body: { content: "Channel msg" },
reactions: [{ reactionType: "surprised", user: { id: "u3", displayName: "Carol" } }],
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
});
expect(result.reactions).toEqual([
{
reactionType: "surprised",
name: "surprised",
emoji: "\u{1F62E}",
count: 1,
users: [{ id: "u3", displayName: "Carol" }],
},
]);
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
});
});
});