mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 19:51:35 +00:00
Merge pull request #115823 from openclaw/refactor/msteams-message-handler-decomposition
refactor(msteams): decompose inbound message handler
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
101
extensions/msteams/src/monitor-handler/inbound-content.ts
Normal file
101
extensions/msteams/src/monitor-handler/inbound-content.ts
Normal 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 };
|
||||
}
|
||||
320
extensions/msteams/src/monitor-handler/inbound-dispatch.ts
Normal file
320
extensions/msteams/src/monitor-handler/inbound-dispatch.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
75
extensions/msteams/src/monitor-handler/inbound-facts.test.ts
Normal file
75
extensions/msteams/src/monitor-handler/inbound-facts.test.ts
Normal 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 & 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
178
extensions/msteams/src/monitor-handler/inbound-facts.ts
Normal file
178
extensions/msteams/src/monitor-handler/inbound-facts.ts
Normal 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(/ /gi, " ")
|
||||
.replace(/&/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
237
extensions/msteams/src/monitor-handler/thread-context.ts
Normal file
237
extensions/msteams/src/monitor-handler/thread-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user