mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 20:51:10 +00:00
msteams: add search message action (#54832)
* 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: add search message action via Graph API * msteams: fix search query injection, add ConsistencyLevel header, use manual query string --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,16 @@ import {
|
||||
listMSTeamsDirectoryGroupsLive as listMSTeamsDirectoryGroupsLiveImpl,
|
||||
listMSTeamsDirectoryPeersLive as listMSTeamsDirectoryPeersLiveImpl,
|
||||
} from "./directory-live.js";
|
||||
import {
|
||||
getMessageMSTeams as getMessageMSTeamsImpl,
|
||||
listPinsMSTeams as listPinsMSTeamsImpl,
|
||||
listReactionsMSTeams as listReactionsMSTeamsImpl,
|
||||
pinMessageMSTeams as pinMessageMSTeamsImpl,
|
||||
reactMessageMSTeams as reactMessageMSTeamsImpl,
|
||||
searchMessagesMSTeams as searchMessagesMSTeamsImpl,
|
||||
unpinMessageMSTeams as unpinMessageMSTeamsImpl,
|
||||
unreactMessageMSTeams as unreactMessageMSTeamsImpl,
|
||||
} from "./graph-messages.js";
|
||||
import { msteamsOutbound as msteamsOutboundImpl } from "./outbound.js";
|
||||
import { probeMSTeams as probeMSTeamsImpl } from "./probe.js";
|
||||
import {
|
||||
@@ -13,6 +23,14 @@ import {
|
||||
export const msTeamsChannelRuntime = {
|
||||
deleteMessageMSTeams: deleteMessageMSTeamsImpl,
|
||||
editMessageMSTeams: editMessageMSTeamsImpl,
|
||||
getMessageMSTeams: getMessageMSTeamsImpl,
|
||||
listPinsMSTeams: listPinsMSTeamsImpl,
|
||||
listReactionsMSTeams: listReactionsMSTeamsImpl,
|
||||
pinMessageMSTeams: pinMessageMSTeamsImpl,
|
||||
reactMessageMSTeams: reactMessageMSTeamsImpl,
|
||||
searchMessagesMSTeams: searchMessagesMSTeamsImpl,
|
||||
unpinMessageMSTeams: unpinMessageMSTeamsImpl,
|
||||
unreactMessageMSTeams: unreactMessageMSTeamsImpl,
|
||||
listMSTeamsDirectoryGroupsLive: listMSTeamsDirectoryGroupsLiveImpl,
|
||||
listMSTeamsDirectoryPeersLive: listMSTeamsDirectoryPeersLiveImpl,
|
||||
msteamsOutbound: { ...msteamsOutboundImpl },
|
||||
|
||||
@@ -114,6 +114,14 @@ const msteamsConfigAdapter = createTopLevelChannelConfigAdapter<
|
||||
resolveDefaultTo: (account) => account.defaultTo,
|
||||
});
|
||||
|
||||
function jsonActionResult(data: Record<string, unknown>) {
|
||||
const text = JSON.stringify(data);
|
||||
return {
|
||||
content: [{ type: "text" as const, text }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
function describeMSTeamsMessageTool({
|
||||
cfg,
|
||||
}: Parameters<
|
||||
@@ -123,7 +131,20 @@ function describeMSTeamsMessageTool({
|
||||
cfg.channels?.msteams?.enabled !== false &&
|
||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
|
||||
return {
|
||||
actions: enabled ? (["poll", "edit", "delete"] satisfies ChannelMessageActionName[]) : [],
|
||||
actions: enabled
|
||||
? ([
|
||||
"poll",
|
||||
"edit",
|
||||
"delete",
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"read",
|
||||
"react",
|
||||
"reactions",
|
||||
"search",
|
||||
] satisfies ChannelMessageActionName[])
|
||||
: [],
|
||||
capabilities: enabled ? ["cards"] : [],
|
||||
schema: enabled
|
||||
? {
|
||||
@@ -505,6 +526,245 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.action === "read") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
const messageId =
|
||||
typeof ctx.params.messageId === "string" ? ctx.params.messageId.trim() : "";
|
||||
if (!to || !messageId) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: "text" as const, text: "Read requires a target (to) and messageId." },
|
||||
],
|
||||
details: { error: "Read requires a target (to) and messageId." },
|
||||
};
|
||||
}
|
||||
const { getMessageMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const message = await getMessageMSTeams({ cfg: ctx.cfg, to, messageId });
|
||||
return jsonActionResult({ ok: true, channel: "msteams", action: "read", message });
|
||||
}
|
||||
|
||||
if (ctx.action === "pin") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
const messageId =
|
||||
typeof ctx.params.messageId === "string" ? ctx.params.messageId.trim() : "";
|
||||
if (!to || !messageId) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: "text" as const, text: "Pin requires a target (to) and messageId." },
|
||||
],
|
||||
details: { error: "Pin requires a target (to) and messageId." },
|
||||
};
|
||||
}
|
||||
const { pinMessageMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await pinMessageMSTeams({ cfg: ctx.cfg, to, messageId });
|
||||
return jsonActionResult({ channel: "msteams", action: "pin", ...result });
|
||||
}
|
||||
|
||||
if (ctx.action === "unpin") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
// Accept pinnedMessageId (preferred) or messageId as fallback
|
||||
const pinnedMessageId =
|
||||
typeof ctx.params.pinnedMessageId === "string"
|
||||
? ctx.params.pinnedMessageId.trim()
|
||||
: typeof ctx.params.messageId === "string"
|
||||
? ctx.params.messageId.trim()
|
||||
: "";
|
||||
if (!to || !pinnedMessageId) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Unpin requires a target (to) and pinnedMessageId.",
|
||||
},
|
||||
],
|
||||
details: { error: "Unpin requires a target (to) and pinnedMessageId." },
|
||||
};
|
||||
}
|
||||
const { unpinMessageMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await unpinMessageMSTeams({ cfg: ctx.cfg, to, pinnedMessageId });
|
||||
return jsonActionResult({ channel: "msteams", action: "unpin", ...result });
|
||||
}
|
||||
|
||||
if (ctx.action === "list-pins") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
if (!to) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: "List-pins requires a target (to)." }],
|
||||
details: { error: "List-pins requires a target (to)." },
|
||||
};
|
||||
}
|
||||
const { listPinsMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await listPinsMSTeams({ cfg: ctx.cfg, to });
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "msteams",
|
||||
action: "list-pins",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "react") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
const messageId =
|
||||
typeof ctx.params.messageId === "string" ? ctx.params.messageId.trim() : "";
|
||||
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
|
||||
const remove = typeof ctx.params.remove === "boolean" ? ctx.params.remove : false;
|
||||
if (!to || !messageId) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "React requires a target (to) and messageId.",
|
||||
},
|
||||
],
|
||||
details: { error: "React requires a target (to) and messageId." },
|
||||
};
|
||||
}
|
||||
if (!emoji) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "React requires an emoji (reaction type). Valid types: like, heart, laugh, surprised, sad, angry.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
error: "React requires an emoji (reaction type).",
|
||||
validTypes: ["like", "heart", "laugh", "surprised", "sad", "angry"],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (remove) {
|
||||
const { unreactMessageMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await unreactMessageMSTeams({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
messageId,
|
||||
reactionType: emoji,
|
||||
});
|
||||
return jsonActionResult({
|
||||
channel: "msteams",
|
||||
action: "react",
|
||||
removed: true,
|
||||
reactionType: emoji,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
const { reactMessageMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await reactMessageMSTeams({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
messageId,
|
||||
reactionType: emoji,
|
||||
});
|
||||
return jsonActionResult({
|
||||
channel: "msteams",
|
||||
action: "react",
|
||||
reactionType: emoji,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "reactions") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
const messageId =
|
||||
typeof ctx.params.messageId === "string" ? ctx.params.messageId.trim() : "";
|
||||
if (!to || !messageId) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Reactions requires a target (to) and messageId.",
|
||||
},
|
||||
],
|
||||
details: { error: "Reactions requires a target (to) and messageId." },
|
||||
};
|
||||
}
|
||||
const { listReactionsMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await listReactionsMSTeams({ cfg: ctx.cfg, to, messageId });
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "msteams",
|
||||
action: "reactions",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "search") {
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
|
||||
const query = typeof ctx.params.query === "string" ? ctx.params.query.trim() : "";
|
||||
if (!to || !query) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Search requires a target (to) and query.",
|
||||
},
|
||||
],
|
||||
details: { error: "Search requires a target (to) and query." },
|
||||
};
|
||||
}
|
||||
const limit = typeof ctx.params.limit === "number" ? ctx.params.limit : undefined;
|
||||
const from = typeof ctx.params.from === "string" ? ctx.params.from.trim() : undefined;
|
||||
const { searchMessagesMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
query,
|
||||
from: from || undefined,
|
||||
limit,
|
||||
});
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "msteams",
|
||||
action: "search",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
// Return null to fall through to default handler
|
||||
return null as never;
|
||||
},
|
||||
|
||||
750
extensions/msteams/src/graph-messages.test.ts
Normal file
750
extensions/msteams/src/graph-messages.test.ts
Normal file
@@ -0,0 +1,750 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
getMessageMSTeams,
|
||||
listPinsMSTeams,
|
||||
listReactionsMSTeams,
|
||||
pinMessageMSTeams,
|
||||
reactMessageMSTeams,
|
||||
searchMessagesMSTeams,
|
||||
unpinMessageMSTeams,
|
||||
unreactMessageMSTeams,
|
||||
} from "./graph-messages.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
resolveGraphToken: vi.fn(),
|
||||
fetchGraphJson: vi.fn(),
|
||||
postGraphJson: vi.fn(),
|
||||
postGraphBetaJson: vi.fn(),
|
||||
deleteGraphRequest: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGraphToken: mockState.resolveGraphToken,
|
||||
fetchGraphJson: mockState.fetchGraphJson,
|
||||
postGraphJson: mockState.postGraphJson,
|
||||
postGraphBetaJson: mockState.postGraphBetaJson,
|
||||
deleteGraphRequest: mockState.deleteGraphRequest,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./conversation-store-fs.js", () => ({
|
||||
createMSTeamsConversationStoreFs: () => ({
|
||||
findByUserId: mockState.findByUserId,
|
||||
}),
|
||||
}));
|
||||
|
||||
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.findByUserId.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.findByUserId).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.findByUserId.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.findByUserId.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.findByUserId.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", 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: { id: "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" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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("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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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("rejects invalid reaction type", async () => {
|
||||
await expect(
|
||||
reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "thumbsup",
|
||||
}),
|
||||
).rejects.toThrow('Invalid reaction type "thumbsup"');
|
||||
});
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findByUserId.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.findByUserId).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("rejects invalid reaction type", async () => {
|
||||
await expect(
|
||||
unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "clap",
|
||||
}),
|
||||
).rejects.toThrow('Invalid reaction type "clap"');
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
count: 2,
|
||||
users: [
|
||||
{ id: "u1", displayName: "Alice" },
|
||||
{ id: "u2", displayName: "Bob" },
|
||||
],
|
||||
},
|
||||
{
|
||||
reactionType: "heart",
|
||||
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", 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMessagesMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("searches chat messages with query string", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "msg-1",
|
||||
body: { content: "Meeting notes from Monday" },
|
||||
from: { user: { id: "u1", displayName: "Alice" } },
|
||||
createdDateTime: "2026-03-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "meeting notes",
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "Meeting notes from Monday",
|
||||
from: { user: { id: "u1", displayName: "Alice" } },
|
||||
createdAt: "2026-03-25T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain(`/chats/${encodeURIComponent(CHAT_ID)}/messages?`);
|
||||
expect(calledPath).toContain("$search=");
|
||||
expect(calledPath).toContain("$top=25");
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain('$search="meeting notes"');
|
||||
});
|
||||
|
||||
it("searches channel messages", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "msg-2",
|
||||
body: { content: "Sprint review" },
|
||||
from: { user: { id: "u2", displayName: "Bob" } },
|
||||
createdDateTime: "2026-03-25T11:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
query: "sprint",
|
||||
});
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("/teams/team-id-1/channels/channel-id-1/messages?");
|
||||
});
|
||||
|
||||
it("applies limit parameter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=10");
|
||||
});
|
||||
|
||||
it("clamps limit to max 50", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=50");
|
||||
});
|
||||
|
||||
it("clamps limit to min 1", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=1");
|
||||
});
|
||||
|
||||
it("applies from filter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "budget",
|
||||
from: "Alice",
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$filter=");
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain("from/user/displayName eq 'Alice'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in from filter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
from: "O'Brien",
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain("O''Brien");
|
||||
});
|
||||
|
||||
it("strips double quotes from query to prevent injection", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: 'say "hello" world',
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain('$search="say hello world"');
|
||||
// No unbalanced/injected quotes
|
||||
expect(decoded).not.toContain('""');
|
||||
});
|
||||
|
||||
it("passes ConsistencyLevel: eventual header", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no messages match", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "nonexistent",
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-user-1",
|
||||
query: "hello",
|
||||
});
|
||||
|
||||
expect(mockState.findByUserId).toHaveBeenCalledWith("aad-user-1");
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain(
|
||||
`/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?`,
|
||||
);
|
||||
});
|
||||
});
|
||||
436
extensions/msteams/src/graph-messages.ts
Normal file
436
extensions/msteams/src/graph-messages.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import {
|
||||
type GraphResponse,
|
||||
deleteGraphRequest,
|
||||
escapeOData,
|
||||
fetchGraphJson,
|
||||
postGraphBetaJson,
|
||||
postGraphJson,
|
||||
resolveGraphToken,
|
||||
} from "./graph.js";
|
||||
|
||||
type GraphMessageBody = {
|
||||
content?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type GraphMessageFrom = {
|
||||
user?: { id?: string; displayName?: string };
|
||||
application?: { id?: string; displayName?: string };
|
||||
};
|
||||
|
||||
type GraphMessage = {
|
||||
id?: string;
|
||||
body?: GraphMessageBody;
|
||||
from?: GraphMessageFrom;
|
||||
createdDateTime?: string;
|
||||
};
|
||||
|
||||
type GraphPinnedMessage = {
|
||||
id?: string;
|
||||
message?: GraphMessage;
|
||||
};
|
||||
|
||||
type GraphPinnedMessagesResponse = {
|
||||
value?: GraphPinnedMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the Graph API path prefix for a conversation.
|
||||
* If `to` contains "/" it's a `teamId/channelId` (channel path),
|
||||
* otherwise it's a chat ID.
|
||||
*/
|
||||
/**
|
||||
* Strip common target prefixes (`conversation:`, `user:`) so raw
|
||||
* conversation IDs can be used directly in Graph paths.
|
||||
*/
|
||||
function stripTargetPrefix(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
return trimmed.slice("conversation:".length).trim();
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
return trimmed.slice("user:".length).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a target to a Graph-compatible conversation ID.
|
||||
* `user:<aadId>` targets are looked up in the conversation store to find the
|
||||
* actual `19:xxx@thread.*` chat ID that Graph API requires.
|
||||
* Conversation IDs and `teamId/channelId` pairs pass through unchanged.
|
||||
*/
|
||||
async function resolveGraphConversationId(to: string): Promise<string> {
|
||||
const trimmed = to.trim();
|
||||
const isUserTarget = /^user:/i.test(trimmed);
|
||||
const cleaned = stripTargetPrefix(trimmed);
|
||||
|
||||
// teamId/channelId or already a conversation ID (19:xxx) — use directly
|
||||
if (!isUserTarget) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// user:<aadId> — look up the conversation store for the real chat ID
|
||||
const store = createMSTeamsConversationStoreFs();
|
||||
const found = await store.findByUserId(cleaned);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`No conversation found for user:${cleaned}. ` +
|
||||
"The bot must receive a message from this user before Graph API operations work.",
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the cached Graph-native chat ID (19:xxx format) over the Bot Framework
|
||||
// conversation ID, which may be in a non-Graph format (a:xxx / 8:orgid:xxx) for
|
||||
// personal DMs. send-context.ts resolves and caches this on first send.
|
||||
if (found.reference.graphChatId) {
|
||||
return found.reference.graphChatId;
|
||||
}
|
||||
if (found.conversationId.startsWith("19:")) {
|
||||
return found.conversationId;
|
||||
}
|
||||
throw new Error(
|
||||
`Conversation for user:${cleaned} uses a Bot Framework ID (${found.conversationId}) ` +
|
||||
"that Graph API does not accept. Send a message to this user first so the Graph chat ID is cached.",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConversationPath(to: string): {
|
||||
kind: "chat" | "channel";
|
||||
basePath: string;
|
||||
chatId?: string;
|
||||
teamId?: string;
|
||||
channelId?: string;
|
||||
} {
|
||||
const cleaned = stripTargetPrefix(to);
|
||||
if (cleaned.includes("/")) {
|
||||
const [teamId, channelId] = cleaned.split("/", 2);
|
||||
return {
|
||||
kind: "channel",
|
||||
basePath: `/teams/${encodeURIComponent(teamId!)}/channels/${encodeURIComponent(channelId!)}`,
|
||||
teamId,
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "chat",
|
||||
basePath: `/chats/${encodeURIComponent(cleaned)}`,
|
||||
chatId: cleaned,
|
||||
};
|
||||
}
|
||||
|
||||
export type GetMessageMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export type GetMessageMSTeamsResult = {
|
||||
id: string;
|
||||
text: string | undefined;
|
||||
from: GraphMessageFrom | undefined;
|
||||
createdAt: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a single message by ID from a chat or channel via Graph API.
|
||||
*/
|
||||
export async function getMessageMSTeams(
|
||||
params: GetMessageMSTeamsParams,
|
||||
): Promise<GetMessageMSTeamsResult> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const { basePath } = resolveConversationPath(conversationId);
|
||||
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
|
||||
const msg = await fetchGraphJson<GraphMessage>({ token, path });
|
||||
return {
|
||||
id: msg.id ?? params.messageId,
|
||||
text: msg.body?.content,
|
||||
from: msg.from,
|
||||
createdAt: msg.createdDateTime,
|
||||
};
|
||||
}
|
||||
|
||||
export type PinMessageMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pin a message in a chat conversation via Graph API.
|
||||
* Channel pinning uses a different endpoint (beta) handled separately.
|
||||
*/
|
||||
export async function pinMessageMSTeams(
|
||||
params: PinMessageMSTeamsParams,
|
||||
): Promise<{ ok: true; pinnedMessageId?: string }> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
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<unknown>({
|
||||
token,
|
||||
path: `${conv.basePath}/pinnedMessages`,
|
||||
body: { message: { id: params.messageId } },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const result = await postGraphJson<{ id?: string }>({
|
||||
token,
|
||||
path: `${conv.basePath}/pinnedMessages`,
|
||||
body: { message: { id: params.messageId } },
|
||||
});
|
||||
return { ok: true, pinnedMessageId: result.id };
|
||||
}
|
||||
|
||||
export type UnpinMessageMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
/** The pinned-message resource ID returned by pin or list-pins (not the message ID). */
|
||||
pinnedMessageId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function unpinMessageMSTeams(
|
||||
params: UnpinMessageMSTeamsParams,
|
||||
): Promise<{ ok: true }> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const conv = resolveConversationPath(conversationId);
|
||||
const path = `${conv.basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}`;
|
||||
await deleteGraphRequest({ token, path });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type ListPinsMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type ListPinsMSTeamsResult = {
|
||||
pins: Array<{ id: string; pinnedMessageId: string; messageId?: string; text?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all pinned messages in a chat conversation via Graph API.
|
||||
*/
|
||||
export async function listPinsMSTeams(
|
||||
params: ListPinsMSTeamsParams,
|
||||
): Promise<ListPinsMSTeamsResult> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const conv = resolveConversationPath(conversationId);
|
||||
const path = `${conv.basePath}/pinnedMessages?$expand=message`;
|
||||
const res = await fetchGraphJson<GraphPinnedMessagesResponse>({ 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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TEAMS_REACTION_TYPES = [
|
||||
"like",
|
||||
"heart",
|
||||
"laugh",
|
||||
"surprised",
|
||||
"sad",
|
||||
"angry",
|
||||
] as const;
|
||||
export type TeamsReactionType = (typeof TEAMS_REACTION_TYPES)[number];
|
||||
|
||||
type GraphReaction = {
|
||||
reactionType?: string;
|
||||
user?: { id?: string; displayName?: string };
|
||||
createdDateTime?: string;
|
||||
};
|
||||
|
||||
type GraphMessageWithReactions = GraphMessage & {
|
||||
reactions?: GraphReaction[];
|
||||
};
|
||||
|
||||
export type ReactMessageMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
messageId: string;
|
||||
reactionType: string;
|
||||
};
|
||||
|
||||
export type ListReactionsMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export type ReactionSummary = {
|
||||
reactionType: string;
|
||||
count: number;
|
||||
users: Array<{ id: string; displayName?: string }>;
|
||||
};
|
||||
|
||||
export type ListReactionsMSTeamsResult = {
|
||||
reactions: ReactionSummary[];
|
||||
};
|
||||
|
||||
function validateReactionType(raw: string): TeamsReactionType {
|
||||
const normalized = raw.toLowerCase().trim();
|
||||
if (!TEAMS_REACTION_TYPES.includes(normalized as TeamsReactionType)) {
|
||||
throw new Error(
|
||||
`Invalid reaction type "${raw}". Valid types: ${TEAMS_REACTION_TYPES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return normalized as TeamsReactionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an emoji reaction to a message via Graph API (beta).
|
||||
*/
|
||||
export async function reactMessageMSTeams(
|
||||
params: ReactMessageMSTeamsParams,
|
||||
): Promise<{ ok: true }> {
|
||||
const reactionType = validateReactionType(params.reactionType);
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const { basePath } = resolveConversationPath(conversationId);
|
||||
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}/setReaction`;
|
||||
await postGraphBetaJson<unknown>({ token, path, body: { reactionType } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an emoji reaction from a message via Graph API (beta).
|
||||
*/
|
||||
export async function unreactMessageMSTeams(
|
||||
params: ReactMessageMSTeamsParams,
|
||||
): Promise<{ ok: true }> {
|
||||
const reactionType = validateReactionType(params.reactionType);
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const { basePath } = resolveConversationPath(conversationId);
|
||||
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}/unsetReaction`;
|
||||
await postGraphBetaJson<unknown>({ token, path, body: { reactionType } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* List reactions on a message, grouped by type.
|
||||
* Uses Graph v1.0 (reactions are included in the message resource).
|
||||
*/
|
||||
export async function listReactionsMSTeams(
|
||||
params: ListReactionsMSTeamsParams,
|
||||
): Promise<ListReactionsMSTeamsResult> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const { basePath } = resolveConversationPath(conversationId);
|
||||
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
|
||||
const msg = await fetchGraphJson<GraphMessageWithReactions>({ token, path });
|
||||
|
||||
const grouped = new Map<string, Array<{ id: string; displayName?: string }>>();
|
||||
for (const reaction of msg.reactions ?? []) {
|
||||
const type = reaction.reactionType ?? "unknown";
|
||||
if (!grouped.has(type)) {
|
||||
grouped.set(type, []);
|
||||
}
|
||||
if (reaction.user?.id) {
|
||||
grouped.get(type)!.push({
|
||||
id: reaction.user.id,
|
||||
displayName: reaction.user.displayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reactions: ReactionSummary[] = Array.from(grouped.entries()).map(([type, users]) => ({
|
||||
reactionType: type,
|
||||
count: users.length,
|
||||
users,
|
||||
}));
|
||||
|
||||
return { reactions };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SearchMessagesMSTeamsParams = {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
query: string;
|
||||
from?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type SearchMessagesMSTeamsResult = {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
text: string | undefined;
|
||||
from: GraphMessageFrom | undefined;
|
||||
createdAt: string | undefined;
|
||||
}>;
|
||||
};
|
||||
|
||||
const SEARCH_DEFAULT_LIMIT = 25;
|
||||
const SEARCH_MAX_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Search messages in a chat or channel by content via Graph API.
|
||||
* Uses `$search` for full-text body search and optional `$filter` for sender.
|
||||
*/
|
||||
export async function searchMessagesMSTeams(
|
||||
params: SearchMessagesMSTeamsParams,
|
||||
): Promise<SearchMessagesMSTeamsResult> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const conversationId = await resolveGraphConversationId(params.to);
|
||||
const { basePath } = resolveConversationPath(conversationId);
|
||||
|
||||
const rawLimit = params.limit ?? SEARCH_DEFAULT_LIMIT;
|
||||
const top = Number.isFinite(rawLimit)
|
||||
? Math.min(Math.max(Math.floor(rawLimit), 1), SEARCH_MAX_LIMIT)
|
||||
: SEARCH_DEFAULT_LIMIT;
|
||||
|
||||
// Strip double quotes from the query to prevent OData $search injection
|
||||
const sanitizedQuery = params.query.replace(/"/g, "");
|
||||
|
||||
// Build query string manually (not URLSearchParams) to preserve literal $
|
||||
// in OData parameter names, consistent with other Graph calls in this module.
|
||||
const parts = [`$search=${encodeURIComponent(`"${sanitizedQuery}"`)}`];
|
||||
parts.push(`$top=${top}`);
|
||||
if (params.from) {
|
||||
parts.push(
|
||||
`$filter=${encodeURIComponent(`from/user/displayName eq '${escapeOData(params.from)}'`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const path = `${basePath}/messages?${parts.join("&")}`;
|
||||
// ConsistencyLevel: eventual is required by Graph API for $search queries
|
||||
const res = await fetchGraphJson<GraphResponse<GraphMessage>>({
|
||||
token,
|
||||
path,
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
});
|
||||
|
||||
const messages = (res.value ?? []).map((msg) => ({
|
||||
id: msg.id ?? "",
|
||||
text: msg.body?.content,
|
||||
from: msg.from,
|
||||
createdAt: msg.createdDateTime,
|
||||
}));
|
||||
|
||||
return { messages };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MSTeamsConfig } from "../runtime-api.js";
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
@@ -76,6 +78,73 @@ export async function listTeamsByName(token: string, query: string): Promise<Gra
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
export async function postGraphJson<T>(params: {
|
||||
token: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body !== undefined ? JSON.stringify(params.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Graph POST ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
// Some Graph mutation endpoints return 204 No Content
|
||||
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function postGraphBetaJson<T>(params: {
|
||||
token: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${GRAPH_BETA}${params.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body !== undefined ? JSON.stringify(params.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Graph beta POST ${params.path} failed (${res.status}): ${text || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function deleteGraphRequest(params: { token: string; path: string }): Promise<void> {
|
||||
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Graph DELETE ${params.path} failed (${res.status}): ${text || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
|
||||
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
|
||||
|
||||
Reference in New Issue
Block a user