Files
openclaw/extensions/msteams/src/monitor-handler.ts
Peter Steinberger 4e0cc187a2 fix(msteams): preserve inbound activities before webhook ack (#110357)
* fix(msteams): adopt durable ingress drain with ack gated on activity append

Bot Framework webhooks acked before detached processing with no platform
retry, silently losing inbound activities on a crash. Dispatchable activities
(message + adaptiveCard/action invokes) now persist their raw JSON durably
(event_id = activity.id per the @microsoft/teams.api uniqueness contract,
lane = conversation.id) before the webhook 200; dispatch runs through the
core drain with deferred claims through debounce, merged-flush fan-out
adoption, gated-turn settlement, retry/dead-letter classification, and
30d/20k tombstones. Restart replay reconstructs proactive routing context.
No inbound replay guard existed; the outbound echo cache is untouched. Twin
check: messageUpdate/votes/consent/reactions run dedicated non-agent
workflows and are excluded from the journal.

Autoreview blocked by codex sandbox network in the build stage; full manual
review performed. Part of the #109657 fleet adoption program (wave 2).

* fix(msteams): close live-context races and double next() in drain dispatch

Three defects found in landing review: the live turn context could swap a
duplicate delivery's mutated activity in place of the journaled payload
(dispatch now always uses the journaled activity, live context is transport
surface only); the context registry installed entries after the durable
append, leaking them when the drain consumed the claim first (install now
precedes enqueue, tombstoned duplicates clean up, first delivery's context
wins); and a failed next() in the message handler was invoked a second time
from the catch fall-through (ran-flag guards it).

* fix(msteams): uninstall a failed append's live context before retry

Follow-up to the install-before-enqueue race fix: an enqueue rejection
bypassed cleanup, so a retry with the same activity id dispatched the failed
request's stale context. Uninstall is identity-guarded (only our context is
removed, never a concurrent redelivery's fresh install) and covers both the
throw and tombstoned-duplicate paths. Regression proves the retry's own
context dispatches.

* test(msteams): track the buffered dispatcher seam after main retired the settled variant

* test(msteams): resolve dispatch union before catch for promise lint

* test(msteams): explicit promise-returning mock for the promise-misuse lint

* test(msteams): type the ingress accept mock promise-returning

ReturnType<typeof vi.fn> types the implementation callback void-returning, so
every promise-returning accept mock tripped typescript(no-misused-promises)
in CI regardless of call-site shape.

* test(msteams): restore compact async mock now that its type permits promises

* test(msteams): extract gated-accept helper to stay under max-lines
2026-07-18 05:33:55 +01:00

271 lines
8.7 KiB
TypeScript

// Msteams plugin module implements monitor handler behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { serializeMSTeamsAdaptiveCardActionValue } from "./adaptive-card-submit.js";
import { formatUnknownError } from "./errors.js";
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import { createMSTeamsReactionHandler } from "./monitor-handler/reaction-handler.js";
import type { MSTeamsIngressDispatchResult, MSTeamsIngressLifecycle } from "./msteams-ingress.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
import { buildGroupWelcomeText, buildWelcomeCard } from "./welcome-card.js";
export type MSTeamsActivityHandler = {
onMessage: (
handler: (
context: unknown,
next: () => Promise<void>,
turnAdoptionLifecycle?: MSTeamsIngressLifecycle,
) => Promise<MSTeamsIngressDispatchResult | void>,
) => MSTeamsActivityHandler;
onMembersAdded: (
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
) => MSTeamsActivityHandler;
onReactionsAdded: (
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
) => MSTeamsActivityHandler;
onReactionsRemoved: (
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
) => MSTeamsActivityHandler;
run?: (
context: unknown,
turnAdoptionLifecycle?: MSTeamsIngressLifecycle,
) => Promise<MSTeamsIngressDispatchResult | void>;
};
async function isInvokeAuthorized(params: {
context: MSTeamsTurnContext;
deps: MSTeamsMessageHandlerDeps;
deniedLogs: {
dm: string;
channel: string;
group: string;
};
includeInvokeName?: boolean;
}): Promise<boolean> {
const { context, deps, deniedLogs, includeInvokeName = false } = params;
const resolved = await resolveMSTeamsSenderAccess({
cfg: deps.cfg,
activity: context.activity,
});
const { msteamsCfg, isDirectMessage, conversationId, senderId } = resolved;
if (!msteamsCfg) {
return true;
}
const maybeInvokeName = includeInvokeName ? { name: context.activity.name } : undefined;
if (isDirectMessage && resolved.senderAccess.decision !== "allow") {
deps.log.debug?.(deniedLogs.dm, {
sender: senderId,
conversationId,
...maybeInvokeName,
});
return false;
}
if (
!isDirectMessage &&
resolved.channelGate.allowlistConfigured &&
!resolved.channelGate.allowed
) {
deps.log.debug?.(deniedLogs.channel, {
conversationId,
teamKey: resolved.channelGate.teamKey ?? "none",
channelKey: resolved.channelGate.channelKey ?? "none",
...maybeInvokeName,
});
return false;
}
if (!isDirectMessage && !resolved.senderAccess.allowed) {
deps.log.debug?.(deniedLogs.group, {
sender: senderId,
conversationId,
...maybeInvokeName,
});
return false;
}
return true;
}
export async function isFeedbackInvokeAuthorized(
context: MSTeamsTurnContext,
deps: MSTeamsMessageHandlerDeps,
): Promise<boolean> {
return isInvokeAuthorized({
context,
deps,
deniedLogs: {
dm: "dropping feedback invoke (dm sender not allowlisted)",
channel: "dropping feedback invoke (not in team/channel allowlist)",
group: "dropping feedback invoke (group sender not allowlisted)",
},
});
}
export async function isSigninInvokeAuthorized(
context: MSTeamsTurnContext,
deps: MSTeamsMessageHandlerDeps,
): Promise<boolean> {
return isInvokeAuthorized({
context,
deps,
deniedLogs: {
dm: "dropping signin invoke (dm sender not allowlisted)",
channel: "dropping signin invoke (not in team/channel allowlist)",
group: "dropping signin invoke (group sender not allowlisted)",
},
includeInvokeName: true,
});
}
export async function isCardActionInvokeAuthorized(
context: MSTeamsTurnContext,
deps: MSTeamsMessageHandlerDeps,
): Promise<boolean> {
return isInvokeAuthorized({
context,
deps,
deniedLogs: {
dm: "dropping card action invoke (dm sender not allowlisted)",
channel: "dropping card action invoke (not in team/channel allowlist)",
group: "dropping card action invoke (group sender not allowlisted)",
},
includeInvokeName: true,
});
}
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
handler: T,
deps: MSTeamsMessageHandlerDeps,
): T {
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
const handleReaction = createMSTeamsReactionHandler(deps);
// Wrap the original run method to intercept invokes
const originalRun = handler.run;
if (originalRun) {
handler.run = async (context: unknown, turnAdoptionLifecycle?: MSTeamsIngressLifecycle) => {
const ctx = context as MSTeamsTurnContext;
// Non-poll adaptiveCard/action invokes get dispatched here as text so the
// agent can react. Poll votes are intercepted in monitor.ts's
// app.on("card.action") handler which returns the InvokeResponse to Teams.
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "adaptiveCard/action") {
const text = serializeMSTeamsAdaptiveCardActionValue(ctx.activity?.value);
if (text) {
return await handleTeamsMessage(
{
...ctx,
activity: {
...ctx.activity,
type: "message",
text,
},
},
turnAdoptionLifecycle,
);
}
return;
}
return originalRun.call(handler, context, turnAdoptionLifecycle);
};
}
handler.onMessage(async (context, next, turnAdoptionLifecycle) => {
let nextRan = false;
const runNext = async () => {
nextRan = true;
await next();
};
try {
const result = await handleTeamsMessage(context as MSTeamsTurnContext, turnAdoptionLifecycle);
await runNext();
return result;
} catch (err) {
if (turnAdoptionLifecycle) {
throw err;
}
deps.runtime.error(`msteams handler failed: ${formatUnknownError(err)}`);
}
if (!nextRan) {
await runNext();
}
return undefined;
});
handler.onMembersAdded(async (context, next) => {
const ctx = context as MSTeamsTurnContext;
const membersAdded = ctx.activity?.membersAdded ?? [];
const botId = ctx.activity?.recipient?.id;
const msteamsCfg = deps.cfg.channels?.msteams;
for (const member of membersAdded) {
if (member.id === botId) {
// Bot was added to a conversation — send welcome card if configured.
const conversationType =
normalizeOptionalLowercaseString(ctx.activity?.conversation?.conversationType) ??
"personal";
const isPersonal = conversationType === "personal";
if (isPersonal && msteamsCfg?.welcomeCard !== false) {
const botName = ctx.activity?.recipient?.name ?? undefined;
const card = buildWelcomeCard({
botName,
promptStarters: msteamsCfg?.promptStarters,
});
try {
await ctx.sendActivity({
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
});
deps.log.info("sent welcome card");
} catch (err) {
deps.log.debug?.("failed to send welcome card", { error: formatUnknownError(err) });
}
} else if (!isPersonal && msteamsCfg?.groupWelcomeCard === true) {
const botName = ctx.activity?.recipient?.name ?? undefined;
try {
await ctx.sendActivity(buildGroupWelcomeText(botName));
deps.log.info("sent group welcome message");
} catch (err) {
deps.log.debug?.("failed to send group welcome", { error: formatUnknownError(err) });
}
} else {
deps.log.debug?.("skipping welcome (disabled by config or conversation type)");
}
} else {
deps.log.debug?.("member added", { member: member.id });
}
}
await next();
});
handler.onReactionsAdded(async (context, next) => {
try {
await handleReaction(context as MSTeamsTurnContext, "added");
} catch (err) {
deps.runtime.error(`msteams reaction handler failed: ${String(err)}`);
}
await next();
});
handler.onReactionsRemoved(async (context, next) => {
try {
await handleReaction(context as MSTeamsTurnContext, "removed");
} catch (err) {
deps.runtime.error(`msteams reaction handler failed: ${String(err)}`);
}
await next();
});
return handler;
}