Skip to content

Commit 5b1c0fd

Browse files
sudie-codesclaude
andcommitted
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) <[email protected]>
1 parent cdc1013 commit 5b1c0fd

File tree

3 files changed

+82
-4
lines changed

3 files changed

+82
-4
lines changed

extensions/msteams/src/graph-messages.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mockState = vi.hoisted(() => ({
1212
fetchGraphJson: vi.fn(),
1313
postGraphJson: vi.fn(),
1414
deleteGraphRequest: vi.fn(),
15+
findByUserId: vi.fn(),
1516
}));
1617

1718
vi.mock("./graph.js", () => ({
@@ -21,6 +22,12 @@ vi.mock("./graph.js", () => ({
2122
deleteGraphRequest: mockState.deleteGraphRequest,
2223
}));
2324

25+
vi.mock("./conversation-store-fs.js", () => ({
26+
createMSTeamsConversationStoreFs: () => ({
27+
findByUserId: mockState.findByUserId,
28+
}),
29+
}));
30+
2431
const TOKEN = "test-graph-token";
2532
const CHAT_ID = "19:[email protected]";
2633
const CHANNEL_TO = "team-id-1/channel-id-1";
@@ -31,6 +38,42 @@ describe("getMessageMSTeams", () => {
3138
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
3239
});
3340

41+
it("resolves user: target to conversation ID via store", async () => {
42+
mockState.findByUserId.mockResolvedValue({
43+
conversationId: "19:[email protected]",
44+
reference: {},
45+
});
46+
mockState.fetchGraphJson.mockResolvedValue({
47+
id: "msg-1",
48+
body: { content: "From user DM" },
49+
createdDateTime: "2026-03-23T12:00:00Z",
50+
});
51+
52+
await getMessageMSTeams({
53+
cfg: {} as OpenClawConfig,
54+
to: "user:aad-object-id-123",
55+
messageId: "msg-1",
56+
});
57+
58+
expect(mockState.findByUserId).toHaveBeenCalledWith("aad-object-id-123");
59+
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
60+
token: TOKEN,
61+
path: `/chats/${encodeURIComponent("19:[email protected]")}/messages/msg-1`,
62+
});
63+
});
64+
65+
it("throws when user: target has no stored conversation", async () => {
66+
mockState.findByUserId.mockResolvedValue(null);
67+
68+
await expect(
69+
getMessageMSTeams({
70+
cfg: {} as OpenClawConfig,
71+
to: "user:unknown-user",
72+
messageId: "msg-1",
73+
}),
74+
).rejects.toThrow("No conversation found for user:unknown-user");
75+
});
76+
3477
it("strips conversation: prefix from target", async () => {
3578
mockState.fetchGraphJson.mockResolvedValue({
3679
id: "msg-1",

extensions/msteams/src/graph-messages.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenClawConfig } from "../runtime-api.js";
2+
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
23
import { deleteGraphRequest, fetchGraphJson, postGraphJson, resolveGraphToken } from "./graph.js";
34

45
type GraphMessageBody = {
@@ -47,6 +48,34 @@ function stripTargetPrefix(raw: string): string {
4748
return trimmed;
4849
}
4950

51+
/**
52+
* Resolve a target to a Graph-compatible conversation ID.
53+
* `user:<aadId>` targets are looked up in the conversation store to find the
54+
* actual `19:xxx@thread.*` chat ID that Graph API requires.
55+
* Conversation IDs and `teamId/channelId` pairs pass through unchanged.
56+
*/
57+
async function resolveGraphConversationId(to: string): Promise<string> {
58+
const trimmed = to.trim();
59+
const isUserTarget = /^user:/i.test(trimmed);
60+
const cleaned = stripTargetPrefix(trimmed);
61+
62+
// teamId/channelId or already a conversation ID (19:xxx) — use directly
63+
if (!isUserTarget) {
64+
return cleaned;
65+
}
66+
67+
// user:<aadId> — look up the conversation store for the real chat ID
68+
const store = createMSTeamsConversationStoreFs();
69+
const found = await store.findByUserId(cleaned);
70+
if (!found) {
71+
throw new Error(
72+
`No conversation found for user:${cleaned}. ` +
73+
"The bot must receive a message from this user before Graph API operations work.",
74+
);
75+
}
76+
return found.conversationId;
77+
}
78+
5079
function resolveConversationPath(to: string): {
5180
kind: "chat" | "channel";
5281
basePath: string;
@@ -91,7 +120,8 @@ export async function getMessageMSTeams(
91120
params: GetMessageMSTeamsParams,
92121
): Promise<GetMessageMSTeamsResult> {
93122
const token = await resolveGraphToken(params.cfg);
94-
const { basePath } = resolveConversationPath(params.to);
123+
const conversationId = await resolveGraphConversationId(params.to);
124+
const { basePath } = resolveConversationPath(conversationId);
95125
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
96126
const msg = await fetchGraphJson<GraphMessage>({ token, path });
97127
return {
@@ -116,7 +146,8 @@ export async function pinMessageMSTeams(
116146
params: PinMessageMSTeamsParams,
117147
): Promise<{ ok: true; pinnedMessageId?: string }> {
118148
const token = await resolveGraphToken(params.cfg);
119-
const conv = resolveConversationPath(params.to);
149+
const conversationId = await resolveGraphConversationId(params.to);
150+
const conv = resolveConversationPath(conversationId);
120151

121152
if (conv.kind === "channel") {
122153
// Graph v1.0 doesn't have channel pin — use the pinnedMessages pattern on chat
@@ -153,7 +184,8 @@ export async function unpinMessageMSTeams(
153184
params: UnpinMessageMSTeamsParams,
154185
): Promise<{ ok: true }> {
155186
const token = await resolveGraphToken(params.cfg);
156-
const conv = resolveConversationPath(params.to);
187+
const conversationId = await resolveGraphConversationId(params.to);
188+
const conv = resolveConversationPath(conversationId);
157189
const path = `${conv.basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}`;
158190
await deleteGraphRequest({ token, path });
159191
return { ok: true };
@@ -175,7 +207,8 @@ export async function listPinsMSTeams(
175207
params: ListPinsMSTeamsParams,
176208
): Promise<ListPinsMSTeamsResult> {
177209
const token = await resolveGraphToken(params.cfg);
178-
const conv = resolveConversationPath(params.to);
210+
const conversationId = await resolveGraphConversationId(params.to);
211+
const conv = resolveConversationPath(conversationId);
179212
const path = `${conv.basePath}/pinnedMessages?$expand=message`;
180213
const res = await fetchGraphJson<GraphPinnedMessagesResponse>({ token, path });
181214
const pins = (res.value ?? []).map((pin) => ({

extensions/msteams/src/graph.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export async function postGraphJson<T>(params: {
8484
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
8585
method: "POST",
8686
headers: {
87+
"User-Agent": buildUserAgent(),
8788
Authorization: `Bearer ${params.token}`,
8889
"Content-Type": "application/json",
8990
},
@@ -104,6 +105,7 @@ export async function deleteGraphRequest(params: { token: string; path: string }
104105
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
105106
method: "DELETE",
106107
headers: {
108+
"User-Agent": buildUserAgent(),
107109
Authorization: `Bearer ${params.token}`,
108110
},
109111
});

0 commit comments

Comments
 (0)