mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 21:50:45 +00:00
* 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.
226 lines
7.5 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|