mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
0e792b6de3
* refactor(channels): centralize inbound turn orchestration * refactor(runtime): remove stale compatibility paths * chore(guards): reject internal deprecated API use * refactor(channels): simplify core turn planning * chore(guards): keep deprecated checks boundary-focused * refactor(memory): keep modern config off compat barrel * fix(msteams): preserve feedback learning * test(channels): align modern inbound fixtures * refactor(channels): finish modern inbound migration * refactor(channels): tighten core inbound kernel * fix(channels): preserve turn assembly narrowing * test(sdk): keep runtime mock binding immutable * test(matrix): isolate read policy runtime * test(msteams): mock canonical reply factory * test(slack): mock core inbound turn dispatch * test(telegram): inject core session recorder * test(signal): inject core session recorder * test(googlechat): assert canonical inbound routing * test(synology-chat): align core turn fixture * fix(sdk): preserve direct DM runtime compat * refactor(channels): own inbound envelope compat in core * refactor(channels): trim inbound dispatch seams * refactor(channels): remove redundant async wrappers * test(synology-chat): type canonical dispatcher mock * refactor(channels): remove remaining dead compat seams * chore(sdk): refresh API baseline after rebase * fix(channels): preserve direct DM identity metadata
1120 lines
39 KiB
TypeScript
1120 lines
39 KiB
TypeScript
// Msteams plugin module implements message handler behavior.
|
|
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
|
|
import {
|
|
buildChannelInboundEventContext,
|
|
createChannelInboundEnvelopeBuilder,
|
|
logInboundDrop,
|
|
resolveInboundMentionDecision,
|
|
resolveInboundSupplementalSenderAllowed,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import {
|
|
dispatchReplyFromConfigWithSettledDispatcher,
|
|
hasFinalInboundReplyDispatch,
|
|
resolveInboundReplyDispatchCounts,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import {
|
|
filterSupplementalContextItems,
|
|
resolveChannelContextVisibilityMode,
|
|
} from "openclaw/plugin-sdk/context-visibility-runtime";
|
|
import {
|
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
createChannelHistoryWindow,
|
|
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 {
|
|
buildMSTeamsMediaPayload,
|
|
resolveMSTeamsInboundAttachmentPresentation,
|
|
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,
|
|
fetchChatMessageText,
|
|
fetchThreadReplies,
|
|
formatThreadContext,
|
|
type GraphThreadMessage,
|
|
} from "../graph-thread.js";
|
|
import {
|
|
extractMSTeamsConversationMessageId,
|
|
extractMSTeamsQuoteInfo,
|
|
normalizeMSTeamsConversationId,
|
|
parseMSTeamsActivityTimestamp,
|
|
stripMSTeamsMentionTags,
|
|
wasMSTeamsBotMentioned,
|
|
} from "../inbound.js";
|
|
import { createMSTeamsInboundDeadline, withMSTeamsRequestDeadline } from "../request-timeout.js";
|
|
import {
|
|
fetchParentMessageCached,
|
|
formatParentContextEvent,
|
|
markParentContextInjected,
|
|
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(/ /gi, " ")
|
|
.replace(/&/gi, "&")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.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 {
|
|
resolveMSTeamsInboundMedia,
|
|
resolveMSTeamsInboundMediaBody,
|
|
shouldAttemptMSTeamsGraphMediaFallback,
|
|
} from "./inbound-media.js";
|
|
import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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,
|
|
runtime,
|
|
appId,
|
|
app,
|
|
tokenProvider,
|
|
textLimit,
|
|
mediaMaxBytes,
|
|
conversationStore,
|
|
pollStore,
|
|
log,
|
|
} = deps;
|
|
const core = getMSTeamsRuntime();
|
|
const logVerboseMessage = (message: string) => {
|
|
if (core.logging.shouldLogVerbose()) {
|
|
log.debug?.(message);
|
|
}
|
|
};
|
|
const msteamsCfg = cfg.channels?.msteams;
|
|
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
|
cfg,
|
|
channel: "msteams",
|
|
});
|
|
const historyLimit = Math.max(
|
|
0,
|
|
msteamsCfg?.historyLimit ??
|
|
cfg.messages?.groupChat?.historyLimit ??
|
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
);
|
|
const conversationHistories = new Map<string, HistoryEntry[]>();
|
|
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
|
cfg,
|
|
channel: "msteams",
|
|
});
|
|
|
|
type MSTeamsDebounceEntry = {
|
|
context: MSTeamsTurnContext;
|
|
rawText: string;
|
|
text: string;
|
|
attachments: MSTeamsAttachmentLike[];
|
|
wasMentioned: boolean;
|
|
implicitMentionKinds: Array<"reply_to_bot">;
|
|
};
|
|
|
|
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 attachmentPresentation = resolveMSTeamsInboundAttachmentPresentation(attachments, {
|
|
maxInlineBytes: mediaMaxBytes,
|
|
maxInlineTotalBytes: mediaMaxBytes,
|
|
});
|
|
const attachmentPlaceholder = attachmentPresentation.placeholder;
|
|
const rawBody = text || attachmentPlaceholder;
|
|
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),
|
|
text: truncateUtf16Safe(text, 50),
|
|
attachments: attachments.length,
|
|
attachmentTypes,
|
|
from: from?.id,
|
|
conversation: conversation?.id,
|
|
});
|
|
if (htmlSummary) {
|
|
log.debug?.("html attachment summary", htmlSummary);
|
|
}
|
|
|
|
if (!from?.id) {
|
|
log.debug?.("skipping message without from.id");
|
|
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",
|
|
});
|
|
const isControlCommand =
|
|
allowTextCommands && core.channel.commands.isControlCommandMessage(text, cfg);
|
|
const {
|
|
dmPolicy,
|
|
senderId,
|
|
senderName,
|
|
pairing,
|
|
isDirectMessage,
|
|
channelGate,
|
|
senderAccess,
|
|
commandAccess,
|
|
allowNameMatching,
|
|
groupPolicy,
|
|
} = await resolveMSTeamsSenderAccess({
|
|
cfg,
|
|
activity,
|
|
hasControlCommand: isControlCommand,
|
|
});
|
|
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)", {
|
|
sender: senderId,
|
|
label: senderName,
|
|
});
|
|
log.debug?.("dropping dm (dms disabled)");
|
|
return;
|
|
}
|
|
const allowMatch = resolveMSTeamsAllowlistMatch({
|
|
allowFrom: effectiveDmAllowFrom,
|
|
senderId,
|
|
senderName,
|
|
allowNameMatching,
|
|
});
|
|
if (senderAccess.decision === "pairing") {
|
|
conversationStore.upsert(conversationId, conversationRef).catch((err: unknown) => {
|
|
log.debug?.("failed to save conversation reference", {
|
|
error: formatUnknownError(err),
|
|
});
|
|
});
|
|
const request = await pairing.upsertPairingRequest({
|
|
id: senderId,
|
|
meta: { name: senderName },
|
|
});
|
|
if (request) {
|
|
log.info("msteams pairing request created", {
|
|
sender: senderId,
|
|
label: senderName,
|
|
});
|
|
}
|
|
}
|
|
log.debug?.("dropping dm (not allowlisted)", {
|
|
sender: senderId,
|
|
label: senderName,
|
|
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
|
});
|
|
log.info("dropping dm (not allowlisted)", {
|
|
sender: senderId,
|
|
label: senderName,
|
|
dmPolicy,
|
|
reason: formatMSTeamsSenderReason({
|
|
reasonCode: senderAccess.reasonCode,
|
|
dmPolicy,
|
|
groupPolicy,
|
|
}),
|
|
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!isDirectMessage && msteamsCfg) {
|
|
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
|
log.info("dropping group message (not in team/channel allowlist)", {
|
|
conversationId,
|
|
teamKey: channelGate.teamKey ?? "none",
|
|
channelKey: channelGate.channelKey ?? "none",
|
|
channelMatchKey: channelGate.channelMatchKey ?? "none",
|
|
channelMatchSource: channelGate.channelMatchSource ?? "none",
|
|
});
|
|
log.debug?.("dropping group message (not in team/channel allowlist)", {
|
|
conversationId,
|
|
teamKey: channelGate.teamKey ?? "none",
|
|
channelKey: channelGate.channelKey ?? "none",
|
|
channelMatchKey: channelGate.channelMatchKey ?? "none",
|
|
channelMatchSource: channelGate.channelMatchSource ?? "none",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_disabled") {
|
|
log.info("dropping group message (groupPolicy: disabled)", {
|
|
conversationId,
|
|
});
|
|
log.debug?.("dropping group message (groupPolicy: disabled)", {
|
|
conversationId,
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
!senderAccess.allowed &&
|
|
(senderAccess.reasonCode === "group_policy_empty_allowlist" ||
|
|
senderAccess.reasonCode === "route_sender_empty")
|
|
) {
|
|
log.info("dropping group message (groupPolicy: allowlist, no allowlist)", {
|
|
conversationId,
|
|
});
|
|
log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
|
|
conversationId,
|
|
});
|
|
return;
|
|
}
|
|
if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_not_allowlisted") {
|
|
const allowMatch = resolveMSTeamsAllowlistMatch({
|
|
allowFrom: effectiveGroupAllowFrom,
|
|
senderId,
|
|
senderName,
|
|
allowNameMatching,
|
|
});
|
|
log.debug?.("dropping group message (not in groupAllowFrom)", {
|
|
sender: senderId,
|
|
label: senderName,
|
|
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
|
});
|
|
log.info("dropping group message (not in groupAllowFrom)", {
|
|
sender: senderId,
|
|
label: senderName,
|
|
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (commandAccess.shouldBlockControlCommand) {
|
|
logInboundDrop({
|
|
log: logVerboseMessage,
|
|
channel: "msteams",
|
|
reason: "control command (unauthorized)",
|
|
target: senderId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
conversationStore.upsert(conversationId, conversationRef).catch((err: unknown) => {
|
|
log.debug?.("failed to save conversation reference", {
|
|
error: formatUnknownError(err),
|
|
});
|
|
});
|
|
|
|
const pollVote = extractMSTeamsPollVote(activity);
|
|
if (pollVote) {
|
|
try {
|
|
const poll = await pollStore.recordVote({
|
|
pollId: pollVote.pollId,
|
|
voterId: senderId,
|
|
selections: pollVote.selections,
|
|
});
|
|
if (!poll) {
|
|
log.debug?.("poll vote ignored (poll not found)", {
|
|
pollId: pollVote.pollId,
|
|
});
|
|
} else {
|
|
log.info("recorded poll vote", {
|
|
pollId: pollVote.pollId,
|
|
voter: senderId,
|
|
selections: pollVote.selections,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
log.error("failed to record poll vote", {
|
|
pollId: pollVote.pollId,
|
|
error: formatUnknownError(err),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const mayRecoverGraphMedia =
|
|
Boolean(htmlSummary?.attachmentIds.length) ||
|
|
shouldAttemptMSTeamsGraphMediaFallback({
|
|
conversationType,
|
|
htmlSummary: htmlSummary ?? undefined,
|
|
graphMediaFallback: msteamsCfg?.graphMediaFallback,
|
|
});
|
|
if (!rawBody && !mayRecoverGraphMedia) {
|
|
log.debug?.("skipping empty message after stripping mentions");
|
|
return;
|
|
}
|
|
|
|
const teamsFrom = isDirectMessage
|
|
? `msteams:${senderId}`
|
|
: isChannel
|
|
? `msteams:channel:${conversationId}`
|
|
: `msteams:group:${conversationId}`;
|
|
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
|
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "msteams",
|
|
teamId,
|
|
peer: {
|
|
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
|
id: isDirectMessage ? senderId : conversationId,
|
|
},
|
|
});
|
|
|
|
// Isolate channel thread sessions: each thread gets its own session key so
|
|
// context does not bleed across threads. Prefer conversationMessageId (the
|
|
// ;messageid= portion of conversation.id, i.e. the thread root) over
|
|
// activity.replyToId (which may point to a non-root parent in deep threads).
|
|
// DMs and group chats are unaffected — only channel thread replies fork.
|
|
route.sessionKey = resolveMSTeamsRouteSessionKey({
|
|
baseSessionKey: route.sessionKey,
|
|
isChannel,
|
|
conversationMessageId,
|
|
replyToId: activity.replyToId,
|
|
});
|
|
|
|
const preview = sliceUtf16Safe(rawBody.replace(/\s+/g, " "), 0, 160);
|
|
const inboundLabel = isDirectMessage
|
|
? `Teams DM from ${senderName}`
|
|
: `Teams message in ${conversationType} from ${senderName}`;
|
|
|
|
const enqueuePrimaryMessageSystemEvent = () =>
|
|
core.system.enqueueSystemEvent(inboundLabel, {
|
|
sessionKey: route.sessionKey,
|
|
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
|
});
|
|
|
|
const channelId = conversationId;
|
|
const { teamConfig, channelConfig } = channelGate;
|
|
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
|
|
isDirectMessage,
|
|
globalConfig: msteamsCfg,
|
|
teamConfig,
|
|
channelConfig,
|
|
});
|
|
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
|
const mentionDecision = resolveInboundMentionDecision({
|
|
facts: {
|
|
canDetectMention: true,
|
|
wasMentioned: params.wasMentioned,
|
|
implicitMentionKinds: params.implicitMentionKinds,
|
|
},
|
|
policy: {
|
|
isGroup: !isDirectMessage,
|
|
requireMention,
|
|
allowTextCommands,
|
|
hasControlCommand: isControlCommand,
|
|
commandAuthorized: commandAuthorized === true,
|
|
},
|
|
});
|
|
|
|
if (!isDirectMessage) {
|
|
const mentioned = mentionDecision.effectiveWasMentioned;
|
|
if (requireMention && mentionDecision.shouldSkip) {
|
|
log.debug?.("skipping message (mention required)", {
|
|
teamId,
|
|
channelId,
|
|
requireMention,
|
|
mentioned,
|
|
});
|
|
if (rawBody) {
|
|
enqueuePrimaryMessageSystemEvent();
|
|
createChannelHistoryWindow({ historyMap: conversationHistories }).record({
|
|
historyKey: conversationId,
|
|
limit: historyLimit,
|
|
entry: {
|
|
sender: senderName,
|
|
body: rawBody,
|
|
timestamp: timestamp?.getTime(),
|
|
messageId: activity.id ?? undefined,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
const preprocessingDeadline = createMSTeamsInboundDeadline();
|
|
let teamAadGroupId = activity.channelData?.team?.aadGroupId?.trim() || undefined;
|
|
const conversationTeamId = isChannel ? teamId : undefined;
|
|
let teamGroupIdPromise: Promise<string | undefined> | undefined;
|
|
const resolveChannelTeamGroupId = async (): Promise<string | undefined> => {
|
|
if (!conversationTeamId) {
|
|
return undefined;
|
|
}
|
|
teamGroupIdPromise ??= resolveTeamGroupId({
|
|
conversationTeamId,
|
|
aadGroupId: teamAadGroupId,
|
|
getTeamDetails: context.getTeamDetails,
|
|
deadline: preprocessingDeadline,
|
|
}).catch((err: unknown) => {
|
|
log.debug?.("failed to resolve Teams AAD group ID", {
|
|
teamId: conversationTeamId,
|
|
error: formatUnknownError(err),
|
|
});
|
|
return undefined;
|
|
});
|
|
teamAadGroupId = await teamGroupIdPromise;
|
|
return teamAadGroupId;
|
|
};
|
|
let mediaList = [] as Awaited<ReturnType<typeof resolveMSTeamsInboundMedia>>;
|
|
try {
|
|
mediaList = await withMSTeamsRequestDeadline({
|
|
deadline: preprocessingDeadline,
|
|
label: "MS Teams inbound media",
|
|
work: () =>
|
|
resolveMSTeamsInboundMedia({
|
|
attachments,
|
|
htmlSummary: htmlSummary ?? undefined,
|
|
maxBytes: mediaMaxBytes,
|
|
tokenProvider,
|
|
allowHosts: msteamsCfg?.mediaAllowHosts,
|
|
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
|
|
graphMediaFallback: msteamsCfg?.graphMediaFallback,
|
|
conversationType,
|
|
conversationId,
|
|
conversationMessageId: conversationMessageId ?? undefined,
|
|
teamAadGroupId,
|
|
resolveTeamAadGroupId: resolveChannelTeamGroupId,
|
|
serviceUrl: activity.serviceUrl,
|
|
activity: {
|
|
id: activity.id,
|
|
replyToId: activity.replyToId,
|
|
channelData: activity.channelData,
|
|
},
|
|
log,
|
|
deadline: preprocessingDeadline,
|
|
preserveFilenames: (cfg as { media?: { preserveFilenames?: boolean } }).media
|
|
?.preserveFilenames,
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
log.debug?.("failed to resolve inbound Teams media", {
|
|
error: formatUnknownError(err),
|
|
});
|
|
}
|
|
|
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
|
const materializedMediaPlaceholder = resolveMSTeamsInboundAttachmentPresentation(
|
|
mediaList.map((media) => ({ contentType: media.contentType, name: media.path })),
|
|
).placeholder;
|
|
const agentBody = resolveMSTeamsInboundMediaBody({
|
|
body: rawBody || materializedMediaPlaceholder,
|
|
mediaPlaceholder: attachmentPlaceholder,
|
|
materializedMediaPlaceholder,
|
|
expectedMediaCount: attachmentPresentation.expectedMediaCount,
|
|
mediaCount: mediaList.length,
|
|
});
|
|
if (!agentBody) {
|
|
log.debug?.("skipping empty message after Graph media recovery");
|
|
return;
|
|
}
|
|
enqueuePrimaryMessageSystemEvent();
|
|
teamAadGroupId = await resolveChannelTeamGroupId();
|
|
|
|
// Media is the primary payload, so optional quote enrichment only gets the
|
|
// remaining preprocessing budget. DMs alone may fetch the full quote: group
|
|
// and channel quotes retain their visibility-filtered preview.
|
|
let quoteBodyFull: string | undefined;
|
|
const quoteMessageId = quoteInfo?.id;
|
|
if (quoteMessageId && isDirectMessage && conversationId.startsWith("19:")) {
|
|
try {
|
|
const graphToken = await withMSTeamsRequestDeadline({
|
|
deadline: preprocessingDeadline,
|
|
label: "MS Teams quote token",
|
|
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
|
|
});
|
|
quoteBodyFull = await withMSTeamsRequestDeadline({
|
|
deadline: preprocessingDeadline,
|
|
label: "MS Teams quote lookup",
|
|
work: () =>
|
|
fetchChatMessageText(graphToken, conversationId, quoteMessageId, preprocessingDeadline),
|
|
});
|
|
} catch (err) {
|
|
log.debug?.("failed to fetch full quoted message text", {
|
|
error: formatUnknownError(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fetch thread history when the message is a reply inside a Teams channel thread.
|
|
// This is a best-effort enhancement; errors are logged and do not block the reply.
|
|
//
|
|
// We also enqueue a compact `Replying to @sender: …` system event when the parent
|
|
// is resolvable. On brand-new thread sessions (see PR #62713), this gives the agent
|
|
// immediate parent context even before the fuller `[Thread history]` block is assembled.
|
|
// Parent fetches are cached (5 min LRU, 100 entries) and per-session deduped so
|
|
// consecutive replies in the same thread do not re-inject identical context.
|
|
let threadContext: string | undefined;
|
|
const threadParentId = activity.replyToId;
|
|
const channelGroupId = teamAadGroupId;
|
|
if (threadParentId && isChannel && channelGroupId) {
|
|
try {
|
|
const graphToken = await withMSTeamsRequestDeadline({
|
|
deadline: preprocessingDeadline,
|
|
label: "MS Teams thread token",
|
|
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
|
|
});
|
|
// Use allSettled so a failure in one fetch does not discard the other.
|
|
// For example, reply-fetch 403 should not throw away a successful parent fetch.
|
|
const [parentResult, repliesResult] = await withMSTeamsRequestDeadline({
|
|
deadline: preprocessingDeadline,
|
|
label: "MS Teams thread history",
|
|
work: () =>
|
|
Promise.allSettled([
|
|
fetchParentMessageCached(
|
|
graphToken,
|
|
channelGroupId,
|
|
conversationId,
|
|
threadParentId,
|
|
(token, groupId, requestedChannelId, messageId) =>
|
|
fetchChannelMessage(
|
|
token,
|
|
groupId,
|
|
requestedChannelId,
|
|
messageId,
|
|
preprocessingDeadline,
|
|
),
|
|
),
|
|
fetchThreadReplies(
|
|
graphToken,
|
|
channelGroupId,
|
|
conversationId,
|
|
threadParentId,
|
|
50,
|
|
preprocessingDeadline,
|
|
),
|
|
]),
|
|
});
|
|
const parentMsg = parentResult.status === "fulfilled" ? parentResult.value : undefined;
|
|
const replies = repliesResult.status === "fulfilled" ? repliesResult.value : [];
|
|
if (parentResult.status === "rejected") {
|
|
log.debug?.("failed to fetch parent message", {
|
|
error: formatUnknownError(parentResult.reason),
|
|
});
|
|
}
|
|
if (repliesResult.status === "rejected") {
|
|
log.debug?.("failed to fetch thread replies", {
|
|
error: formatUnknownError(repliesResult.reason),
|
|
});
|
|
}
|
|
const isThreadSenderAllowed = (msg: GraphThreadMessage) =>
|
|
resolveInboundSupplementalSenderAllowed({
|
|
isGroup: isChannel,
|
|
groupPolicy,
|
|
allowFrom: effectiveGroupAllowFrom,
|
|
isSenderAllowed: (allowFrom) =>
|
|
resolveMSTeamsAllowlistMatch({
|
|
allowFrom,
|
|
senderId: msg.from?.user?.id ?? "",
|
|
senderName: msg.from?.user?.displayName,
|
|
allowNameMatching,
|
|
}).allowed,
|
|
});
|
|
const parentSummary = summarizeParentMessage(parentMsg);
|
|
const visibleParentMessages = parentMsg
|
|
? filterSupplementalContextItems({
|
|
items: [parentMsg],
|
|
mode: 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:${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 ??
|
|
quoteInfo?.sender;
|
|
const { items: threadMessages } = filterSupplementalContextItems({
|
|
items: allMessages,
|
|
mode: contextVisibilityMode,
|
|
kind: "thread",
|
|
isSenderAllowed: isThreadSenderAllowed,
|
|
});
|
|
const formatted = formatThreadContext(threadMessages, activity.id);
|
|
if (formatted) {
|
|
threadContext = formatted;
|
|
}
|
|
} catch (err) {
|
|
log.debug?.("failed to fetch thread history", { error: formatUnknownError(err) });
|
|
// Graceful degradation: thread history is an optional enhancement.
|
|
}
|
|
}
|
|
quoteSenderName ??= quoteInfo?.sender;
|
|
|
|
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;
|
|
// Prepend thread history to the agent body so the agent has full thread context.
|
|
const bodyForAgent = threadContext
|
|
? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${agentBody}`
|
|
: agentBody;
|
|
|
|
// For Teams *channel* messages (not group chats / DMs), preserve the
|
|
// `aadGroupId/channelId` pair on NativeChannelId so downstream action handlers
|
|
// can route through `/teams/{aadGroupId}/channels/{channelId}` via Graph API.
|
|
// The bare conversation id (`19:...@thread.tacv2`) is insufficient on its
|
|
// own because channel Graph endpoints require the owning team id too.
|
|
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,
|
|
},
|
|
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,
|
|
accountId: route.accountId,
|
|
routeSessionKey: route.sessionKey,
|
|
},
|
|
reply: {
|
|
to: teamsTo,
|
|
replyToId: activity.replyToId ?? undefined,
|
|
nativeChannelId,
|
|
},
|
|
message: {
|
|
body: combinedBody,
|
|
bodyForAgent,
|
|
inboundHistory,
|
|
rawBody,
|
|
commandBody,
|
|
},
|
|
access: {
|
|
mentions: {
|
|
canDetectMention: !isDirectMessage,
|
|
wasMentioned: isDirectMessage || mentionDecision.effectiveWasMentioned,
|
|
},
|
|
commands: {
|
|
authorized: commandAuthorized === true,
|
|
},
|
|
},
|
|
extra: {
|
|
GroupSubject: !isDirectMessage ? conversationType : undefined,
|
|
ReplyToIsQuote: quoteInfo ? true : undefined,
|
|
...mediaPayload,
|
|
},
|
|
});
|
|
|
|
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
|
|
|
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
|
|
const { dispatcher, replyOptions, markDispatchIdle } = 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,
|
|
});
|
|
|
|
// Use Teams clientInfo timezone if no explicit userTimezone is configured.
|
|
// This ensures the agent knows the sender's timezone for time-aware responses
|
|
// and proactive sends within the same session.
|
|
const activityClientInfo = activity.entities?.find((e) => e.type === "clientInfo") as
|
|
| { timezone?: string }
|
|
| undefined;
|
|
const senderTimezone = activityClientInfo?.timezone || conversationRef.timezone;
|
|
const configOverride =
|
|
senderTimezone && !cfg.agents?.defaults?.userTimezone
|
|
? {
|
|
agents: {
|
|
defaults: { ...cfg.agents?.defaults, userTimezone: senderTimezone },
|
|
},
|
|
}
|
|
: undefined;
|
|
|
|
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,
|
|
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,
|
|
},
|
|
onPreDispatchFailure: () =>
|
|
core.channel.reply.settleReplyDispatcher({
|
|
dispatcher,
|
|
onSettled: () => markDispatchIdle(),
|
|
}),
|
|
runDispatch: () =>
|
|
dispatchReplyFromConfigWithSettledDispatcher({
|
|
cfg,
|
|
ctxPayload,
|
|
dispatcher,
|
|
onSettled: () => markDispatchIdle(),
|
|
replyOptions,
|
|
configOverride,
|
|
}),
|
|
}),
|
|
},
|
|
});
|
|
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
|
const queuedFinal = dispatchResult?.queuedFinal ?? false;
|
|
const counts = resolveInboundReplyDispatchCounts(dispatchResult);
|
|
const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
|
|
|
|
log.info("dispatch complete", { queuedFinal, counts });
|
|
|
|
if (!hasFinalResponse) {
|
|
return;
|
|
}
|
|
const finalCount = counts.final;
|
|
logVerboseMessage(
|
|
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
|
);
|
|
} catch (err) {
|
|
log.error("dispatch failed", { error: formatUnknownError(err) });
|
|
runtime.error(`msteams dispatch failed: ${formatUnknownError(err)}`);
|
|
try {
|
|
await context.sendActivity("⚠️ Something went wrong. Please try again.");
|
|
} catch {
|
|
// Best effort.
|
|
}
|
|
}
|
|
};
|
|
|
|
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
|
|
debounceMs: inboundDebounceMs,
|
|
buildKey: (entry) => {
|
|
const conversationId = normalizeMSTeamsConversationId(
|
|
entry.context.activity.conversation?.id ?? "",
|
|
);
|
|
const senderId =
|
|
entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? "";
|
|
if (!senderId || !conversationId) {
|
|
return null;
|
|
}
|
|
return `msteams:${appId}:${conversationId}:${senderId}`;
|
|
},
|
|
shouldDebounce: (entry) => {
|
|
if (!entry.text.trim()) {
|
|
return false;
|
|
}
|
|
if (entry.attachments.length > 0) {
|
|
return false;
|
|
}
|
|
return !core.channel.commands.isControlCommandMessage(entry.text, cfg);
|
|
},
|
|
onFlush: async (entries) => {
|
|
const last = entries.at(-1);
|
|
if (!last) {
|
|
return;
|
|
}
|
|
if (entries.length === 1) {
|
|
await handleTeamsMessageNow(last);
|
|
return;
|
|
}
|
|
const combinedText = entries
|
|
.map((entry) => entry.text)
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
if (!combinedText.trim()) {
|
|
return;
|
|
}
|
|
const combinedRawText = entries
|
|
.map((entry) => entry.rawText)
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const wasMentioned = entries.some((entry) => entry.wasMentioned);
|
|
const implicitMentionKinds = entries.flatMap((entry) => entry.implicitMentionKinds);
|
|
await handleTeamsMessageNow({
|
|
context: last.context,
|
|
rawText: combinedRawText,
|
|
text: combinedText,
|
|
attachments: [],
|
|
wasMentioned,
|
|
implicitMentionKinds,
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
runtime.error(`msteams debounce flush failed: ${formatUnknownError(err)}`);
|
|
},
|
|
});
|
|
|
|
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
|
|
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({
|
|
context,
|
|
rawText,
|
|
text,
|
|
attachments,
|
|
wasMentioned,
|
|
implicitMentionKinds,
|
|
});
|
|
};
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|