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; }; export type MSTeamsActivityHandler = { onMessage: ( handler: (context: unknown, next: () => Promise) => Promise, ) => MSTeamsActivityHandler; onMembersAdded: ( handler: (context: unknown, next: () => Promise) => Promise, ) => MSTeamsActivityHandler; onReactionsAdded: ( handler: (context: unknown, next: () => Promise) => Promise, ) => MSTeamsActivityHandler; onReactionsRemoved: ( handler: (context: unknown, next: () => Promise) => Promise, ) => MSTeamsActivityHandler; run?: (context: unknown) => Promise; }; 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 { 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 { 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( 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; }