refactor(msteams): extract inbound activity facts

This commit is contained in:
Vincent Koc
2026-07-29 16:45:55 +08:00
parent ef5066b3cf
commit a1b0839a5c
3 changed files with 292 additions and 174 deletions
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { assembleMSTeamsInboundFacts, prepareMSTeamsDebounceEntry } from "./inbound-facts.js";
function context(activity: MSTeamsTurnContext["activity"]): MSTeamsTurnContext {
return { activity } as MSTeamsTurnContext;
}
describe("msteams inbound facts", () => {
it("prefers activity text, then HTML, then adaptive-card values", async () => {
const textEntry = await prepareMSTeamsDebounceEntry({
context: context({
type: "message",
text: " hello ",
attachments: [{ contentType: "text/html", content: "<p>ignored</p>" }],
value: { action: "ignored" },
}),
});
const htmlEntry = await prepareMSTeamsDebounceEntry({
context: context({
type: "message",
attachments: [{ contentType: "text/html", content: "<p>hello &amp; goodbye</p>" }],
}),
});
const valueEntry = await prepareMSTeamsDebounceEntry({
context: context({
type: "message",
value: { action: "approve", id: 7 },
}),
});
expect(textEntry.text).toBe("hello");
expect(htmlEntry.text).toBe("hello & goodbye");
expect(valueEntry.text).toContain("approve");
expect(valueEntry.text).toContain("7");
});
it("preserves raw Bot Framework IDs while normalizing routing facts", async () => {
const entry = await prepareMSTeamsDebounceEntry({
context: context({
type: "message",
id: "message-1",
text: "hello",
from: { id: "user-1", aadObjectId: "aad-1", name: "Alice" },
recipient: { id: "bot-1", name: "Bot" },
conversation: {
id: "19:channel@thread.tacv2;messageid=thread-root",
conversationType: "channel",
},
channelData: {
team: { id: "team-1" },
channel: { id: "channel-1" },
tenant: { id: "tenant-1" },
},
serviceUrl: "https://smba.trafficmanager.net/amer/",
}),
});
const facts = assembleMSTeamsInboundFacts({ entry, mediaMaxBytes: 1024 });
expect(facts.rawConversationId).toBe("19:channel@thread.tacv2;messageid=thread-root");
expect(facts.conversationId).toBe("19:channel@thread.tacv2");
expect(facts.conversationMessageId).toBe("thread-root");
expect(facts.threadId).toBe("thread-root");
expect(facts.conversationRef).toMatchObject({
tenantId: "tenant-1",
teamId: "team-1",
threadId: "thread-root",
conversation: {
id: "19:channel@thread.tacv2",
tenantId: "tenant-1",
},
});
});
});
@@ -0,0 +1,178 @@
// Msteams plugin module assembles stable inbound activity facts.
import { serializeMSTeamsAdaptiveCardActionValue } from "../adaptive-card-submit.js";
import {
resolveMSTeamsAdvertisedMedia,
summarizeMSTeamsHtmlAttachments,
type MSTeamsAttachmentLike,
} from "../attachments.js";
import { extractHtmlFromAttachment } from "../attachments/shared.js";
import { tryNormalizeBotFrameworkServiceUrl } from "../bot-framework-service-url.js";
import type { StoredConversationReference } from "../conversation-store.js";
import {
extractMSTeamsConversationMessageId,
extractMSTeamsQuoteInfo,
normalizeMSTeamsConversationId,
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "../inbound.js";
import type { MSTeamsIngressLifecycle } from "../msteams-ingress.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { wasMSTeamsMessageSentWithPersistence } from "../sent-message-cache.js";
function extractTextFromHtmlAttachments(attachments: MSTeamsAttachmentLike[]): string {
for (const attachment of attachments) {
const raw = extractHtmlFromAttachment(attachment);
if (!raw) {
continue;
}
const text = raw
.replace(/<at[^>]*>.*?<\/at>/gis, " ")
.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gis, "$2 $1")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/\s+/g, " ")
.trim();
if (text) {
return text;
}
}
return "";
}
export type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext;
rawText: string;
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
implicitMentionKinds: Array<"reply_to_bot">;
turnAdoptionLifecycle?: MSTeamsIngressLifecycle;
};
export async function prepareMSTeamsDebounceEntry(params: {
context: MSTeamsTurnContext;
turnAdoptionLifecycle?: MSTeamsIngressLifecycle;
}): Promise<MSTeamsDebounceEntry> {
const activity = params.context.activity;
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const rawText = activity.text?.trim() ?? "";
const htmlText = extractTextFromHtmlAttachments(attachments);
const valueText =
rawText || htmlText ? "" : serializeMSTeamsAdaptiveCardActionValue(activity.value);
const text = stripMSTeamsMentionTags(rawText || htmlText || valueText || "");
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
const replyToId = activity.replyToId ?? undefined;
const implicitMentionKinds: Array<"reply_to_bot"> =
conversationId &&
replyToId &&
(await wasMSTeamsMessageSentWithPersistence({ conversationId, messageId: replyToId }))
? ["reply_to_bot"]
: [];
return {
context: params.context,
rawText,
text,
attachments,
wasMentioned: wasMSTeamsBotMentioned(activity),
implicitMentionKinds,
turnAdoptionLifecycle: params.turnAdoptionLifecycle,
};
}
function buildStoredConversationReference(params: {
activity: MSTeamsTurnContext["activity"];
conversationId: string;
conversationType: string;
teamId?: string;
threadId?: string;
}): StoredConversationReference {
const { activity, conversationId, conversationType, teamId, threadId } = params;
const from = activity.from;
const conversation = activity.conversation;
const clientInfo = activity.entities?.find((entity) => entity.type === "clientInfo") as
| { timezone?: string }
| undefined;
// Proactive sends require the tenant and normalized regional service URL
// captured from the original Bot Framework activity.
const tenantId = activity.channelData?.tenant?.id ?? conversation?.tenantId;
const aadObjectId = from?.aadObjectId;
const serviceUrl = tryNormalizeBotFrameworkServiceUrl(activity.serviceUrl);
return {
activityId: activity.id,
user: from ? { id: from.id, name: from.name, aadObjectId: from.aadObjectId } : undefined,
agent: activity.recipient,
conversation: {
id: conversationId,
conversationType,
tenantId,
},
...(tenantId ? { tenantId } : {}),
...(aadObjectId ? { aadObjectId } : {}),
teamId,
channelId: activity.channelId,
...(serviceUrl ? { serviceUrl } : {}),
locale: activity.locale,
...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}),
...(threadId ? { threadId } : {}),
};
}
export function assembleMSTeamsInboundFacts(params: {
entry: MSTeamsDebounceEntry;
mediaMaxBytes: number;
}) {
const { entry, mediaMaxBytes } = params;
const activity = entry.context.activity;
const conversation = activity.conversation;
const rawConversationId = conversation?.id ?? "";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const isChannel = conversationType === "channel";
const teamId = activity.channelData?.team?.id;
const threadId = isChannel
? (conversationMessageId ?? activity.replyToId ?? undefined)
: undefined;
const advertisedMedia = resolveMSTeamsAdvertisedMedia(entry.attachments, {
maxInlineBytes: mediaMaxBytes,
maxInlineTotalBytes: mediaMaxBytes,
});
return {
...entry,
activity,
from: activity.from,
conversation,
rawBody: entry.text,
advertisedMedia,
quoteInfo: extractMSTeamsQuoteInfo(entry.attachments),
attachmentTypes: entry.attachments
.map((attachment) =>
typeof attachment.contentType === "string" ? attachment.contentType : undefined,
)
.filter(Boolean)
.slice(0, 3),
htmlSummary: summarizeMSTeamsHtmlAttachments(entry.attachments),
rawConversationId,
conversationId,
conversationMessageId,
conversationType,
isChannel,
teamId,
graphChannelId: activity.channelData?.channel?.id?.trim() || conversationId,
threadId,
conversationRef: buildStoredConversationReference({
activity,
conversationId,
conversationType,
teamId,
threadId,
}),
};
}
@@ -25,15 +25,6 @@ import {
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { serializeMSTeamsAdaptiveCardActionValue } from "../adaptive-card-submit.js";
import {
resolveMSTeamsAdvertisedMedia,
summarizeMSTeamsHtmlAttachments,
type MSTeamsAttachmentLike,
} from "../attachments.js";
import { extractHtmlFromAttachment } from "../attachments/shared.js";
import { tryNormalizeBotFrameworkServiceUrl } from "../bot-framework-service-url.js";
import type { StoredConversationReference } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import {
fetchChannelMessage,
@@ -42,15 +33,17 @@ import {
formatThreadContext,
type GraphThreadMessage,
} from "../graph-thread.js";
import {
extractMSTeamsConversationMessageId,
extractMSTeamsQuoteInfo,
normalizeMSTeamsConversationId,
parseMSTeamsActivityTimestamp,
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "../inbound.js";
import { normalizeMSTeamsConversationId, parseMSTeamsActivityTimestamp } from "../inbound.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
import type { MSTeamsIngressLifecycle } from "../msteams-ingress.js";
import { resolveMSTeamsAllowlistMatch, resolveMSTeamsReplyPolicy } from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { createMSTeamsInboundDeadline, withMSTeamsRequestDeadline } from "../request-timeout.js";
import { getMSTeamsRuntime } from "../runtime.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { recordMSTeamsSentMessage } from "../sent-message-cache.js";
import { resolveTeamGroupId } from "../team-identity.js";
import {
fetchParentMessageCached,
formatParentContextEvent,
@@ -58,43 +51,12 @@ import {
shouldInjectParentContext,
summarizeParentMessage,
} from "../thread-parent-context.js";
function extractTextFromHtmlAttachments(attachments: MSTeamsAttachmentLike[]): string {
for (const attachment of attachments) {
const raw = extractHtmlFromAttachment(attachment);
if (!raw) {
continue;
}
const text = raw
.replace(/<at[^>]*>.*?<\/at>/gis, " ")
.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gis, "$2 $1")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/\s+/g, " ")
.trim();
if (text) {
return text;
}
}
return "";
}
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
import type { MSTeamsIngressLifecycle } from "../msteams-ingress.js";
import { resolveMSTeamsAllowlistMatch, resolveMSTeamsReplyPolicy } from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { getMSTeamsRuntime } from "../runtime.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import {
recordMSTeamsSentMessage,
wasMSTeamsMessageSentWithPersistence,
} from "../sent-message-cache.js";
import { resolveTeamGroupId } from "../team-identity.js";
import { resolveMSTeamsSenderAccess } from "./access.js";
import {
assembleMSTeamsInboundFacts,
prepareMSTeamsDebounceEntry,
type MSTeamsDebounceEntry,
} from "./inbound-facts.js";
import {
resolveMSTeamsInboundMedia,
resolveMSTeamsInboundMediaBody,
@@ -135,49 +97,6 @@ function formatMSTeamsSenderReason(params: {
}
}
function buildStoredConversationReference(params: {
activity: MSTeamsTurnContext["activity"];
conversationId: string;
conversationType: string;
teamId?: string;
/** Thread root message ID for channel thread messages. */
threadId?: string;
}): StoredConversationReference {
const { activity, conversationId, conversationType, teamId, threadId } = params;
const from = activity.from;
const conversation = activity.conversation;
const agent = activity.recipient;
const clientInfo = activity.entities?.find((e) => e.type === "clientInfo") as
| { timezone?: string }
| undefined;
// Bot Framework requires `tenantId` on outbound proactive activities so the
// connector can route them to the correct Azure AD tenant; missing it causes
// HTTP 403. Channel activities often leave `conversation.tenantId` unset, so
// prefer the canonical `channelData.tenant.id` source when available.
const channelDataTenantId = activity.channelData?.tenant?.id;
const tenantId = channelDataTenantId ?? conversation?.tenantId;
const aadObjectId = from?.aadObjectId;
const serviceUrl = tryNormalizeBotFrameworkServiceUrl(activity.serviceUrl);
return {
activityId: activity.id,
user: from ? { id: from.id, name: from.name, aadObjectId: from.aadObjectId } : undefined,
agent,
conversation: {
id: conversationId,
conversationType,
tenantId,
},
...(tenantId ? { tenantId } : {}),
...(aadObjectId ? { aadObjectId } : {}),
teamId,
channelId: activity.channelId,
...(serviceUrl ? { serviceUrl } : {}),
locale: activity.locale,
...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}),
...(threadId ? { threadId } : {}),
};
}
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const {
cfg,
@@ -214,41 +133,34 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
channel: "msteams",
});
type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext;
rawText: string;
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
implicitMentionKinds: Array<"reply_to_bot">;
turnAdoptionLifecycle?: MSTeamsIngressLifecycle;
};
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
const context = params.context;
const activity = context.activity;
const rawText = params.rawText;
const text = params.text;
const attachments = params.attachments;
const advertisedMedia = resolveMSTeamsAdvertisedMedia(attachments, {
maxInlineBytes: mediaMaxBytes,
maxInlineTotalBytes: mediaMaxBytes,
});
const rawBody = text;
const facts = assembleMSTeamsInboundFacts({ entry: params, mediaMaxBytes });
const {
context,
activity,
rawText,
text,
attachments,
advertisedMedia,
rawBody,
quoteInfo,
from,
conversation,
attachmentTypes,
htmlSummary,
conversationId,
conversationMessageId,
conversationType,
isChannel,
teamId,
graphChannelId,
conversationRef,
} = facts;
const historyBody = [text, formatMediaPlaceholderText(advertisedMedia)]
.filter(Boolean)
.join("\n");
const quoteInfo = extractMSTeamsQuoteInfo(attachments);
let quoteSenderId: string | undefined;
let quoteSenderName: string | undefined;
const from = activity.from;
const conversation = activity.conversation;
const attachmentTypes = attachments
.map((att) => (typeof att.contentType === "string" ? att.contentType : undefined))
.filter(Boolean)
.slice(0, 3);
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
log.info("received message", {
rawText: truncateUtf16Safe(rawText, 50),
@@ -267,28 +179,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
return;
}
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key.
const rawConversationId = conversation?.id ?? "";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const teamId = activity.channelData?.team?.id;
const graphChannelId = activity.channelData?.channel?.id?.trim() || conversationId;
// 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 allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "msteams",
@@ -314,8 +204,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const commandAuthorized = commandAccess.requested ? commandAccess.authorized : undefined;
const effectiveDmAllowFrom = senderAccess.effectiveAllowFrom;
const effectiveGroupAllowFrom = senderAccess.effectiveGroupAllowFrom;
const isChannel = conversationType === "channel";
if (isDirectMessage && msteamsCfg && senderAccess.decision !== "allow") {
if (senderAccess.reasonCode === "dm_policy_disabled") {
log.info("dropping dm (dms disabled)", {
@@ -1112,34 +1000,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
context: MSTeamsTurnContext,
turnAdoptionLifecycle?: MSTeamsIngressLifecycle,
) {
const activity = context.activity;
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const rawText = activity.text?.trim() ?? "";
const htmlText = extractTextFromHtmlAttachments(attachments);
const valueText =
rawText || htmlText ? "" : serializeMSTeamsAdaptiveCardActionValue(activity.value);
const text = stripMSTeamsMentionTags(rawText || htmlText || valueText || "");
const wasMentioned = wasMSTeamsBotMentioned(activity);
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
const replyToId = activity.replyToId ?? undefined;
const implicitMentionKinds: Array<"reply_to_bot"> =
conversationId &&
replyToId &&
(await wasMSTeamsMessageSentWithPersistence({ conversationId, messageId: replyToId }))
? ["reply_to_bot"]
: [];
await inboundDebouncer.enqueue({
const entry = await prepareMSTeamsDebounceEntry({
context,
rawText,
text,
attachments,
wasMentioned,
implicitMentionKinds,
turnAdoptionLifecycle,
});
await inboundDebouncer.enqueue(entry);
if (turnAdoptionLifecycle) {
// Keep the durable claim held across the debounce window. The merged
// flush completes it only when the reply lane adopts (or terminally skips).