mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
4e0cc187a2
* 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
271 lines
8.7 KiB
TypeScript
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;
|
|
}
|