Merge pull request #115823 from openclaw/refactor/msteams-message-handler-decomposition

refactor(msteams): decompose inbound message handler
This commit is contained in:
Vincent Koc
2026-07-29 21:57:31 +08:00
8 changed files with 1244 additions and 878 deletions

View File

@@ -182,7 +182,6 @@ extensions/migrate-hermes/config.test.ts
extensions/migrate-hermes/secrets.test.ts
extensions/msteams/src/channel.actions.test.ts
extensions/msteams/src/channel.ts
extensions/msteams/src/monitor-handler/message-handler.ts
extensions/mxc/test/mxc-backend.test.ts
extensions/oc-path/src/oc-path/find.ts
extensions/oc-path/src/oc-path/universal.ts

View File

@@ -1,4 +1,6 @@
// Msteams plugin module implements access behavior.
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import {
channelIngressRoutes,
resolveStableChannelMessageIngress,
@@ -12,8 +14,12 @@ import {
resolveDefaultGroupPolicy,
type OpenClawConfig,
} from "../../runtime-api.js";
import type { StoredConversationReference } from "../conversation-store.js";
import type { MSTeamsConversationStore } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import { normalizeMSTeamsConversationId } from "../inbound.js";
import { resolveMSTeamsRouteConfig } from "../policy.js";
import type { MSTeamsMonitorLogger } from "../monitor-types.js";
import { resolveMSTeamsAllowlistMatch, resolveMSTeamsRouteConfig } from "../policy.js";
import { looksLikeMSTeamsConversationId } from "../resolve-allowlist.js";
import { getMSTeamsRuntime } from "../runtime.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
@@ -70,6 +76,38 @@ function normalizeAllowlistConversationId(value?: string | null): string | null
return trimmed ? normalizeMSTeamsConversationId(trimmed) : null;
}
function formatMSTeamsSenderReason(params: {
reasonCode: string;
dmPolicy?: string;
groupPolicy?: string;
}): string {
switch (params.reasonCode) {
case "dm_policy_open":
return "dmPolicy=open";
case "dm_policy_disabled":
return "dmPolicy=disabled";
case "dm_policy_pairing_required":
return "dmPolicy=pairing (not allowlisted)";
case "dm_policy_allowlisted":
return `dmPolicy=${params.dmPolicy ?? "allowlist"} (allowlisted)`;
case "dm_policy_not_allowlisted":
return `dmPolicy=${params.dmPolicy ?? "allowlist"} (not allowlisted)`;
case "group_policy_disabled":
return "groupPolicy=disabled";
case "group_policy_empty_allowlist":
case "route_sender_empty":
return "groupPolicy=allowlist (empty allowlist)";
case "group_policy_not_allowlisted":
return "groupPolicy=allowlist (not allowlisted)";
case "group_policy_open":
return "groupPolicy=open";
case "group_policy_allowed":
return `groupPolicy=${params.groupPolicy ?? "allowlist"}`;
default:
return params.reasonCode;
}
}
export async function resolveMSTeamsSenderAccess(params: {
cfg: OpenClawConfig;
activity: MSTeamsTurnContext["activity"];
@@ -169,3 +207,186 @@ export async function resolveMSTeamsSenderAccess(params: {
groupPolicy,
};
}
export async function admitMSTeamsMessage(params: {
cfg: OpenClawConfig;
activity: MSTeamsTurnContext["activity"];
text: string;
conversationId: string;
conversationRef: StoredConversationReference;
isChannel: boolean;
conversationStore: MSTeamsConversationStore;
log: MSTeamsMonitorLogger;
logVerboseMessage: (message: string) => void;
}) {
const core = getMSTeamsRuntime();
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: params.cfg,
surface: "msteams",
});
const isControlCommand =
allowTextCommands && core.channel.commands.isControlCommandMessage(params.text, params.cfg);
const access = await resolveMSTeamsSenderAccess({
cfg: params.cfg,
activity: params.activity,
hasControlCommand: isControlCommand,
});
const {
dmPolicy,
senderId,
senderName,
pairing,
isDirectMessage,
channelGate,
senderAccess,
commandAccess,
allowNameMatching,
groupPolicy,
msteamsCfg,
} = access;
const effectiveDmAllowFrom = senderAccess.effectiveAllowFrom;
const effectiveGroupAllowFrom = senderAccess.effectiveGroupAllowFrom;
if (isDirectMessage && msteamsCfg && senderAccess.decision !== "allow") {
if (senderAccess.reasonCode === "dm_policy_disabled") {
params.log.info("dropping dm (dms disabled)", {
sender: senderId,
label: senderName,
});
params.log.debug?.("dropping dm (dms disabled)");
return null;
}
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId,
senderName,
allowNameMatching,
});
if (senderAccess.decision === "pairing") {
params.conversationStore
.upsert(params.conversationId, params.conversationRef)
.catch((err: unknown) => {
params.log.debug?.("failed to save conversation reference", {
error: formatUnknownError(err),
});
});
const request = await pairing.upsertPairingRequest({
id: senderId,
meta: { name: senderName },
});
if (request) {
params.log.info("msteams pairing request created", {
sender: senderId,
label: senderName,
});
}
}
params.log.debug?.("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
params.log.info("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
dmPolicy,
reason: formatMSTeamsSenderReason({
reasonCode: senderAccess.reasonCode,
dmPolicy,
groupPolicy,
}),
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return null;
}
if (!isDirectMessage && msteamsCfg) {
if (channelGate.allowlistConfigured && !channelGate.allowed) {
params.log.info("dropping group message (not in team/channel allowlist)", {
conversationId: params.conversationId,
teamKey: channelGate.teamKey ?? "none",
channelKey: channelGate.channelKey ?? "none",
channelMatchKey: channelGate.channelMatchKey ?? "none",
channelMatchSource: channelGate.channelMatchSource ?? "none",
});
params.log.debug?.("dropping group message (not in team/channel allowlist)", {
conversationId: params.conversationId,
teamKey: channelGate.teamKey ?? "none",
channelKey: channelGate.channelKey ?? "none",
channelMatchKey: channelGate.channelMatchKey ?? "none",
channelMatchSource: channelGate.channelMatchSource ?? "none",
});
return null;
}
if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_disabled") {
params.log.info("dropping group message (groupPolicy: disabled)", {
conversationId: params.conversationId,
});
params.log.debug?.("dropping group message (groupPolicy: disabled)", {
conversationId: params.conversationId,
});
return null;
}
if (
!senderAccess.allowed &&
(senderAccess.reasonCode === "group_policy_empty_allowlist" ||
senderAccess.reasonCode === "route_sender_empty")
) {
params.log.info("dropping group message (groupPolicy: allowlist, no allowlist)", {
conversationId: params.conversationId,
});
params.log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
conversationId: params.conversationId,
});
return null;
}
if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_not_allowlisted") {
const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
allowNameMatching,
});
params.log.debug?.("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
params.log.info("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
});
return null;
}
}
if (commandAccess.shouldBlockControlCommand) {
logInboundDrop({
log: params.logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
return null;
}
params.conversationStore
.upsert(params.conversationId, params.conversationRef)
.catch((err: unknown) => {
params.log.debug?.("failed to save conversation reference", {
error: formatUnknownError(err),
});
});
return {
...access,
allowTextCommands,
isControlCommand,
commandAuthorized: commandAccess.requested ? commandAccess.authorized : undefined,
effectiveDmAllowFrom,
effectiveGroupAllowFrom,
isChannel: params.isChannel,
};
}

View File

@@ -0,0 +1,101 @@
// Msteams plugin module materializes inbound media and agent content.
import type { MSTeamsHtmlAttachmentSummary, MSTeamsInboundMedia } from "../attachments.js";
import { formatUnknownError } from "../errors.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
import type { MSTeamsRequestDeadline } from "../request-timeout.js";
import { withMSTeamsRequestDeadline } from "../request-timeout.js";
import type { MSTeamsDebounceEntry } from "./inbound-facts.js";
import {
mergeMSTeamsMediaFacts,
resolveMSTeamsInboundMedia,
resolveMSTeamsInboundMediaBody,
shouldAttemptMSTeamsGraphMediaFallback,
} from "./inbound-media.js";
export async function prepareMSTeamsInboundContent(params: {
entry: MSTeamsDebounceEntry;
rawBody: string;
advertisedMedia: MSTeamsInboundMedia[];
htmlSummary?: MSTeamsHtmlAttachmentSummary;
conversationType: string;
conversationId: string;
conversationMessageId?: string;
teamAadGroupId?: string;
resolveTeamAadGroupId: () => Promise<string | undefined>;
mediaMaxBytes: number;
tokenProvider: MSTeamsMessageHandlerDeps["tokenProvider"];
mediaAllowHosts?: Parameters<typeof resolveMSTeamsInboundMedia>[0]["allowHosts"];
mediaAuthAllowHosts?: Parameters<typeof resolveMSTeamsInboundMedia>[0]["authAllowHosts"];
graphMediaFallback?: boolean;
deadline: MSTeamsRequestDeadline;
log: MSTeamsMessageHandlerDeps["log"];
}) {
const activity = params.entry.context.activity;
const mayRecoverGraphMedia =
Boolean(params.htmlSummary?.attachmentIds.length) ||
shouldAttemptMSTeamsGraphMediaFallback({
conversationType: params.conversationType,
htmlSummary: params.htmlSummary,
graphMediaFallback: params.graphMediaFallback,
});
if (!params.rawBody && params.advertisedMedia.length === 0 && !mayRecoverGraphMedia) {
params.log.debug?.("skipping empty message after stripping mentions");
return null;
}
let mediaList = [] as Awaited<ReturnType<typeof resolveMSTeamsInboundMedia>>;
try {
mediaList = await withMSTeamsRequestDeadline({
deadline: params.deadline,
label: "MS Teams inbound media",
work: () =>
resolveMSTeamsInboundMedia({
attachments: params.entry.attachments,
htmlSummary: params.htmlSummary,
maxBytes: params.mediaMaxBytes,
tokenProvider: params.tokenProvider,
allowHosts: params.mediaAllowHosts,
authAllowHosts: params.mediaAuthAllowHosts,
graphMediaFallback: params.graphMediaFallback,
conversationType: params.conversationType,
conversationId: params.conversationId,
conversationMessageId: params.conversationMessageId,
teamAadGroupId: params.teamAadGroupId,
resolveTeamAadGroupId: params.resolveTeamAadGroupId,
serviceUrl: activity.serviceUrl,
activity: {
id: activity.id,
replyToId: activity.replyToId,
channelData: activity.channelData,
},
log: params.log,
deadline: params.deadline,
preserveFilenames: false,
}),
});
} catch (err) {
params.log.debug?.("failed to resolve inbound Teams media", {
error: formatUnknownError(err),
});
}
const inboundMedia = mergeMSTeamsMediaFacts(params.advertisedMedia, mediaList);
const nativeMediaForComparison = [
...params.advertisedMedia,
...mediaList.slice(params.advertisedMedia.length).map((media) => ({
contentType: media.contentType,
kind: media.kind,
})),
];
const agentBody = resolveMSTeamsInboundMediaBody({
body: params.rawBody,
nativeMedia: nativeMediaForComparison,
materializedMedia: inboundMedia,
});
if (!agentBody && inboundMedia.length === 0) {
params.log.debug?.("skipping empty message after Graph media recovery");
return null;
}
return { agentBody, inboundMedia };
}

View File

@@ -0,0 +1,320 @@
// Msteams plugin module dispatches prepared inbound turns and owns reply lifecycle handling.
import {
buildChannelInboundEventContext,
createChannelInboundEnvelopeBuilder,
hasFinalInboundReplyDispatch,
resolveInboundReplyDispatchCounts,
resolveInboundSupplementalSenderAllowed,
toInboundMediaFactsWithMetadata,
} from "openclaw/plugin-sdk/channel-inbound";
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
import { createChannelHistoryWindow, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { RuntimeEnv } from "../../runtime-api.js";
import { formatUnknownError } from "../errors.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
import { resolveMSTeamsAllowlistMatch, resolveMSTeamsReplyPolicy } from "../policy.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { getMSTeamsRuntime } from "../runtime.js";
import { recordMSTeamsSentMessage } from "../sent-message-cache.js";
import type { admitMSTeamsMessage } from "./access.js";
import type { prepareMSTeamsInboundContent } from "./inbound-content.js";
import type { assembleMSTeamsInboundFacts } from "./inbound-facts.js";
import type { prepareMSTeamsThreadRouting, resolveMSTeamsThreadContext } from "./thread-context.js";
type MSTeamsInboundDispatchResult =
| { kind: "completed"; finalResponses: number }
| { kind: "failed" };
export async function dispatchMSTeamsInboundTurn(params: {
cfg: MSTeamsMessageHandlerDeps["cfg"];
runtime: RuntimeEnv;
appId: string;
app: MSTeamsMessageHandlerDeps["app"];
tokenProvider: MSTeamsMessageHandlerDeps["tokenProvider"];
textLimit: number;
log: MSTeamsMessageHandlerDeps["log"];
logVerboseMessage: (message: string) => void;
facts: ReturnType<typeof assembleMSTeamsInboundFacts>;
admission: NonNullable<Awaited<ReturnType<typeof admitMSTeamsMessage>>>;
content: NonNullable<Awaited<ReturnType<typeof prepareMSTeamsInboundContent>>>;
routing: ReturnType<typeof prepareMSTeamsThreadRouting>;
thread: Awaited<ReturnType<typeof resolveMSTeamsThreadContext>>;
replyStyle: ReturnType<typeof resolveMSTeamsReplyPolicy>["replyStyle"];
timestamp?: Date;
contextVisibilityMode: "all" | "allowlist" | "allowlist_quote";
mentionWasEffective: boolean;
conversationHistories: Map<string, HistoryEntry[]>;
historyLimit: number;
}): Promise<MSTeamsInboundDispatchResult> {
const core = getMSTeamsRuntime();
const {
cfg,
runtime,
appId,
app,
tokenProvider,
textLimit,
log,
logVerboseMessage,
facts,
admission,
content,
routing,
thread,
replyStyle,
timestamp,
contextVisibilityMode,
conversationHistories,
historyLimit,
} = params;
const { context, activity, rawBody, text, quoteInfo, conversationRef } = facts;
const {
senderId,
senderName,
isDirectMessage,
allowNameMatching,
groupPolicy,
commandAuthorized,
effectiveGroupAllowFrom,
} = admission;
const { route } = routing;
const { agentBody, inboundMedia } = content;
const { teamAadGroupId, quoteBodyFull, quoteSenderId, quoteSenderName, threadContext } = thread;
const { conversationId, conversationType, isChannel, teamId, graphChannelId } = facts;
const teamsFrom = isDirectMessage
? `msteams:${senderId}`
: isChannel
? `msteams:channel:${conversationId}`
: `msteams:group:${conversationId}`;
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg, route });
const body = buildEnvelope({
channel: "Teams",
from: envelopeFrom,
timestamp,
body: agentBody,
});
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey) {
const channelHistory = createChannelHistoryWindow({ historyMap: conversationHistories });
combinedBody = channelHistory.buildPendingContext({
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
buildEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
previousTimestamp: null,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}),
});
}
const inboundHistory =
isRoomish && historyKey && historyLimit > 0
? createChannelHistoryWindow({ historyMap: conversationHistories }).buildInboundHistory({
historyKey,
limit: historyLimit,
})
: undefined;
const commandBody = text.trim();
const quoteSenderAllowed =
quoteInfo && quoteInfo.sender
? resolveInboundSupplementalSenderAllowed({
isGroup: !isDirectMessage,
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
isSenderAllowed: (allowFrom) =>
resolveMSTeamsAllowlistMatch({
allowFrom,
senderId: quoteSenderId ?? "",
senderName: quoteSenderName,
allowNameMatching,
}).allowed,
})
: true;
const bodyForAgent = threadContext
? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${agentBody}`
: agentBody;
// Teams channel actions need both the AAD group and Graph channel ids.
const nativeChannelId =
isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
const ctxPayload = buildChannelInboundEventContext({
channel: "msteams",
contextVisibility: contextVisibilityMode,
supplemental: {
quote: quoteInfo
? {
id: quoteInfo.id ?? activity.replyToId ?? undefined,
body: quoteBodyFull ?? quoteInfo.body,
sender: quoteInfo.sender,
senderAllowed: quoteSenderAllowed,
isQuote: true,
}
: undefined,
},
media: await toInboundMediaFactsWithMetadata(inboundMedia),
messageId: activity.id,
timestamp: timestamp?.getTime() ?? Date.now(),
from: teamsFrom,
sender: {
id: senderId,
name: senderName,
},
conversation: {
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
id: conversationId,
label: envelopeFrom,
spaceId: teamId,
nativeChannelId,
},
route: {
agentId: route.agentId,
dmScope: route.dmScope,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
},
reply: {
to: teamsTo,
replyToId: activity.replyToId ?? undefined,
nativeChannelId,
},
message: {
body: combinedBody,
bodyForAgent,
inboundHistory,
rawBody,
commandBody,
},
sessionTranscript: { historyLimit: isRoomish ? historyLimit : 0 },
access: {
mentions: {
canDetectMention: !isDirectMessage,
wasMentioned: isDirectMessage || params.mentionWasEffective,
},
commands: {
authorized: commandAuthorized === true,
},
},
extra: {
GroupSubject: !isDirectMessage ? conversationType : undefined,
ReplyToIsQuote: quoteInfo ? true : undefined,
},
});
const preview = sliceUtf16Safe(rawBody.replace(/\s+/g, " "), 0, 160);
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
const { dispatcherOptions, delivery, replyOptions } = createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
sessionKey: route.sessionKey,
accountId: route.accountId,
runtime,
log,
app,
appId,
conversationRef,
context,
replyStyle,
textLimit,
onSentMessageIds: (ids) => {
for (const id of ids) {
recordMSTeamsSentMessage(conversationId, id);
}
},
tokenProvider,
sharePointSiteId: cfg.channels?.msteams?.sharePointSiteId,
});
const activityClientInfo = activity.entities?.find((entity) => entity.type === "clientInfo") as
| { timezone?: string }
| undefined;
const senderTimezone = activityClientInfo?.timezone || conversationRef.timezone;
const turnConfig =
senderTimezone && !cfg.agents?.defaults?.userTimezone
? {
...cfg,
agents: {
...cfg.agents,
defaults: { ...cfg.agents?.defaults, userTimezone: senderTimezone },
},
}
: cfg;
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const turnResult = await core.channel.inbound.run({
channel: "msteams",
accountId: route.accountId,
raw: context,
adapter: {
ingest: () => ({
id: activity.id ?? `${teamsFrom}:${Date.now()}`,
timestamp: timestamp?.getTime(),
rawText: rawBody,
textForAgent: bodyForAgent,
textForCommands: commandBody,
raw: activity,
}),
resolveTurn: () => ({
cfg: turnConfig,
channel: "msteams",
accountId: route.accountId,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
record: {
onRecordError: (err) => {
logVerboseMessage(
`msteams: failed updating session meta: ${formatUnknownError(err)}`,
);
},
},
history: {
isGroup: isRoomish,
historyKey,
historyMap: conversationHistories,
limit: historyLimit,
},
dispatcherOptions,
delivery,
replyOptions: {
...replyOptions,
...(facts.turnAdoptionLifecycle
? bindIngressLifecycleToReplyOptions(facts.turnAdoptionLifecycle)
: {}),
},
}),
},
});
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
const counts = resolveInboundReplyDispatchCounts(dispatchResult);
log.info("dispatch complete", {
queuedFinal: dispatchResult?.queuedFinal ?? false,
counts,
});
if (hasFinalInboundReplyDispatch(dispatchResult)) {
logVerboseMessage(
`msteams: delivered ${counts.final} repl${counts.final === 1 ? "y" : "ies"} to ${teamsTo}`,
);
}
return { kind: "completed", finalResponses: counts.final };
} catch (err) {
log.error("dispatch failed", { error: formatUnknownError(err) });
runtime.error(`msteams dispatch failed: ${formatUnknownError(err)}`);
if (facts.turnAdoptionLifecycle) {
throw err;
}
try {
await context.sendActivity("⚠️ Something went wrong. Please try again.");
} catch {
// Best effort.
}
return { kind: "failed" };
}
}

View File

@@ -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",
},
});
});
});

View File

@@ -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,
}),
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
// Msteams plugin module owns thread routing and Graph parent context.
import { resolveInboundSupplementalSenderAllowed } from "openclaw/plugin-sdk/channel-inbound";
import { filterSupplementalContextItems } from "openclaw/plugin-sdk/context-visibility-runtime";
import type { OpenClawConfig } from "../../runtime-api.js";
import { formatUnknownError } from "../errors.js";
import {
fetchChannelMessage,
fetchChatMessageText,
fetchThreadReplies,
formatThreadContext,
type GraphThreadMessage,
} from "../graph-thread.js";
import type { extractMSTeamsQuoteInfo } from "../inbound.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
import { resolveMSTeamsAllowlistMatch } from "../policy.js";
import { createMSTeamsInboundDeadline, withMSTeamsRequestDeadline } from "../request-timeout.js";
import { getMSTeamsRuntime } from "../runtime.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveTeamGroupId } from "../team-identity.js";
import {
fetchParentMessageCached,
formatParentContextEvent,
markParentContextInjected,
shouldInjectParentContext,
summarizeParentMessage,
} from "../thread-parent-context.js";
import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
export function prepareMSTeamsThreadRouting(params: {
cfg: OpenClawConfig;
context: MSTeamsTurnContext;
isDirectMessage: boolean;
isChannel: boolean;
senderId: string;
conversationId: string;
conversationMessageId?: string;
teamId?: string;
log: MSTeamsMessageHandlerDeps["log"];
}) {
const core = getMSTeamsRuntime();
const route = core.channel.routing.resolveAgentRoute({
cfg: params.cfg,
channel: "msteams",
teamId: params.teamId,
peer: {
kind: params.isDirectMessage ? "direct" : params.isChannel ? "channel" : "group",
id: params.isDirectMessage ? params.senderId : params.conversationId,
},
});
route.sessionKey = resolveMSTeamsRouteSessionKey({
baseSessionKey: route.sessionKey,
isChannel: params.isChannel,
conversationMessageId: params.conversationMessageId,
replyToId: params.context.activity.replyToId,
});
const deadline = createMSTeamsInboundDeadline();
let teamAadGroupId = params.context.activity.channelData?.team?.aadGroupId?.trim() || undefined;
const conversationTeamId = params.isChannel ? params.teamId : undefined;
let teamGroupIdPromise: Promise<string | undefined> | undefined;
const resolveTeamAadGroupId = async (): Promise<string | undefined> => {
if (!conversationTeamId) {
return undefined;
}
teamGroupIdPromise ??= resolveTeamGroupId({
conversationTeamId,
aadGroupId: teamAadGroupId,
getTeamDetails: params.context.getTeamDetails,
deadline,
}).catch((err: unknown) => {
params.log.debug?.("failed to resolve Teams AAD group ID", {
teamId: conversationTeamId,
error: formatUnknownError(err),
});
return undefined;
});
teamAadGroupId = await teamGroupIdPromise;
return teamAadGroupId;
};
return {
route,
deadline,
resolveTeamAadGroupId,
getTeamAadGroupId: () => teamAadGroupId,
};
}
export async function resolveMSTeamsThreadContext(params: {
routing: ReturnType<typeof prepareMSTeamsThreadRouting>;
context: MSTeamsTurnContext;
tokenProvider: MSTeamsMessageHandlerDeps["tokenProvider"];
quoteInfo: ReturnType<typeof extractMSTeamsQuoteInfo>;
isDirectMessage: boolean;
isChannel: boolean;
conversationId: string;
contextVisibilityMode: "all" | "allowlist" | "allowlist_quote";
groupPolicy: Parameters<typeof resolveInboundSupplementalSenderAllowed>[0]["groupPolicy"];
effectiveGroupAllowFrom: readonly (string | number)[];
allowNameMatching: boolean;
log: MSTeamsMessageHandlerDeps["log"];
}) {
const core = getMSTeamsRuntime();
const activity = params.context.activity;
const { route, deadline } = params.routing;
const teamAadGroupId = await params.routing.resolveTeamAadGroupId();
let quoteBodyFull: string | undefined;
let quoteSenderId: string | undefined;
let quoteSenderName: string | undefined;
const quoteMessageId = params.quoteInfo?.id;
if (quoteMessageId && params.isDirectMessage && params.conversationId.startsWith("19:")) {
try {
const graphToken = await withMSTeamsRequestDeadline({
deadline,
label: "MS Teams quote token",
work: () => params.tokenProvider.getAccessToken("https://graph.microsoft.com"),
});
quoteBodyFull = await withMSTeamsRequestDeadline({
deadline,
label: "MS Teams quote lookup",
work: () =>
fetchChatMessageText(graphToken, params.conversationId, quoteMessageId, deadline),
});
} catch (err) {
params.log.debug?.("failed to fetch full quoted message text", {
error: formatUnknownError(err),
});
}
}
let threadContext: string | undefined;
const threadParentId = activity.replyToId;
if (threadParentId && params.isChannel && teamAadGroupId) {
try {
const graphToken = await withMSTeamsRequestDeadline({
deadline,
label: "MS Teams thread token",
work: () => params.tokenProvider.getAccessToken("https://graph.microsoft.com"),
});
// Keep successful parent context when the replies endpoint fails, and vice versa.
const [parentResult, repliesResult] = await withMSTeamsRequestDeadline({
deadline,
label: "MS Teams thread history",
work: () =>
Promise.allSettled([
fetchParentMessageCached(
graphToken,
teamAadGroupId,
params.conversationId,
threadParentId,
(token, groupId, requestedChannelId, messageId) =>
fetchChannelMessage(token, groupId, requestedChannelId, messageId, deadline),
),
fetchThreadReplies(
graphToken,
teamAadGroupId,
params.conversationId,
threadParentId,
50,
deadline,
),
]),
});
const parentMsg = parentResult.status === "fulfilled" ? parentResult.value : undefined;
const replies = repliesResult.status === "fulfilled" ? repliesResult.value : [];
if (parentResult.status === "rejected") {
params.log.debug?.("failed to fetch parent message", {
error: formatUnknownError(parentResult.reason),
});
}
if (repliesResult.status === "rejected") {
params.log.debug?.("failed to fetch thread replies", {
error: formatUnknownError(repliesResult.reason),
});
}
const isThreadSenderAllowed = (message: GraphThreadMessage) =>
resolveInboundSupplementalSenderAllowed({
isGroup: params.isChannel,
groupPolicy: params.groupPolicy,
allowFrom: params.effectiveGroupAllowFrom,
isSenderAllowed: (allowFrom) =>
resolveMSTeamsAllowlistMatch({
allowFrom,
senderId: message.from?.user?.id ?? "",
senderName: message.from?.user?.displayName,
allowNameMatching: params.allowNameMatching,
}).allowed,
});
const parentSummary = summarizeParentMessage(parentMsg);
const visibleParentMessages = parentMsg
? filterSupplementalContextItems({
items: [parentMsg],
mode: params.contextVisibilityMode,
kind: "thread",
isSenderAllowed: isThreadSenderAllowed,
}).items
: [];
if (
parentSummary &&
visibleParentMessages.length > 0 &&
shouldInjectParentContext(route.sessionKey, threadParentId)
) {
core.system.enqueueSystemEvent(formatParentContextEvent(parentSummary), {
sessionKey: route.sessionKey,
contextKey: `msteams:thread-parent:${params.conversationId}:${threadParentId}`,
});
markParentContextInjected(route.sessionKey, threadParentId);
}
const allMessages = parentMsg ? [parentMsg, ...replies] : replies;
quoteSenderId = parentMsg?.from?.user?.id ?? parentMsg?.from?.application?.id ?? undefined;
quoteSenderName =
parentMsg?.from?.user?.displayName ??
parentMsg?.from?.application?.displayName ??
params.quoteInfo?.sender;
const { items: threadMessages } = filterSupplementalContextItems({
items: allMessages,
mode: params.contextVisibilityMode,
kind: "thread",
isSenderAllowed: isThreadSenderAllowed,
});
threadContext = formatThreadContext(threadMessages, activity.id) || undefined;
} catch (err) {
params.log.debug?.("failed to fetch thread history", {
error: formatUnknownError(err),
});
}
}
quoteSenderName ??= params.quoteInfo?.sender;
return {
teamAadGroupId,
quoteBodyFull,
quoteSenderId,
quoteSenderName,
threadContext,
};
}