openclaw/extensions/msteams/src/monitor-handler.ts

407 lines
14 KiB
TypeScript

import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
import { normalizeMSTeamsConversationId } from "./inbound.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
import type { MSTeamsPollStore } from "./polls.js";
import { withRevokedProxyFallback } from "./revoked-context.js";
import { getMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
import { buildGroupWelcomeText, buildWelcomeCard } from "./welcome-card.js";
export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
export type MSTeamsActivityHandler = {
onMessage: (
handler: (context: unknown, next: () => Promise<void>) => Promise<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) => Promise<void>;
};
export type MSTeamsMessageHandlerDeps = {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
appId: string;
adapter: MSTeamsAdapter;
tokenProvider: MSTeamsAccessTokenProvider;
textLimit: number;
mediaMaxBytes: number;
conversationStore: MSTeamsConversationStore;
pollStore: MSTeamsPollStore;
log: MSTeamsMonitorLogger;
};
/**
* Handle fileConsent/invoke activities for large file uploads.
*/
async function handleFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise<boolean> {
const expiredUploadMessage =
"The file upload request has expired. Please try sending the file again.";
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
}
const consentResponse = parseFileConsentInvoke(activity);
if (!consentResponse) {
log.debug?.("invalid file consent invoke", { value: activity.value });
return false;
}
const uploadId =
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
log.info("file consent conversation mismatch", {
uploadId,
expectedConversationId: pendingConversationId,
receivedConversationId: invokeConversationId || undefined,
});
if (consentResponse.action === "accept") {
await context.sendActivity(expiredUploadMessage);
}
return true;
}
}
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
if (pendingFile) {
log.debug?.("user accepted file consent, uploading", {
uploadId,
filename: pendingFile.filename,
size: pendingFile.buffer.length,
});
try {
// Upload file to the provided URL
await uploadToConsentUrl({
url: consentResponse.uploadInfo.uploadUrl,
buffer: pendingFile.buffer,
contentType: pendingFile.contentType,
});
// Send confirmation card
const fileInfoCard = buildFileInfoCard({
filename: consentResponse.uploadInfo.name,
contentUrl: consentResponse.uploadInfo.contentUrl,
uniqueId: consentResponse.uploadInfo.uniqueId,
fileType: consentResponse.uploadInfo.fileType,
});
await context.sendActivity({
type: "message",
attachments: [fileInfoCard],
});
log.info("file upload complete", {
uploadId,
filename: consentResponse.uploadInfo.name,
uniqueId: consentResponse.uploadInfo.uniqueId,
});
} catch (err) {
log.debug?.("file upload failed", { uploadId, error: String(err) });
await context.sendActivity(`File upload failed: ${String(err)}`);
} finally {
removePendingUpload(uploadId);
}
} else {
log.debug?.("pending file not found for consent", { uploadId });
await context.sendActivity(expiredUploadMessage);
}
} else {
// User declined
log.debug?.("user declined file consent", { uploadId });
removePendingUpload(uploadId);
}
return true;
}
/**
* Parse and handle feedback invoke activities (thumbs up/down).
* Returns true if the activity was a feedback invoke, false otherwise.
*/
async function handleFeedbackInvoke(
context: MSTeamsTurnContext,
deps: MSTeamsMessageHandlerDeps,
): Promise<boolean> {
const activity = context.activity;
const value = activity.value as
| {
actionName?: string;
actionValue?: { reaction?: string; feedback?: string };
replyToId?: string;
}
| undefined;
if (!value) {
return false;
}
// Teams feedback invoke format: actionName="feedback", actionValue.reaction="like"|"dislike"
if (value.actionName !== "feedback") {
return false;
}
const reaction = value.actionValue?.reaction;
if (reaction !== "like" && reaction !== "dislike") {
deps.log.debug?.("ignoring feedback with unknown reaction", { reaction });
return false;
}
const msteamsCfg = deps.cfg.channels?.msteams;
if (msteamsCfg?.feedbackEnabled === false) {
deps.log.debug?.("feedback handling disabled");
return true; // Still consume the invoke
}
// Extract user comment from the nested JSON string
let userComment: string | undefined;
if (value.actionValue?.feedback) {
try {
const parsed = JSON.parse(value.actionValue.feedback) as { feedbackText?: string };
userComment = parsed.feedbackText || undefined;
} catch {
// Best effort — feedback text is optional
}
}
// Strip ;messageid=... suffix to match the normalized ID used by the message handler.
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "unknown");
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
const messageId = value.replyToId ?? activity.replyToId ?? "unknown";
const isNegative = reaction === "dislike";
// Route feedback using the same chat-type logic as normal messages
// so session keys, agent IDs, and transcript paths match.
const convType = activity.conversation?.conversationType?.toLowerCase();
const isDirectMessage = convType === "personal" || (!convType && !activity.conversation?.isGroup);
const isChannel = convType === "channel";
const core = getMSTeamsRuntime();
const route = core.channel.routing.resolveAgentRoute({
cfg: deps.cfg,
channel: "msteams",
peer: {
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
id: isDirectMessage ? senderId : conversationId,
},
});
// Log feedback event to session JSONL
const feedbackEvent = buildFeedbackEvent({
messageId,
value: isNegative ? "negative" : "positive",
comment: userComment,
sessionKey: route.sessionKey,
agentId: route.agentId,
conversationId,
});
deps.log.info("received feedback", {
value: feedbackEvent.value,
messageId,
conversationId,
hasComment: Boolean(userComment),
});
// Write feedback event to session transcript
try {
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
agentId: route.agentId,
});
const fs = await import("node:fs/promises");
const pathMod = await import("node:path");
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
const transcriptFile = pathMod.join(storePath, `${safeKey}.jsonl`);
await fs.appendFile(transcriptFile, JSON.stringify(feedbackEvent) + "\n", "utf-8").catch(() => {
// Best effort — transcript dir may not exist yet
});
} catch {
// Best effort
}
// Build conversation reference for proactive messages (ack + reflection follow-up)
const conversationRef = {
activityId: activity.id,
user: {
id: activity.from?.id,
name: activity.from?.name,
aadObjectId: activity.from?.aadObjectId,
},
agent: activity.recipient
? { id: activity.recipient.id, name: activity.recipient.name }
: undefined,
bot: activity.recipient
? { id: activity.recipient.id, name: activity.recipient.name }
: undefined,
conversation: {
id: conversationId,
conversationType: activity.conversation?.conversationType,
tenantId: activity.conversation?.tenantId,
},
channelId: activity.channelId ?? "msteams",
serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
// For negative feedback, trigger background reflection (fire-and-forget).
// No ack message — the reflection follow-up serves as the acknowledgement.
// Sending anything during the invoke handler causes "unable to reach app" errors.
if (isNegative && msteamsCfg?.feedbackReflection !== false) {
// Note: thumbedDownResponse is not populated here because we don't cache
// sent message text. The agent still has full session context for reflection
// since the reflection runs in the same session. The user comment (if any)
// provides additional signal.
runFeedbackReflection({
cfg: deps.cfg,
adapter: deps.adapter,
appId: deps.appId,
conversationRef,
sessionKey: route.sessionKey,
agentId: route.agentId,
conversationId,
feedbackMessageId: messageId,
userComment,
log: deps.log,
}).catch((err) => {
deps.log.error("feedback reflection failed", { error: String(err) });
});
}
return true;
}
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
handler: T,
deps: MSTeamsMessageHandlerDeps,
): T {
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
// Wrap the original run method to intercept invokes
const originalRun = handler.run;
if (originalRun) {
handler.run = async (context: unknown) => {
const ctx = context as MSTeamsTurnContext;
// Handle file consent invokes before passing to normal flow
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
// Send invoke response IMMEDIATELY to prevent Teams timeout
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
try {
await withRevokedProxyFallback({
run: async () => await handleFileConsentInvoke(ctx, deps.log),
onRevoked: async () => true,
onRevokedLog: () => {
deps.log.debug?.(
"turn context revoked during file consent invoke; skipping delayed response",
);
},
});
} catch (err) {
deps.log.debug?.("file consent handler error", { error: String(err) });
}
return;
}
// Handle feedback invokes (thumbs up/down on AI-generated messages).
// Just return after handling — the process() handler sends HTTP 200 automatically.
// Do NOT call sendActivity with invokeResponse; our custom adapter would POST
// a new activity to Bot Framework instead of responding to the HTTP request.
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "message/submitAction") {
const handled = await handleFeedbackInvoke(ctx, deps);
if (handled) {
return;
}
}
return originalRun.call(handler, context);
};
}
handler.onMessage(async (context, next) => {
try {
await handleTeamsMessage(context as MSTeamsTurnContext);
} catch (err) {
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
}
await next();
});
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 =
ctx.activity?.conversation?.conversationType?.toLowerCase() ?? "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: String(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: String(err) });
}
} else {
deps.log.debug?.("skipping welcome (disabled by config or conversation type)");
}
} else {
deps.log.debug?.("member added", { member: member.id });
}
}
await next();
});
return handler;
}