Files
openclaw/extensions/msteams/src/conversation-store.shared.test.ts
Pengfei Ni 78389b1f02 fix(msteams): resolve Graph chat ID for personal DM media downloads (#62219) (#63063)
* fix(msteams): resolve Graph chat ID for personal DM media downloads (#62219)

Bot Framework personal DM conversation IDs use an opaque `a:...` format
that the Graph `/chats/{chatId}/messages` endpoint rejects as "Invalid
ThreadId". When the direct Bot Framework attachment download fails and
the code falls back to the Graph API path, inbound media (images, files)
is silently dropped.

Resolve the real Graph chat ID via `resolveGraphChatId()` before
constructing Graph message URLs, with conversation-store caching so
subsequent messages skip the API lookup.

* fix(msteams): preserve graphChatId across conversation store upserts

mergeStoredConversationReference only preserved timezone from the
existing entry — graphChatId was silently overwritten on every
activity-triggered upsert, defeating the cache and causing repeated
Graph API lookups on every DM turn.

Mirror the existing timezone guard so graphChatId survives upserts
that don't carry it.
2026-04-09 22:57:02 -05:00

226 lines
7.5 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { createMSTeamsConversationStoreMemory } from "./conversation-store-memory.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-runtime.js";
type StoreFactory = {
name: string;
createStore: () => Promise<MSTeamsConversationStore>;
};
const storeFactories: StoreFactory[] = [
{
name: "fs",
createStore: async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
return createMSTeamsConversationStoreFs({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
ttlMs: 60_000,
});
},
},
{
name: "memory",
createStore: async () => createMSTeamsConversationStoreMemory(),
},
];
describe.each(storeFactories)("msteams conversation store ($name)", ({ createStore }) => {
beforeEach(() => {
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("normalizes conversation ids consistently", async () => {
const store = await createStore();
await store.upsert("conv-norm;messageid=123", {
conversation: { id: "conv-norm" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
await expect(store.get("conv-norm")).resolves.toEqual(
expect.objectContaining({
conversation: { id: "conv-norm" },
}),
);
await expect(store.remove("conv-norm")).resolves.toBe(true);
await expect(store.get("conv-norm;messageid=123")).resolves.toBeNull();
});
it("upserts, lists, removes, and resolves users by both AAD and Bot Framework ids", async () => {
const store = await createStore();
await store.upsert("conv-a", {
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
});
await store.upsert("conv-b", {
conversation: { id: "conv-b" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
});
await expect(store.get("conv-a")).resolves.toEqual({
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
lastSeenAt: expect.any(String),
});
await expect(store.list()).resolves.toEqual([
{
conversationId: "conv-a",
reference: {
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
lastSeenAt: expect.any(String),
},
},
{
conversationId: "conv-b",
reference: {
conversation: { id: "conv-b" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
lastSeenAt: expect.any(String),
},
},
]);
await expect(store.findPreferredDmByUserId(" aad-b ")).resolves.toEqual({
conversationId: "conv-b",
reference: {
conversation: { id: "conv-b" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
lastSeenAt: expect.any(String),
},
});
await expect(store.findPreferredDmByUserId("user-a")).resolves.toEqual({
conversationId: "conv-a",
reference: {
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
lastSeenAt: expect.any(String),
},
});
await expect(store.findByUserId("user-a")).resolves.toEqual(
await store.findPreferredDmByUserId("user-a"),
);
await expect(store.findPreferredDmByUserId(" ")).resolves.toBeNull();
await expect(store.remove("conv-a")).resolves.toBe(true);
await expect(store.get("conv-a")).resolves.toBeNull();
await expect(store.remove("missing")).resolves.toBe(false);
});
it("preserves existing timezone when upsert omits timezone", async () => {
const store = await createStore();
await store.upsert("conv-tz", {
conversation: { id: "conv-tz" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
timezone: "Europe/London",
});
await store.upsert("conv-tz", {
conversation: { id: "conv-tz" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
await expect(store.get("conv-tz")).resolves.toMatchObject({
timezone: "Europe/London",
});
});
it("preserves graphChatId across upserts that omit it", async () => {
const store = await createStore();
await store.upsert("conv-graph", {
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
});
// Second upsert without graphChatId (normal activity-based upsert)
await store.upsert("conv-graph", {
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
await expect(store.get("conv-graph")).resolves.toMatchObject({
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
});
});
it("prefers the freshest personal conversation for repeated upserts of the same user", async () => {
const store = await createStore();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
await store.upsert("dm-old", {
conversation: { id: "dm-old", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-old", aadObjectId: "aad-shared", name: "Old DM" },
});
vi.setSystemTime(new Date("2026-03-25T20:30:00.000Z"));
await store.upsert("group-shared", {
conversation: { id: "group-shared", conversationType: "groupChat" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-group", aadObjectId: "aad-shared", name: "Group" },
});
vi.setSystemTime(new Date("2026-03-25T21:00:00.000Z"));
await store.upsert("dm-new", {
conversation: { id: "dm-new", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-new", aadObjectId: "aad-shared", name: "New DM" },
});
await expect(store.findPreferredDmByUserId("aad-shared")).resolves.toEqual({
conversationId: "dm-new",
reference: {
conversation: { id: "dm-new", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-new", aadObjectId: "aad-shared", name: "New DM" },
lastSeenAt: "2026-03-25T21:00:00.000Z",
},
});
} finally {
vi.useRealTimers();
}
});
});