fix(msteams): improve graph user and token parsing

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:19 +00:00
parent e80c66a571
commit 8c1afc4b63
9 changed files with 145 additions and 85 deletions

View File

@@ -1,11 +1,8 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
import { searchGraphUsers } from "./graph-users.js";
import {
escapeOData,
fetchGraphJson,
type GraphChannel,
type GraphGroup,
type GraphResponse,
type GraphUser,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
@@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: {
const token = await resolveGraphToken(params.cfg);
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
let users: GraphUser[] = [];
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
users = res.value ?? [];
}
const users = await searchGraphUsers({ token, query, top: limit });
return users
.map((user) => {

View File

@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { searchGraphUsers } from "./graph-users.js";
import { fetchGraphJson } from "./graph.js";
vi.mock("./graph.js", () => ({
escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")),
fetchGraphJson: vi.fn(),
}));
describe("searchGraphUsers", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
});
it("returns empty array for blank queries", async () => {
await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]);
expect(fetchGraphJson).not.toHaveBeenCalled();
});
it("uses exact mail/upn filter lookup for email-like queries", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
value: [{ id: "user-1", displayName: "User One" }],
} as never);
const result = await searchGraphUsers({
token: "token-2",
query: "alice.o'hara@example.com",
});
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "token-2",
path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
});
expect(result).toEqual([{ id: "user-1", displayName: "User One" }]);
});
it("uses displayName search with eventual consistency and custom top", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
value: [{ id: "user-2", displayName: "Bob" }],
} as never);
const result = await searchGraphUsers({
token: "token-3",
query: "bob",
top: 25,
});
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "token-3",
path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
headers: { ConsistencyLevel: "eventual" },
});
expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]);
});
it("falls back to default top and empty value handling", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]);
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "token-4",
path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
headers: { ConsistencyLevel: "eventual" },
});
});
});

View File

@@ -0,0 +1,29 @@
import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js";
export async function searchGraphUsers(params: {
token: string;
query: string;
top?: number;
}): Promise<GraphUser[]> {
const query = params.query.trim();
if (!query) {
return [];
}
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token: params.token, path });
return res.value ?? [];
}
const top = typeof params.top === "number" && params.top > 0 ? params.top : 10;
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token: params.token,
path,
headers: { ConsistencyLevel: "eventual" },
});
return res.value ?? [];
}

View File

@@ -1,6 +1,7 @@
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
import { GRAPH_ROOT } from "./attachments/shared.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type GraphUser = {
@@ -22,18 +23,6 @@ export type GraphChannel = {
export type GraphResponse<T> = { value?: T[] };
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
export function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}

View File

@@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: {
}
};
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
@@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: {
messageIds.push(extractMessageId(response) ?? "unknown");
}
return messageIds;
};
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
return await sendMessagesInContext(ctx);
}
const baseRef = buildConversationReference(params.conversationRef);
@@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: {
const messageIds: string[] = [];
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity(
await buildActivity(
message,
params.conversationRef,
params.tokenProvider,
params.sharePointSiteId,
params.mediaMaxBytes,
),
),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
}
messageIds.push(...(await sendMessagesInContext(ctx)));
});
return messageIds;
}

View File

@@ -1,6 +1,7 @@
import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
import { formatUnknownError } from "./errors.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type ProbeMSTeamsResult = BaseProbeResult<string> & {
@@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult<string> & {
};
};
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2) {

View File

@@ -1,8 +1,5 @@
import { searchGraphUsers } from "./graph-users.js";
import {
escapeOData,
fetchGraphJson,
type GraphResponse,
type GraphUser,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
@@ -182,22 +179,7 @@ export async function resolveMSTeamsUserAllowlist(params: {
results.push({ input, resolved: true, id: query });
continue;
}
let users: GraphUser[] = [];
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
users = res.value ?? [];
}
const users = await searchGraphUsers({ token, query, top: 10 });
const match = users[0];
if (!match?.id) {
results.push({ input, resolved: false });

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { readAccessToken } from "./token-response.js";
describe("readAccessToken", () => {
it("returns raw string token values", () => {
expect(readAccessToken("abc")).toBe("abc");
});
it("returns accessToken from object value", () => {
expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token");
});
it("returns token fallback from object value", () => {
expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token");
});
it("returns null for unsupported values", () => {
expect(readAccessToken({ accessToken: 123 })).toBeNull();
expect(readAccessToken({ token: false })).toBeNull();
expect(readAccessToken(null)).toBeNull();
expect(readAccessToken(undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,11 @@
export function readAccessToken(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}