mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-13 10:11:20 +00:00
This commit is contained in:
@@ -36,6 +36,15 @@ export type StoredConversationReference = {
|
||||
graphChatId?: string;
|
||||
/** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */
|
||||
timezone?: string;
|
||||
/**
|
||||
* Thread root message ID for channel thread messages.
|
||||
* When a message arrives inside a Teams channel thread, the Bot Framework
|
||||
* sets `conversation.id` to `19:xxx@thread.tacv2;messageid=<rootId>` and/or
|
||||
* `replyToId` to the thread root activity ID. This field caches that root ID
|
||||
* so outbound replies can target the correct thread instead of landing as
|
||||
* top-level channel posts.
|
||||
*/
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsConversationStoreEntry = {
|
||||
|
||||
@@ -474,6 +474,147 @@ describe("msteams messenger", () => {
|
||||
expect(reference.activityId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses threadId instead of activityId for channel revoke fallback (#58030)", async () => {
|
||||
const proactiveSent: string[] = [];
|
||||
let capturedReference: unknown;
|
||||
|
||||
const channelRef: StoredConversationReference = {
|
||||
activityId: "current-message-id",
|
||||
user: { id: "user123", name: "User" },
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: {
|
||||
id: "19:abc@thread.tacv2",
|
||||
conversationType: "channel",
|
||||
},
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
// threadId is the thread root, which differs from activityId (current message)
|
||||
threadId: "thread-root-msg-id",
|
||||
};
|
||||
|
||||
const ctx = createRevokedThreadContext();
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: channelRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(proactiveSent).toEqual(["hello"]);
|
||||
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
|
||||
// Should use threadId (thread root), NOT activityId (current message)
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
|
||||
expect(ref.activityId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to activityId when threadId is not set (backward compat)", async () => {
|
||||
const proactiveSent: string[] = [];
|
||||
let capturedReference: unknown;
|
||||
|
||||
const channelRef: StoredConversationReference = {
|
||||
activityId: "legacy-activity-id",
|
||||
user: { id: "user123", name: "User" },
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: {
|
||||
id: "19:abc@thread.tacv2",
|
||||
conversationType: "channel",
|
||||
},
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
// No threadId — older stored references may not have it
|
||||
};
|
||||
|
||||
const ctx = createRevokedThreadContext();
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: channelRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(proactiveSent).toEqual(["hello"]);
|
||||
const ref = capturedReference as { conversation?: { id?: string } };
|
||||
// Falls back to activityId when threadId is missing
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
|
||||
});
|
||||
|
||||
it("does not add thread suffix for top-level replyStyle even with threadId set", async () => {
|
||||
let capturedReference: unknown;
|
||||
const sent: string[] = [];
|
||||
|
||||
const channelRef: StoredConversationReference = {
|
||||
activityId: "current-msg",
|
||||
user: { id: "user123", name: "User" },
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: {
|
||||
id: "19:abc@thread.tacv2",
|
||||
conversationType: "channel",
|
||||
},
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
threadId: "thread-root-msg-id",
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
capturedReference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: channelRef,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(sent).toEqual(["hello"]);
|
||||
const ref = capturedReference as { conversation?: { id?: string } };
|
||||
// Top-level sends should NOT include thread suffix
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
|
||||
});
|
||||
|
||||
it("retries top-level sends on transient (5xx)", async () => {
|
||||
const attempts: string[] = [];
|
||||
|
||||
|
||||
@@ -521,12 +521,16 @@ export async function sendMSTeamsMessages(params: {
|
||||
return messageIds;
|
||||
};
|
||||
|
||||
// Resolve the thread root message ID for channel thread routing.
|
||||
// `threadId` is the canonical thread root (set on inbound for channel threads);
|
||||
// fall back to `activityId` for backward compatibility with older stored refs.
|
||||
const resolvedThreadId = params.conversationRef.threadId ?? params.conversationRef.activityId;
|
||||
|
||||
if (params.replyStyle === "thread") {
|
||||
const ctx = params.context;
|
||||
if (!ctx) {
|
||||
throw new Error("Missing context for replyStyle=thread");
|
||||
}
|
||||
const threadActivityId = params.conversationRef.activityId;
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const result = await withRevokedProxyFallback({
|
||||
@@ -541,7 +545,7 @@ export async function sendMSTeamsMessages(params: {
|
||||
const remaining = messages.slice(idx);
|
||||
return {
|
||||
ids:
|
||||
remaining.length > 0 ? await sendProactively(remaining, idx, threadActivityId) : [],
|
||||
remaining.length > 0 ? await sendProactively(remaining, idx, resolvedThreadId) : [],
|
||||
fellBack: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -93,8 +93,10 @@ function buildStoredConversationReference(params: {
|
||||
conversationId: string;
|
||||
conversationType: string;
|
||||
teamId?: string;
|
||||
/** Thread root message ID for channel thread messages. */
|
||||
threadId?: string;
|
||||
}): StoredConversationReference {
|
||||
const { activity, conversationId, conversationType, teamId } = params;
|
||||
const { activity, conversationId, conversationType, teamId, threadId } = params;
|
||||
const from = activity.from;
|
||||
const conversation = activity.conversation;
|
||||
const agent = activity.recipient;
|
||||
@@ -116,6 +118,7 @@ function buildStoredConversationReference(params: {
|
||||
serviceUrl: activity.serviceUrl,
|
||||
locale: activity.locale,
|
||||
...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,11 +213,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
|
||||
const conversationType = conversation?.conversationType ?? "personal";
|
||||
const teamId = activity.channelData?.team?.id;
|
||||
// For channel thread messages, resolve the thread root message ID so outbound
|
||||
// replies land in the correct thread. The root ID comes from the `messageid=`
|
||||
// portion of conversation.id (preferred) or from activity.replyToId.
|
||||
const threadId =
|
||||
conversationType === "channel"
|
||||
? (conversationMessageId ?? activity.replyToId ?? undefined)
|
||||
: undefined;
|
||||
const conversationRef = buildStoredConversationReference({
|
||||
activity,
|
||||
conversationId,
|
||||
conversationType,
|
||||
teamId,
|
||||
threadId,
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
Reference in New Issue
Block a user