diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 95855b281c..385815f4d2 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -4,6 +4,7 @@ "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { + "@sinclair/typebox": "0.34.49", "@microsoft/teams.api": "2.0.7", "@microsoft/teams.apps": "2.0.7", "express": "^5.2.1", diff --git a/extensions/msteams/src/actions.ts b/extensions/msteams/src/actions.ts index 6db32bde96..5d6d6bcca5 100644 --- a/extensions/msteams/src/actions.ts +++ b/extensions/msteams/src/actions.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionAdapter, @@ -71,6 +72,14 @@ function resolveActionTarget( : (currentChannelId?.trim() ?? ""); } +function resolveGraphActionTarget( + params: Record, + currentChannelId?: string | null, + currentGraphChannelId?: string | null, +): string { + return resolveActionTarget(params, currentGraphChannelId ?? currentChannelId); +} + function resolveActionMessageId(params: Record): string { return normalizeOptionalString(params.messageId) ?? ""; } @@ -113,8 +122,16 @@ function resolveRequiredActionTarget(params: { actionLabel: string; toolParams: Record; currentChannelId?: string | null; + currentGraphChannelId?: string | null; + graphOnly?: boolean; }): string | ReturnType { - const to = resolveActionTarget(params.toolParams, params.currentChannelId); + const to = params.graphOnly + ? resolveGraphActionTarget( + params.toolParams, + params.currentChannelId, + params.currentGraphChannelId, + ) + : resolveActionTarget(params.toolParams, params.currentChannelId); if (!to) { return actionError(`${params.actionLabel} requires a target (to).`); } @@ -125,8 +142,16 @@ function resolveRequiredActionMessageTarget(params: { actionLabel: string; toolParams: Record; currentChannelId?: string | null; + currentGraphChannelId?: string | null; + graphOnly?: boolean; }): { to: string; messageId: string } | ReturnType { - const to = resolveActionTarget(params.toolParams, params.currentChannelId); + const to = params.graphOnly + ? resolveGraphActionTarget( + params.toolParams, + params.currentChannelId, + params.currentGraphChannelId, + ) + : resolveActionTarget(params.toolParams, params.currentChannelId); const messageId = resolveActionMessageId(params.toolParams); if (!to || !messageId) { return actionError(`${params.actionLabel} requires a target (to) and messageId.`); @@ -138,8 +163,16 @@ function resolveRequiredActionPinnedMessageTarget(params: { actionLabel: string; toolParams: Record; currentChannelId?: string | null; + currentGraphChannelId?: string | null; + graphOnly?: boolean; }): { to: string; pinnedMessageId: string } | ReturnType { - const to = resolveActionTarget(params.toolParams, params.currentChannelId); + const to = params.graphOnly + ? resolveGraphActionTarget( + params.toolParams, + params.currentChannelId, + params.currentGraphChannelId, + ) + : resolveActionTarget(params.toolParams, params.currentChannelId); const pinnedMessageId = resolveActionPinnedMessageId(params.toolParams); if (!to || !pinnedMessageId) { return actionError(`${params.actionLabel} requires a target (to) and pinnedMessageId.`); @@ -151,12 +184,16 @@ async function runWithRequiredActionTarget(params: { actionLabel: string; toolParams: Record; currentChannelId?: string | null; + currentGraphChannelId?: string | null; + graphOnly?: boolean; run: (to: string) => Promise; }): Promise> { const to = resolveRequiredActionTarget({ actionLabel: params.actionLabel, toolParams: params.toolParams, currentChannelId: params.currentChannelId, + currentGraphChannelId: params.currentGraphChannelId, + graphOnly: params.graphOnly, }); if (typeof to !== "string") { return to; @@ -168,12 +205,16 @@ async function runWithRequiredActionMessageTarget(params: { actionLabel: string; toolParams: Record; currentChannelId?: string | null; + currentGraphChannelId?: string | null; + graphOnly?: boolean; run: (target: { to: string; messageId: string }) => Promise; }): Promise> { const target = resolveRequiredActionMessageTarget({ actionLabel: params.actionLabel, toolParams: params.toolParams, currentChannelId: params.currentChannelId, + currentGraphChannelId: params.currentGraphChannelId, + graphOnly: params.graphOnly, }); if ("isError" in target) { return target; @@ -185,12 +226,16 @@ async function runWithRequiredActionPinnedMessageTarget(params: { actionLabel: string; toolParams: Record; currentChannelId?: string | null; + currentGraphChannelId?: string | null; + graphOnly?: boolean; run: (target: { to: string; pinnedMessageId: string }) => Promise; }): Promise> { const target = resolveRequiredActionPinnedMessageTarget({ actionLabel: params.actionLabel, toolParams: params.toolParams, currentChannelId: params.currentChannelId, + currentGraphChannelId: params.currentGraphChannelId, + graphOnly: params.graphOnly, }); if ("isError" in target) { return target; @@ -230,6 +275,12 @@ export function describeMSTeamsMessageTool({ ? { properties: { card: createMessageToolCardSchema(), + pinnedMessageId: Type.Optional( + Type.String({ + description: + "Pinned message resource ID for unpin (from pin or list-pins, not the chat message ID).", + }), + ), }, } : null, @@ -348,6 +399,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "Read", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (target) => { const { getMessageMSTeams } = await loadMSTeamsChannelRuntime(); const message = await getMessageMSTeams({ @@ -365,6 +418,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "Pin", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (target) => { const { pinMessageMSTeams } = await loadMSTeamsChannelRuntime(); const result = await pinMessageMSTeams({ @@ -382,6 +437,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "Unpin", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (target) => { const { unpinMessageMSTeams } = await loadMSTeamsChannelRuntime(); const result = await unpinMessageMSTeams({ @@ -399,6 +456,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "List-pins", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (to) => { const { listPinsMSTeams } = await loadMSTeamsChannelRuntime(); const result = await listPinsMSTeams({ cfg: ctx.cfg, to }); @@ -412,6 +471,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "React", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (target) => { const emoji = normalizeOptionalString(ctx.params.emoji) ?? ""; const remove = typeof ctx.params.remove === "boolean" ? ctx.params.remove : false; @@ -464,6 +525,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "Reactions", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (target) => { const { listReactionsMSTeams } = await loadMSTeamsChannelRuntime(); const result = await listReactionsMSTeams({ @@ -481,6 +544,8 @@ export const msteamsActionsAdapter: NonNullable = { actionLabel: "Search", toolParams: ctx.params, currentChannelId: ctx.toolContext?.currentChannelId, + currentGraphChannelId: ctx.toolContext?.currentGraphChannelId, + graphOnly: true, run: async (to) => { const query = resolveActionQuery(ctx.params); if (!query) { diff --git a/extensions/msteams/src/channel.actions.test.ts b/extensions/msteams/src/channel.actions.test.ts index 535a46aee1..8e586f69c9 100644 --- a/extensions/msteams/src/channel.actions.test.ts +++ b/extensions/msteams/src/channel.actions.test.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { msteamsActionsAdapter } from "./actions.js"; +import { msteamsPlugin } from "./channel.js"; const { editMessageMSTeamsMock, @@ -331,6 +332,52 @@ describe("msteamsPlugin message actions", () => { }); }); + it("uses explicit pinnedMessageId over messageId for unpin actions", async () => { + await expectSuccessfulAction({ + mockFn: unpinMessageMSTeamsMock, + mockResult: { ok: true }, + action: "unpin", + actionParams: { + target: padded(targetChannelId), + pinnedMessageId: padded("pinned-resource-99"), + messageId: padded("msg-99"), + }, + runtimeParams: { + to: targetChannelId, + pinnedMessageId: "pinned-resource-99", + }, + details: okMSTeamsActionDetails("unpin"), + }); + }); + + it("returns an error when unpin is called without pinnedMessageId or messageId", async () => { + await expectActionParamError( + "unpin", + { target: targetChannelId }, + "Unpin requires a target (to) and pinnedMessageId.", + ); + }); + + it("exposes pinnedMessageId in the tool schema", () => { + const discovery = msteamsPlugin.actions?.describeMessageTool?.({ + cfg: { + channels: { + msteams: { + appId: "app-id", + appPassword: "secret", + tenantId: "tenant-id", + }, + }, + } as OpenClawConfig, + }); + const schema = discovery?.schema; + expect(schema).toBeTruthy(); + const properties = Array.isArray(schema) + ? schema[0]?.properties + : (schema as { properties: Record })?.properties; + expect(properties).toHaveProperty("pinnedMessageId"); + }); + it("reuses currentChannelId fallback for react actions", async () => { await expectSuccessfulAction({ mockFn: reactMessageMSTeamsMock, @@ -399,4 +446,171 @@ describe("msteamsPlugin message actions", () => { searchMissingQueryError, ); }); + + it("routes channel fallback targets via teamId/channelId for react actions", async () => { + // When an action is invoked in a Teams channel context and `target` is + // omitted, the action handler falls back to `toolContext.currentChannelId`. + // For channel turns, buildToolContext populates that field with the + // compound `teamId/channelId` form (see buildToolContext below), so the + // runtime call must receive that compound form — NOT a bare + // `conversation:` — so Graph API routes through + // `/teams/{teamId}/channels/{channelId}` rather than `/chats/{id}`. + const teamChannelTarget = "team-1/19:channel-abc@thread.tacv2"; + await expectSuccessfulAction({ + mockFn: reactMessageMSTeamsMock, + mockResult: { ok: true }, + action: "react", + actionParams: { + messageId: "msg-channel-react", + emoji: reactionType, + }, + toolContext: { + currentChannelId: "conversation:19:channel-abc@thread.tacv2", + currentGraphChannelId: teamChannelTarget, + }, + runtimeParams: { + to: teamChannelTarget, + messageId: "msg-channel-react", + reactionType, + }, + details: okMSTeamsActionDetails("react", { + reactionType, + }), + contentDetails: { + channel: "msteams", + action: "react", + reactionType, + ok: true, + }, + }); + }); + + it("preserves explicit teamId/channelId target over toolContext fallback", async () => { + // Even in a channel context with a compound currentChannelId, an + // explicit `target` param must take precedence. + const teamChannelTarget = "team-2/19:channel-def@thread.tacv2"; + const explicitTarget = "team-explicit/19:other@thread.tacv2"; + await expectSuccessfulAction({ + mockFn: reactMessageMSTeamsMock, + mockResult: { ok: true }, + action: "react", + actionParams: { + target: explicitTarget, + messageId: "msg-explicit", + emoji: reactionType, + }, + toolContext: { + currentChannelId: teamChannelTarget, + currentGraphChannelId: teamChannelTarget, + }, + runtimeParams: { + to: explicitTarget, + messageId: "msg-explicit", + reactionType, + }, + details: okMSTeamsActionDetails("react", { + reactionType, + }), + contentDetails: { + channel: "msteams", + action: "react", + reactionType, + ok: true, + }, + }); + }); + + it("keeps chat conversation fallback targets as-is for DM react actions", async () => { + // DM/group-chat turns continue to set currentChannelId to a + // `conversation:` string (no `teamId/` prefix), which the runtime + // will resolve through `/chats/{id}`. + const dmFallback = "conversation:19:chat-dm@thread.skype"; + await expectSuccessfulAction({ + mockFn: reactMessageMSTeamsMock, + mockResult: { ok: true }, + action: "react", + actionParams: { + messageId: "msg-dm-react", + emoji: reactionType, + }, + toolContext: { + currentChannelId: dmFallback, + }, + runtimeParams: { + to: dmFallback, + messageId: "msg-dm-react", + reactionType, + }, + details: okMSTeamsActionDetails("react", { + reactionType, + }), + contentDetails: { + channel: "msteams", + action: "react", + reactionType, + ok: true, + }, + }); + }); +}); + +describe("msteamsPlugin.threading.buildToolContext", () => { + function callBuildToolContext(context: { + To?: string; + NativeChannelId?: string; + ReplyToId?: string; + }) { + const build = msteamsPlugin.threading?.buildToolContext; + if (!build) { + throw new Error("msteams threading.buildToolContext unavailable"); + } + return build({ + cfg: {} as OpenClawConfig, + accountId: undefined, + context, + }); + } + + it("uses NativeChannelId for channel turns so actions route via teamId/channelId", () => { + // Teams channel inbound messages carry the compound `teamId/channelId` + // on NativeChannelId. buildToolContext must prefer it over the bare + // `conversation:` in To so action fallbacks route via + // `/teams/{teamId}/channels/{channelId}`. + const result = callBuildToolContext({ + To: "conversation:19:channel-abc@thread.tacv2", + NativeChannelId: "team-1/19:channel-abc@thread.tacv2", + ReplyToId: "reply-1", + }); + expect(result?.currentChannelId).toBe("conversation:19:channel-abc@thread.tacv2"); + expect(result?.currentGraphChannelId).toBe("team-1/19:channel-abc@thread.tacv2"); + expect(result?.currentThreadTs).toBe("reply-1"); + }); + + it("falls back to To for DM turns (no NativeChannelId)", () => { + const result = callBuildToolContext({ + To: "user:aad-user-1", + }); + expect(result?.currentChannelId).toBe("user:aad-user-1"); + expect(result?.currentGraphChannelId).toBeUndefined(); + }); + + it("falls back to To for group chat turns (no NativeChannelId)", () => { + const result = callBuildToolContext({ + To: "conversation:19:groupchat@thread.v2", + }); + expect(result?.currentChannelId).toBe("conversation:19:groupchat@thread.v2"); + expect(result?.currentGraphChannelId).toBeUndefined(); + }); + + it("ignores NativeChannelId that does not encode a teamId/channelId pair", () => { + // Safety: only compound forms (with "/") should preempt the To fallback. + // A bare native id without a team prefix must not accidentally route + // through channel Graph paths. + const result = callBuildToolContext({ + To: "conversation:19:chat@thread.v2", + NativeChannelId: "19:chat@thread.v2", + }); + expect(result?.currentChannelId).toBe("conversation:19:chat@thread.v2"); + expect(result?.currentGraphChannelId).toBeUndefined(); + }); }); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 613f6a5df3..eae3cc0f89 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -387,11 +387,16 @@ export const msteamsPlugin: ChannelPlugin ({ - currentChannelId: normalizeOptionalString(context.To), - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }), + buildToolContext: ({ context, hasRepliedRef }) => { + const nativeChannelId = context.NativeChannelId?.trim(); + const hasChannelRoute = Boolean(nativeChannelId && nativeChannelId.includes("/")); + return { + currentChannelId: normalizeOptionalString(context.To), + currentGraphChannelId: hasChannelRoute ? nativeChannelId : undefined, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }; + }, }, outbound: { deliveryMode: "direct", diff --git a/extensions/msteams/src/graph-messages.actions.test.ts b/extensions/msteams/src/graph-messages.actions.test.ts index 147116b52d..9b7f474107 100644 --- a/extensions/msteams/src/graph-messages.actions.test.ts +++ b/extensions/msteams/src/graph-messages.actions.test.ts @@ -23,7 +23,7 @@ beforeAll(async () => { }); describe("pinMessageMSTeams", () => { - it("pins a message in a chat", async () => { + it("pins a message in a chat via message@odata.bind body", async () => { mockState.postGraphJson.mockResolvedValue({ id: "pinned-1" }); const result = await pinMessageMSTeams({ @@ -36,25 +36,23 @@ describe("pinMessageMSTeams", () => { expect(mockState.postGraphJson).toHaveBeenCalledWith({ token: TOKEN, path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages`, - body: { message: { id: "msg-1" } }, + body: { + "message@odata.bind": `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent( + CHAT_ID, + )}/messages/${encodeURIComponent("msg-1")}`, + }, }); }); - it("pins a message in a channel", async () => { - mockState.postGraphJson.mockResolvedValue({}); - - const result = await pinMessageMSTeams({ - cfg: {} as OpenClawConfig, - to: CHANNEL_TO, - messageId: "msg-2", - }); - - expect(result).toEqual({ ok: true }); - expect(mockState.postGraphJson).toHaveBeenCalledWith({ - token: TOKEN, - path: "/teams/team-id-1/channels/channel-id-1/pinnedMessages", - body: { message: { id: "msg-2" } }, - }); + it("rejects pinning a message in a channel on Graph v1.0", async () => { + await expect( + pinMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + messageId: "msg-2", + }), + ).rejects.toThrow(/Pin\/unpin is not supported for channel messages/); + expect(mockState.postGraphJson).not.toHaveBeenCalled(); }); }); @@ -71,24 +69,19 @@ describe("unpinMessageMSTeams", () => { expect(result).toEqual({ ok: true }); expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({ token: TOKEN, - path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages/pinned-1`, + path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages/${encodeURIComponent("pinned-1")}`, }); }); - it("unpins a message from a channel", async () => { - mockState.deleteGraphRequest.mockResolvedValue(undefined); - - const result = await unpinMessageMSTeams({ - cfg: {} as OpenClawConfig, - to: CHANNEL_TO, - pinnedMessageId: "pinned-2", - }); - - expect(result).toEqual({ ok: true }); - expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({ - token: TOKEN, - path: "/teams/team-id-1/channels/channel-id-1/pinnedMessages/pinned-2", - }); + it("rejects unpinning a message from a channel on Graph v1.0", async () => { + await expect( + unpinMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + pinnedMessageId: "pinned-2", + }), + ).rejects.toThrow(/Pin\/unpin is not supported for channel messages/); + expect(mockState.deleteGraphRequest).not.toHaveBeenCalled(); }); }); @@ -146,15 +139,35 @@ describe("reactMessageMSTeams", () => { }); }); - it("rejects invalid reaction type", async () => { + it("passes through non-well-known reaction types (e.g. Unicode emoji)", async () => { + // Graph setReaction accepts arbitrary Unicode emoji plus the legacy + // well-known types; normalizeReactionType only lowercases the legacy set + // and lets any other non-empty value through unchanged. + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + await reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "🎉", + }); + + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`, + body: { reactionType: "🎉" }, + }); + }); + + it("rejects empty reaction type", async () => { await expect( reactMessageMSTeams({ cfg: {} as OpenClawConfig, to: CHAT_ID, messageId: "msg-1", - reactionType: "thumbsup", + reactionType: " ", }), - ).rejects.toThrow('Invalid reaction type "thumbsup"'); + ).rejects.toThrow(/Reaction type is required/); }); it("resolves user: target through conversation store", async () => { @@ -217,14 +230,14 @@ describe("unreactMessageMSTeams", () => { }); }); - it("rejects invalid reaction type", async () => { + it("rejects empty reaction type", async () => { await expect( unreactMessageMSTeams({ cfg: {} as OpenClawConfig, to: CHAT_ID, messageId: "msg-1", - reactionType: "clap", + reactionType: "", }), - ).rejects.toThrow('Invalid reaction type "clap"'); + ).rejects.toThrow(/Reaction type is required/); }); }); diff --git a/extensions/msteams/src/graph-messages.read.test.ts b/extensions/msteams/src/graph-messages.read.test.ts index f10b281b7b..e1c39cfa72 100644 --- a/extensions/msteams/src/graph-messages.read.test.ts +++ b/extensions/msteams/src/graph-messages.read.test.ts @@ -232,6 +232,8 @@ describe("listReactionsMSTeams", () => { expect(result.reactions).toEqual([ { reactionType: "like", + name: "like", + emoji: "\u{1F44D}", count: 2, users: [ { id: "u1", displayName: "Alice" }, @@ -240,6 +242,8 @@ describe("listReactionsMSTeams", () => { }, { reactionType: "heart", + name: "heart", + emoji: "\u2764\uFE0F", count: 1, users: [{ id: "u1", displayName: "Alice" }], }, @@ -275,7 +279,13 @@ describe("listReactionsMSTeams", () => { }); expect(result.reactions).toEqual([ - { reactionType: "surprised", count: 1, users: [{ id: "u3", displayName: "Carol" }] }, + { + reactionType: "surprised", + name: "surprised", + emoji: "\u{1F62E}", + count: 1, + users: [{ id: "u3", displayName: "Carol" }], + }, ]); expect(mockState.fetchGraphJson).toHaveBeenCalledWith({ token: TOKEN, diff --git a/extensions/msteams/src/graph-messages.test.ts b/extensions/msteams/src/graph-messages.test.ts new file mode 100644 index 0000000000..0fbeec1c5d --- /dev/null +++ b/extensions/msteams/src/graph-messages.test.ts @@ -0,0 +1,679 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; +import { + getMessageMSTeams, + listPinsMSTeams, + listReactionsMSTeams, + pinMessageMSTeams, + reactMessageMSTeams, + unpinMessageMSTeams, + unreactMessageMSTeams, +} from "./graph-messages.js"; + +const mockState = vi.hoisted(() => ({ + resolveGraphToken: vi.fn(), + fetchGraphJson: vi.fn(), + fetchGraphAbsoluteUrl: vi.fn(), + postGraphJson: vi.fn(), + postGraphBetaJson: vi.fn(), + deleteGraphRequest: vi.fn(), + findPreferredDmByUserId: vi.fn(), +})); + +vi.mock("./graph.js", () => ({ + resolveGraphToken: mockState.resolveGraphToken, + fetchGraphJson: mockState.fetchGraphJson, + fetchGraphAbsoluteUrl: mockState.fetchGraphAbsoluteUrl, + postGraphJson: mockState.postGraphJson, + postGraphBetaJson: mockState.postGraphBetaJson, + deleteGraphRequest: mockState.deleteGraphRequest, +})); + +vi.mock("./conversation-store-fs.js", () => ({ + createMSTeamsConversationStoreFs: () => ({ + findPreferredDmByUserId: mockState.findPreferredDmByUserId, + }), +})); + +const TOKEN = "test-graph-token"; +const CHAT_ID = "19:abc@thread.tacv2"; +const CHANNEL_TO = "team-id-1/channel-id-1"; + +describe("getMessageMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + 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"); + // Must use the graphChatId, not the Bot Framework conversation ID + 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("pinMessageMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + it("pins a message in a chat using message@odata.bind", async () => { + mockState.postGraphJson.mockResolvedValue({ id: "pinned-1" }); + + const result = await pinMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + }); + + expect(result).toEqual({ ok: true, pinnedMessageId: "pinned-1" }); + expect(mockState.postGraphJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages`, + body: { + "message@odata.bind": `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`, + }, + }); + }); + + it("throws for channel pin (not supported on Graph v1.0)", async () => { + await expect( + pinMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + messageId: "msg-2", + }), + ).rejects.toThrow("not supported for channel messages"); + }); +}); + +describe("unpinMessageMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + it("unpins a message from a chat", async () => { + mockState.deleteGraphRequest.mockResolvedValue(undefined); + + const result = await unpinMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + pinnedMessageId: "pinned-1", + }); + + expect(result).toEqual({ ok: true }); + expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages/pinned-1`, + }); + }); + + it("throws for channel unpin (not supported on Graph v1.0)", async () => { + await expect( + unpinMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + pinnedMessageId: "pinned-2", + }), + ).rejects.toThrow("not supported for channel messages"); + }); +}); + +describe("listPinsMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + 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([]); + }); + + it("follows @odata.nextLink pagination", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [{ id: "pinned-1", message: { id: "msg-1", body: { content: "First page" } } }], + "@odata.nextLink": + "https://graph.microsoft.com/v1.0/chats/19%3Aabc%40thread.tacv2/pinnedMessages?$expand=message&$skiptoken=page2", + }); + mockState.fetchGraphAbsoluteUrl.mockResolvedValue({ + value: [{ id: "pinned-2", message: { id: "msg-2", body: { content: "Second page" } } }], + }); + + const result = await listPinsMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + }); + + expect(result.pins).toEqual([ + { id: "pinned-1", pinnedMessageId: "pinned-1", messageId: "msg-1", text: "First page" }, + { id: "pinned-2", pinnedMessageId: "pinned-2", messageId: "msg-2", text: "Second page" }, + ]); + expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledWith({ + token: TOKEN, + url: "https://graph.microsoft.com/v1.0/chats/19%3Aabc%40thread.tacv2/pinnedMessages?$expand=message&$skiptoken=page2", + }); + }); + + it("stops paginating after max pages", async () => { + // Return nextLink on every page to test the cap + const makePageResponse = (pageNum: number) => ({ + value: [ + { + id: `pinned-${pageNum}`, + message: { id: `msg-${pageNum}`, body: { content: `Page ${pageNum}` } }, + }, + ], + "@odata.nextLink": `https://graph.microsoft.com/v1.0/next?page=${pageNum + 1}`, + }); + + mockState.fetchGraphJson.mockResolvedValue(makePageResponse(1)); + // Pages 2-10 via fetchGraphAbsoluteUrl (page 1 is the initial fetch) + for (let i = 2; i <= 10; i++) { + mockState.fetchGraphAbsoluteUrl.mockResolvedValueOnce(makePageResponse(i)); + } + + const result = await listPinsMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + }); + + // Should collect exactly 10 pages (the max) and stop + expect(result.pins).toHaveLength(10); + // fetchGraphAbsoluteUrl should be called 9 times (pages 2-10) + expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledTimes(9); + }); + + it("throws for channel list-pins (not supported on Graph v1.0)", async () => { + await expect( + listPinsMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + }), + ).rejects.toThrow("not supported for channels"); + }); +}); + +describe("reactMessageMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + it("sets a like reaction on a chat message", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + const result = await reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "like", + }); + + expect(result).toEqual({ ok: true }); + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`, + body: { reactionType: "like" }, + }); + }); + + it("sets a reaction on a channel message", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + const result = await reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + messageId: "msg-2", + reactionType: "heart", + }); + + expect(result).toEqual({ ok: true }); + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/setReaction", + body: { reactionType: "heart" }, + }); + }); + + it("normalizes reaction type to lowercase", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + await reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "LAUGH", + }); + + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`, + body: { reactionType: "laugh" }, + }); + }); + + it("passes through unknown reaction types (Unicode emoji)", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + await reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "\u{1F44D}", + }); + + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`, + body: { reactionType: "\u{1F44D}" }, + }); + }); + + it("rejects empty reaction type", async () => { + await expect( + reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "", + }), + ).rejects.toThrow("Reaction type is required"); + }); + + it("resolves user: target through conversation store", async () => { + mockState.findPreferredDmByUserId.mockResolvedValue({ + conversationId: "a:bot-id", + reference: { graphChatId: "19:dm-chat@thread.tacv2" }, + }); + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + await reactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: "user:aad-user-1", + messageId: "msg-1", + reactionType: "like", + }); + + expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1"); + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages/msg-1/setReaction`, + body: { reactionType: "like" }, + }); + }); +}); + +describe("unreactMessageMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + it("removes a reaction from a chat message", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + const result = await unreactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "sad", + }); + + expect(result).toEqual({ ok: true }); + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`, + body: { reactionType: "sad" }, + }); + }); + + it("removes a reaction from a channel message", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + const result = await unreactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + messageId: "msg-2", + reactionType: "angry", + }); + + expect(result).toEqual({ ok: true }); + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/unsetReaction", + body: { reactionType: "angry" }, + }); + }); + + it("passes through unknown reaction types", async () => { + mockState.postGraphBetaJson.mockResolvedValue(undefined); + + await unreactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: "clap", + }); + + expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`, + body: { reactionType: "clap" }, + }); + }); + + it("rejects empty reaction type", async () => { + await expect( + unreactMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + messageId: "msg-1", + reactionType: " ", + }), + ).rejects.toThrow("Reaction type is required"); + }); +}); + +describe("listReactionsMSTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState.resolveGraphToken.mockResolvedValue(TOKEN); + }); + + 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("counts reactions from users without an ID (deleted/guest/anonymous)", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + id: "msg-1", + body: { content: "Hello" }, + reactions: [ + { reactionType: "like", user: { id: "u1", displayName: "Alice" } }, + { reactionType: "like", user: { displayName: "Deleted User" } }, + { reactionType: "like", user: undefined }, + { reactionType: "like" }, + { reactionType: "heart", user: { id: "u2", displayName: "Bob" } }, + ], + }); + + 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: 4, + users: [{ id: "u1", displayName: "Alice" }], + }, + { + reactionType: "heart", + name: "heart", + emoji: "\u2764\uFE0F", + count: 1, + users: [{ id: "u2", displayName: "Bob" }], + }, + ]); + }); + + 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", + }); + }); +}); diff --git a/extensions/msteams/src/graph-messages.ts b/extensions/msteams/src/graph-messages.ts index f780870166..cd8cd3b925 100644 --- a/extensions/msteams/src/graph-messages.ts +++ b/extensions/msteams/src/graph-messages.ts @@ -1,10 +1,10 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { OpenClawConfig } from "../runtime-api.js"; import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; import { type GraphResponse, deleteGraphRequest, escapeOData, + fetchGraphAbsoluteUrl, fetchGraphJson, postGraphBetaJson, postGraphJson, @@ -35,6 +35,7 @@ type GraphPinnedMessage = { type GraphPinnedMessagesResponse = { value?: GraphPinnedMessage[]; + "@odata.nextLink"?: string; }; /** @@ -115,6 +116,11 @@ function resolveConversationPath(to: string): { channelId, }; } + // Conversation IDs like 19:xxx@thread.tacv2 may represent either group chats + // or channel threads. Without a teamId/channelId pair (format "teamId/channelId") + // we route through /chats/{id} which works for group chats and 1:1 DMs. + // Channel operations that require /teams/{teamId}/channels/{channelId} paths + // must be called with the explicit teamId/channelId target format. return { kind: "chat", basePath: `/chats/${encodeURIComponent(cleaned)}`, @@ -162,7 +168,14 @@ export type PinMessageMSTeamsParams = { /** * Pin a message in a chat conversation via Graph API. - * Channel pinning uses a different endpoint (beta) handled separately. + * + * Chat pinning uses the v1.0 endpoint: `POST /chats/{chatId}/pinnedMessages`. + * + * Channel pinning uses `POST /teams/{teamId}/channels/{channelId}/pinnedMessages`. + * **Note:** The channel pin endpoint may require the Graph beta API or specific + * tenant-level permissions. As of March 2026, general availability is not + * confirmed for all tenants. If the call returns 404 or 403, the endpoint may + * not be enabled for the target tenant. */ export async function pinMessageMSTeams( params: PinMessageMSTeamsParams, @@ -172,20 +185,22 @@ export async function pinMessageMSTeams( const conv = resolveConversationPath(conversationId); if (conv.kind === "channel") { - // Graph v1.0 doesn't have channel pin — use the pinnedMessages pattern on chat - // For channels, attempt POST to pinnedMessages (same shape, may require beta) - await postGraphJson({ - token, - path: `${conv.basePath}/pinnedMessages`, - body: { message: { id: params.messageId } }, - }); - return { ok: true }; + // Graph v1.0 does not expose pinnedMessages on channels — only on chats. + // Attempting this would 404. + throw new Error( + "Pin/unpin is not supported for channel messages on Graph v1.0. " + + "Only chat conversations support pinned messages.", + ); } + // Graph API expects message@odata.bind with the full message resource URI + const body = { + "message@odata.bind": `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(params.messageId)}`, + }; const result = await postGraphJson<{ id?: string }>({ token, path: `${conv.basePath}/pinnedMessages`, - body: { message: { id: params.messageId } }, + body, }); return { ok: true, pinnedMessageId: result.id }; } @@ -201,6 +216,9 @@ export type UnpinMessageMSTeamsParams = { * Unpin a message in a chat conversation via Graph API. * `pinnedMessageId` is the pinned-message resource ID (from pin or list-pins), * not the underlying chat message ID. + * + * Channel unpin uses `DELETE /teams/{teamId}/channels/{channelId}/pinnedMessages/{id}`. + * See the note on {@link pinMessageMSTeams} regarding beta/GA status. */ export async function unpinMessageMSTeams( params: UnpinMessageMSTeamsParams, @@ -208,6 +226,12 @@ export async function unpinMessageMSTeams( const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const conv = resolveConversationPath(conversationId); + if (conv.kind === "channel") { + throw new Error( + "Pin/unpin is not supported for channel messages on Graph v1.0. " + + "Only chat conversations support pinned messages.", + ); + } const path = `${conv.basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}`; await deleteGraphRequest({ token, path }); return { ok: true }; @@ -222,8 +246,15 @@ export type ListPinsMSTeamsResult = { pins: Array<{ id: string; pinnedMessageId: string; messageId?: string; text?: string }>; }; +/** Maximum number of pagination pages to follow to avoid unbounded loops. */ +const LIST_PINS_MAX_PAGES = 10; + /** * List all pinned messages in a chat conversation via Graph API. + * Follows `@odata.nextLink` pagination to collect the full pin set. + * + * Channel list-pins uses the same endpoint pattern as channel pin/unpin. + * See the note on {@link pinMessageMSTeams} regarding beta/GA status. */ export async function listPinsMSTeams( params: ListPinsMSTeamsParams, @@ -231,15 +262,41 @@ export async function listPinsMSTeams( const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const conv = resolveConversationPath(conversationId); + + if (conv.kind === "channel") { + throw new Error( + "Listing pinned messages is not supported for channels on Graph v1.0. " + + "Only chat conversations support pinned messages.", + ); + } + const path = `${conv.basePath}/pinnedMessages?$expand=message`; - const res = await fetchGraphJson({ token, path }); - const pins = (res.value ?? []).map((pin) => ({ - id: pin.id ?? "", - pinnedMessageId: pin.id ?? "", - messageId: pin.message?.id, - text: pin.message?.body?.content, - })); - return { pins }; + const allPins: Array<{ id: string; pinnedMessageId: string; messageId?: string; text?: string }> = + []; + + let res = await fetchGraphJson({ token, path }); + let pages = 1; + + while (true) { + for (const pin of res.value ?? []) { + allPins.push({ + id: pin.id ?? "", + pinnedMessageId: pin.id ?? "", + messageId: pin.message?.id, + text: pin.message?.body?.content, + }); + } + + const nextLink = res["@odata.nextLink"]; + if (!nextLink || pages >= LIST_PINS_MAX_PAGES) { + break; + } + + res = await fetchGraphAbsoluteUrl({ token, url: nextLink }); + pages++; + } + + return { pins: allPins }; } // --------------------------------------------------------------------------- @@ -279,8 +336,22 @@ export type ListReactionsMSTeamsParams = { messageId: string; }; +/** Map well-known reaction type names to representative emoji for CLI display. */ +const REACTION_TYPE_EMOJI: Record = { + like: "\u{1F44D}", + heart: "\u2764\uFE0F", + laugh: "\u{1F606}", + surprised: "\u{1F62E}", + sad: "\u{1F622}", + angry: "\u{1F621}", +}; + export type ReactionSummary = { reactionType: string; + /** Display name for the reaction (matches reactionType for known types). */ + name: string; + /** Emoji representation when available. */ + emoji?: string; count: number; users: Array<{ id: string; displayName?: string }>; }; @@ -289,14 +360,23 @@ export type ListReactionsMSTeamsResult = { reactions: ReactionSummary[]; }; -function validateReactionType(raw: string): TeamsReactionType { - const normalized = normalizeLowercaseStringOrEmpty(raw); - if (!TEAMS_REACTION_TYPES.includes(normalized as TeamsReactionType)) { - throw new Error( - `Invalid reaction type "${raw}". Valid types: ${TEAMS_REACTION_TYPES.join(", ")}`, - ); +/** + * Normalize a reaction type string. Graph setReaction/unsetReaction accepts + * the well-known legacy names (like, heart, laugh, surprised, sad, angry) + * as well as Unicode emoji values — so we pass unknown types through rather + * than rejecting them. + */ +function normalizeReactionType(raw: string): string { + const normalized = raw.trim(); + if (!normalized) { + throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`); } - return normalized as TeamsReactionType; + // Lowercase only the well-known names; Unicode emoji should pass through as-is + const lowered = normalized.toLowerCase(); + if (TEAMS_REACTION_TYPES.includes(lowered as TeamsReactionType)) { + return lowered; + } + return normalized; } /** @@ -305,7 +385,7 @@ function validateReactionType(raw: string): TeamsReactionType { export async function reactMessageMSTeams( params: ReactMessageMSTeamsParams, ): Promise<{ ok: true }> { - const reactionType = validateReactionType(params.reactionType); + const reactionType = normalizeReactionType(params.reactionType); const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const { basePath } = resolveConversationPath(conversationId); @@ -320,7 +400,7 @@ export async function reactMessageMSTeams( export async function unreactMessageMSTeams( params: ReactMessageMSTeamsParams, ): Promise<{ ok: true }> { - const reactionType = validateReactionType(params.reactionType); + const reactionType = normalizeReactionType(params.reactionType); const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const { basePath } = resolveConversationPath(conversationId); @@ -342,24 +422,33 @@ export async function listReactionsMSTeams( const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`; const msg = await fetchGraphJson({ token, path }); - const grouped = new Map>(); + const grouped = new Map< + string, + { count: number; users: Array<{ id: string; displayName?: string }> } + >(); for (const reaction of msg.reactions ?? []) { const type = reaction.reactionType ?? "unknown"; if (!grouped.has(type)) { - grouped.set(type, []); + grouped.set(type, { count: 0, users: [] }); } + const group = grouped.get(type)!; + // Count every reaction regardless of whether the user ID is present + // (deleted accounts, guests, or anonymous users may lack a user ID) + group.count++; if (reaction.user?.id) { - grouped.get(type)!.push({ + group.users.push({ id: reaction.user.id, displayName: reaction.user.displayName, }); } } - const reactions: ReactionSummary[] = Array.from(grouped.entries()).map(([type, users]) => ({ + const reactions: ReactionSummary[] = Array.from(grouped.entries()).map(([type, group]) => ({ reactionType: type, - count: users.length, - users, + name: type, + emoji: REACTION_TYPE_EMOJI[type], + count: group.count, + users: group.users, })); return { reactions }; diff --git a/extensions/msteams/src/graph.ts b/extensions/msteams/src/graph.ts index 6bc8ce97bc..4e98b10480 100644 --- a/extensions/msteams/src/graph.ts +++ b/extensions/msteams/src/graph.ts @@ -1,4 +1,4 @@ -import type { MSTeamsConfig } from "../runtime-api.js"; +import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js"; import { GRAPH_ROOT } from "./attachments/shared.js"; const GRAPH_BETA = "https://graph.microsoft.com/beta"; @@ -83,6 +83,42 @@ export async function fetchGraphJson(params: { return (await res.json()) as T; } +/** + * Fetch JSON from an absolute Graph API URL (e.g. @odata.nextLink pagination URLs). + * Unlike {@link fetchGraphJson}, this does not prepend GRAPH_ROOT. + * + * Routed through `fetchWithSsrFGuard` so absolute-URL pagination follow-ups + * honor the same SSRF policy as other Graph calls. + */ +export async function fetchGraphAbsoluteUrl(params: { + token: string; + url: string; + headers?: Record; +}): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: params.url, + init: { + headers: { + "User-Agent": buildUserAgent(), + Authorization: `Bearer ${params.token}`, + ...params.headers, + }, + }, + auditContext: "msteams.graph.absolute", + }); + try { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Graph ${params.url} failed (${response.status}): ${text || "unknown error"}`, + ); + } + return (await response.json()) as T; + } finally { + await release(); + } +} + export async function resolveGraphToken(cfg: unknown): Promise { const creds = resolveMSTeamsCredentials( (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams as MSTeamsConfig | undefined, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index ac7988243e..b50ad9ae07 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -752,6 +752,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${rawBody}` : rawBody; + // For Teams *channel* messages (not group chats / DMs), preserve the + // `teamId/channelId` pair on NativeChannelId so downstream action handlers + // can route through `/teams/{teamId}/channels/{channelId}` via Graph API. + // The bare conversation id (`19:...@thread.tacv2`) is insufficient on its + // own because channel Graph endpoints require the owning team id too. + const nativeChannelId = isChannel && teamId ? `${teamId}/${conversationId}` : undefined; + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, BodyForAgent: bodyForAgent, @@ -776,6 +783,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { CommandAuthorized: commandAuthorized, OriginatingChannel: "msteams" as const, OriginatingTo: teamsTo, + NativeChannelId: nativeChannelId, ReplyToId: activity.replyToId ?? undefined, ReplyToBody: includeQuoteContext ? quoteInfo?.body : undefined, ReplyToSender: includeQuoteContext ? quoteInfo?.sender : undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0a373282e..45bd030fb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -843,6 +843,9 @@ importers: '@microsoft/teams.apps': specifier: 2.0.7 version: 2.0.7 + '@sinclair/typebox': + specifier: 0.34.49 + version: 0.34.49 express: specifier: ^5.2.1 version: 5.2.1 diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 60926a0a71..99bff3f36a 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -400,6 +400,7 @@ export type ChannelThreadingContext = { export type ChannelThreadingToolContext = { currentChannelId?: string; + currentGraphChannelId?: string; currentChannelProvider?: ChannelId; currentThreadTs?: string; currentMessageId?: string | number; diff --git a/src/cli/program/message/register.pins.ts b/src/cli/program/message/register.pins.ts index 62c6c0c2f7..2235099aae 100644 --- a/src/cli/program/message/register.pins.ts +++ b/src/cli/program/message/register.pins.ts @@ -15,7 +15,11 @@ export function registerMessagePinCommands(message: Command, helpers: MessageCli .withMessageBase( helpers.withRequiredMessageTarget(message.command("unpin").description("Unpin a message")), ) - .requiredOption("--message-id ", "Message id") + .requiredOption("--message-id ", "Message id (or pinned message resource id for MSTeams)") + .option( + "--pinned-message-id ", + "Pinned message resource id (MSTeams: from pin or list-pins, not the chat message id)", + ) .action(async (opts) => { await helpers.runMessageAction("unpin", opts); }),