fix: msteams attachments + plugin prompt hints

Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-22 03:27:26 +00:00
parent 5fe8c4ab8c
commit 0f7f7bb95f
50 changed files with 2739 additions and 174 deletions

View File

@@ -1,13 +1,35 @@
import {
isSilentReplyText,
loadWebMedia,
type MSTeamsReplyStyle,
type ReplyPayload,
SILENT_REPLY_TOKEN,
} from "clawdbot/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import {
getDriveItemProperties,
uploadAndShareOneDrive,
uploadAndShareSharePoint,
} from "./graph-upload.js";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
import { getMSTeamsRuntime } from "./runtime.js";
/**
* MSTeams-specific media size limit (100MB).
* Higher than the default because OneDrive upload handles large files well.
*/
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
/**
* Threshold for large files that require FileConsentCard flow in personal chats.
* Files >= 4MB use consent flow; smaller images can use inline base64.
*/
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
};
@@ -41,6 +63,15 @@ export type MSTeamsReplyRenderOptions = {
mediaMode?: "split" | "inline";
};
/**
* A rendered message that preserves media vs text distinction.
* When mediaUrl is present, it will be sent as a Bot Framework attachment.
*/
export type MSTeamsRenderedMessage = {
text?: string;
mediaUrl?: string;
};
export type MSTeamsSendRetryOptions = {
maxAttempts?: number;
baseDelayMs?: number;
@@ -90,16 +121,8 @@ export function buildConversationReference(
};
}
function extractMessageId(response: unknown): string | null {
if (!response || typeof response !== "object") return null;
if (!("id" in response)) return null;
const { id } = response as { id?: unknown };
if (typeof id !== "string" || !id) return null;
return id;
}
function pushTextMessages(
out: string[],
out: MSTeamsRenderedMessage[],
text: string,
opts: {
chunkText: boolean;
@@ -111,16 +134,17 @@ function pushTextMessages(
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push(trimmed);
out.push({ text: trimmed });
}
return;
}
const trimmed = text.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
out.push(trimmed);
out.push({ text: trimmed });
}
function clampMs(value: number, maxMs: number): number {
if (!Number.isFinite(value) || value < 0) return 0;
return Math.min(value, maxMs);
@@ -167,8 +191,8 @@ function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>
export function renderReplyPayloadsToMessages(
replies: ReplyPayload[],
options: MSTeamsReplyRenderOptions,
): string[] {
const out: string[] = [];
): MSTeamsRenderedMessage[] {
const out: MSTeamsRenderedMessage[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split";
@@ -185,8 +209,17 @@ export function renderReplyPayloadsToMessages(
}
if (mediaMode === "inline") {
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
pushTextMessages(out, combined, { chunkText, chunkLimit });
// For inline mode, combine text with first media as attachment
const firstMedia = mediaList[0];
if (firstMedia) {
out.push({ text: text || undefined, mediaUrl: firstMedia });
// Additional media URLs as separate messages
for (let i = 1; i < mediaList.length; i++) {
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
}
} else {
pushTextMessages(out, text, { chunkText, chunkLimit });
}
continue;
}
@@ -194,26 +227,142 @@ export function renderReplyPayloadsToMessages(
pushTextMessages(out, text, { chunkText, chunkLimit });
for (const mediaUrl of mediaList) {
if (!mediaUrl) continue;
out.push(mediaUrl);
out.push({ mediaUrl });
}
}
return out;
}
async function buildActivity(
msg: MSTeamsRenderedMessage,
conversationRef: StoredConversationReference,
tokenProvider?: MSTeamsAccessTokenProvider,
sharePointSiteId?: string,
mediaMaxBytes?: number,
): Promise<Record<string, unknown>> {
const activity: Record<string, unknown> = { type: "message" };
if (msg.text) {
activity.text = msg.text;
}
if (msg.mediaUrl) {
let contentUrl = msg.mediaUrl;
let contentType = await getMimeType(msg.mediaUrl);
let fileName = await extractFilename(msg.mediaUrl);
if (isLocalPath(msg.mediaUrl)) {
const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES;
const media = await loadWebMedia(msg.mediaUrl, maxBytes);
contentType = media.contentType ?? contentType;
fileName = media.fileName ?? fileName;
// Determine conversation type and file type
// Teams only accepts base64 data URLs for images
const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
const isPersonal = conversationType === "personal";
const isImage = contentType?.startsWith("image/") ?? false;
if (requiresFileConsent({
conversationType,
contentType,
bufferSize: media.buffer.length,
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
})) {
// Large file or non-image in personal chat: use FileConsentCard flow
const conversationId = conversationRef.conversation?.id ?? "unknown";
const { activity: consentActivity } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType },
conversationId,
description: msg.text || undefined,
});
// Return the consent activity (caller sends it)
return consentActivity;
}
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
// Non-image in group chat/channel with SharePoint site configured:
// Upload to SharePoint and use native file card attachment
const chatId = conversationRef.conversation?.id;
// Upload to SharePoint
const uploaded = await uploadAndShareSharePoint({
buffer: media.buffer,
filename: fileName,
contentType,
tokenProvider,
siteId: sharePointSiteId,
chatId: chatId ?? undefined,
usePerUserSharing: conversationType === "groupchat",
});
// Get driveItem properties needed for native file card attachment
const driveItem = await getDriveItemProperties({
siteId: sharePointSiteId,
itemId: uploaded.itemId,
tokenProvider,
});
// Build native Teams file card attachment
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
activity.attachments = [fileCardAttachment];
return activity;
}
if (!isPersonal && !isImage && tokenProvider) {
// Fallback: no SharePoint site configured, try OneDrive upload
const uploaded = await uploadAndShareOneDrive({
buffer: media.buffer,
filename: fileName,
contentType,
tokenProvider,
});
// Bot Framework doesn't support "reference" attachment type for sending
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
return activity;
}
// Image (any chat): use base64 (works for images in all conversation types)
const base64 = media.buffer.toString("base64");
contentUrl = `data:${media.contentType};base64,${base64}`;
}
activity.attachments = [
{
name: fileName,
contentType,
contentUrl,
},
];
}
return activity;
}
export async function sendMSTeamsMessages(params: {
replyStyle: MSTeamsReplyStyle;
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
context?: SendContext;
messages: string[];
messages: MSTeamsRenderedMessage[];
retry?: false | MSTeamsSendRetryOptions;
onRetry?: (event: MSTeamsSendRetryEvent) => void;
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
tokenProvider?: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
/** Max media size in bytes. Default: 100MB. */
mediaMaxBytes?: number;
}): Promise<string[]> {
const messages = params.messages
.map((m) => (typeof m === "string" ? m : String(m)))
.filter((m) => m.trim().length > 0);
const messages = params.messages.filter(
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
);
if (messages.length === 0) return [];
const retryOptions = resolveRetryOptions(params.retry);
@@ -259,10 +408,9 @@ export async function sendMSTeamsMessages(params: {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity({
type: "message",
text: message,
}),
await ctx.sendActivity(
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
@@ -281,10 +429,9 @@ export async function sendMSTeamsMessages(params: {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity({
type: "message",
text: message,
}),
await ctx.sendActivity(
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");