@openclaw/msteams 2026.5.2 → 2026.5.3-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +3 -0
- package/dist/channel-D7hdreTh.js +984 -0
- package/dist/channel-config-api.js +2 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-BC1ruIfN.js +573 -0
- package/dist/config-schema-B8QezH6t.js +15 -0
- package/dist/contract-api.js +2 -0
- package/dist/graph-users-9uQJepqr.js +1354 -0
- package/dist/index.js +22 -0
- package/dist/oauth-BWJyilR1.js +114 -0
- package/dist/oauth.token-xxpoLWy5.js +115 -0
- package/dist/policy-DTnU2GR7.js +142 -0
- package/dist/probe-D_H8yFps.js +2194 -0
- package/dist/resolve-allowlist-D41JSziq.js +219 -0
- package/dist/runtime-api-DV1iVMn1.js +28 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-BuoEXmPS.js +35 -0
- package/dist/secret-contract-api.js +2 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +64 -0
- package/dist/setup-surface-BLkFQYIQ.js +313 -0
- package/dist/src-CFp1QpFd.js +4064 -0
- package/dist/test-api.js +2 -0
- package/package.json +14 -6
- package/api.ts +0 -3
- package/channel-config-api.ts +0 -1
- package/channel-plugin-api.ts +0 -2
- package/config-api.ts +0 -4
- package/contract-api.ts +0 -4
- package/index.ts +0 -20
- package/runtime-api.ts +0 -73
- package/secret-contract-api.ts +0 -5
- package/setup-entry.ts +0 -13
- package/setup-plugin-api.ts +0 -3
- package/src/ai-entity.ts +0 -7
- package/src/approval-auth.ts +0 -44
- package/src/attachments/bot-framework.test.ts +0 -461
- package/src/attachments/bot-framework.ts +0 -362
- package/src/attachments/download.ts +0 -311
- package/src/attachments/graph.test.ts +0 -416
- package/src/attachments/graph.ts +0 -484
- package/src/attachments/html.ts +0 -122
- package/src/attachments/payload.ts +0 -14
- package/src/attachments/remote-media.test.ts +0 -137
- package/src/attachments/remote-media.ts +0 -112
- package/src/attachments/shared.test.ts +0 -530
- package/src/attachments/shared.ts +0 -626
- package/src/attachments/types.ts +0 -47
- package/src/attachments.graph.test.ts +0 -342
- package/src/attachments.helpers.test.ts +0 -246
- package/src/attachments.test-helpers.ts +0 -17
- package/src/attachments.test.ts +0 -687
- package/src/attachments.ts +0 -18
- package/src/block-streaming-config.test.ts +0 -61
- package/src/channel-api.ts +0 -1
- package/src/channel.actions.test.ts +0 -742
- package/src/channel.directory.test.ts +0 -200
- package/src/channel.runtime.ts +0 -56
- package/src/channel.setup.ts +0 -77
- package/src/channel.test.ts +0 -128
- package/src/channel.ts +0 -1136
- package/src/config-schema.ts +0 -6
- package/src/config-ui-hints.ts +0 -12
- package/src/conversation-store-fs.test.ts +0 -74
- package/src/conversation-store-fs.ts +0 -149
- package/src/conversation-store-helpers.test.ts +0 -202
- package/src/conversation-store-helpers.ts +0 -105
- package/src/conversation-store-memory.ts +0 -51
- package/src/conversation-store.shared.test.ts +0 -225
- package/src/conversation-store.ts +0 -71
- package/src/directory-live.test.ts +0 -156
- package/src/directory-live.ts +0 -111
- package/src/doctor.ts +0 -27
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -246
- package/src/feedback-reflection-prompt.ts +0 -117
- package/src/feedback-reflection-store.ts +0 -114
- package/src/feedback-reflection.test.ts +0 -237
- package/src/feedback-reflection.ts +0 -283
- package/src/file-consent-helpers.test.ts +0 -326
- package/src/file-consent-helpers.ts +0 -126
- package/src/file-consent-invoke.ts +0 -150
- package/src/file-consent.test.ts +0 -363
- package/src/file-consent.ts +0 -287
- package/src/graph-chat.ts +0 -55
- package/src/graph-group-management.test.ts +0 -318
- package/src/graph-group-management.ts +0 -168
- package/src/graph-members.test.ts +0 -89
- package/src/graph-members.ts +0 -48
- package/src/graph-messages.actions.test.ts +0 -243
- package/src/graph-messages.read.test.ts +0 -391
- package/src/graph-messages.search.test.ts +0 -213
- package/src/graph-messages.test-helpers.ts +0 -50
- package/src/graph-messages.ts +0 -534
- package/src/graph-teams.test.ts +0 -215
- package/src/graph-teams.ts +0 -114
- package/src/graph-thread.test.ts +0 -246
- package/src/graph-thread.ts +0 -146
- package/src/graph-upload.test.ts +0 -258
- package/src/graph-upload.ts +0 -531
- package/src/graph-users.ts +0 -29
- package/src/graph.test.ts +0 -516
- package/src/graph.ts +0 -293
- package/src/inbound.test.ts +0 -221
- package/src/inbound.ts +0 -148
- package/src/index.ts +0 -4
- package/src/media-helpers.test.ts +0 -202
- package/src/media-helpers.ts +0 -105
- package/src/mentions.test.ts +0 -244
- package/src/mentions.ts +0 -114
- package/src/messenger.test.ts +0 -865
- package/src/messenger.ts +0 -605
- package/src/monitor-handler/access.ts +0 -125
- package/src/monitor-handler/inbound-media.test.ts +0 -289
- package/src/monitor-handler/inbound-media.ts +0 -180
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +0 -28
- package/src/monitor-handler/message-handler.authz.test.ts +0 -669
- package/src/monitor-handler/message-handler.dm-media.test.ts +0 -54
- package/src/monitor-handler/message-handler.test-support.ts +0 -100
- package/src/monitor-handler/message-handler.thread-parent.test.ts +0 -223
- package/src/monitor-handler/message-handler.thread-session.test.ts +0 -77
- package/src/monitor-handler/message-handler.ts +0 -1000
- package/src/monitor-handler/reaction-handler.test.ts +0 -267
- package/src/monitor-handler/reaction-handler.ts +0 -210
- package/src/monitor-handler/thread-session.ts +0 -17
- package/src/monitor-handler.adaptive-card.test.ts +0 -162
- package/src/monitor-handler.feedback-authz.test.ts +0 -314
- package/src/monitor-handler.file-consent.test.ts +0 -423
- package/src/monitor-handler.sso.test.ts +0 -563
- package/src/monitor-handler.test-helpers.ts +0 -180
- package/src/monitor-handler.ts +0 -534
- package/src/monitor-handler.types.ts +0 -27
- package/src/monitor-types.ts +0 -6
- package/src/monitor.lifecycle.test.ts +0 -278
- package/src/monitor.test.ts +0 -119
- package/src/monitor.ts +0 -442
- package/src/oauth.flow.ts +0 -77
- package/src/oauth.shared.ts +0 -37
- package/src/oauth.test.ts +0 -305
- package/src/oauth.token.ts +0 -158
- package/src/oauth.ts +0 -130
- package/src/outbound.test.ts +0 -130
- package/src/outbound.ts +0 -71
- package/src/pending-uploads-fs.test.ts +0 -246
- package/src/pending-uploads-fs.ts +0 -235
- package/src/pending-uploads.test.ts +0 -173
- package/src/pending-uploads.ts +0 -121
- package/src/policy.test.ts +0 -240
- package/src/policy.ts +0 -262
- package/src/polls-store-memory.ts +0 -32
- package/src/polls.test.ts +0 -160
- package/src/polls.ts +0 -323
- package/src/presentation.ts +0 -68
- package/src/probe.test.ts +0 -77
- package/src/probe.ts +0 -132
- package/src/reply-dispatcher.test.ts +0 -437
- package/src/reply-dispatcher.ts +0 -346
- package/src/reply-stream-controller.test.ts +0 -235
- package/src/reply-stream-controller.ts +0 -147
- package/src/resolve-allowlist.test.ts +0 -250
- package/src/resolve-allowlist.ts +0 -309
- package/src/revoked-context.ts +0 -17
- package/src/runtime.ts +0 -9
- package/src/sdk-types.ts +0 -59
- package/src/sdk.test.ts +0 -666
- package/src/sdk.ts +0 -884
- package/src/secret-contract.ts +0 -49
- package/src/secret-input.ts +0 -7
- package/src/send-context.ts +0 -231
- package/src/send.test.ts +0 -493
- package/src/send.ts +0 -637
- package/src/sent-message-cache.test.ts +0 -15
- package/src/sent-message-cache.ts +0 -56
- package/src/session-route.ts +0 -40
- package/src/setup-core.ts +0 -160
- package/src/setup-surface.test.ts +0 -202
- package/src/setup-surface.ts +0 -320
- package/src/sso-token-store.test.ts +0 -72
- package/src/sso-token-store.ts +0 -166
- package/src/sso.ts +0 -300
- package/src/storage.ts +0 -25
- package/src/store-fs.ts +0 -44
- package/src/streaming-message.test.ts +0 -262
- package/src/streaming-message.ts +0 -297
- package/src/test-runtime.ts +0 -16
- package/src/thread-parent-context.test.ts +0 -224
- package/src/thread-parent-context.ts +0 -159
- package/src/token-response.ts +0 -11
- package/src/token.test.ts +0 -259
- package/src/token.ts +0 -195
- package/src/user-agent.test.ts +0 -86
- package/src/user-agent.ts +0 -53
- package/src/webhook-timeouts.ts +0 -27
- package/src/welcome-card.test.ts +0 -81
- package/src/welcome-card.ts +0 -57
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,4064 @@
|
|
|
1
|
+
import { A as resolveDmGroupAccessWithLists, F as summarizeMapping, L as getMSTeamsRuntime, N as resolveSenderScopedGroupPolicy, O as resolveChannelMediaMaxBytes, R as getOptionalMSTeamsRuntime, S as mergeAllowlist, T as readStoreAllowFromForDmPolicy, a as buildMediaPayload, c as createChannelPairingController, f as dispatchReplyFromConfigWithSettledDispatcher$1, j as resolveEffectiveAllowFromLists, k as resolveDefaultGroupPolicy, l as createChannelReplyPipeline, n as DEFAULT_WEBHOOK_MAX_BODY_BYTES, p as evaluateSenderGroupAccessForPolicy$1, t as DEFAULT_ACCOUNT_ID, v as isDangerousNameMatchingEnabled, x as logTypingFailure, y as keepHttpServerTaskAlive } from "./runtime-api-DV1iVMn1.js";
|
|
2
|
+
import { A as ATTACHMENT_TAG_RE, B as isLikelyImageAttachment, C as loadMSTeamsSdkWithAuth, D as formatMSTeamsSendErrorHint, E as classifyMSTeamsSendError, F as estimateBase64DecodedBytes, G as resolveAttachmentFetchPolicy, H as isUrlAllowed, I as extractHtmlFromAttachment, J as safeFetchWithPolicy, K as resolveMediaSsrfPolicy, L as extractInlineImageCandidates, M as IMG_SRC_RE, N as applyAuthorizationHeaderForUrl, O as formatUnknownError, P as encodeGraphShareId, R as inferPlaceholder, S as createMSTeamsTokenProvider, T as ensureUserAgentHeader, U as normalizeContentType, V as isRecord, W as readNestedString, X as tryBuildGraphSharesUrlForSharedLink, Y as safeHostForUrl, _ as resolveMSTeamsStorePath, a as fetchGraphJson, b as createBotFrameworkJwtValidator, h as resolveMSTeamsCredentials, j as GRAPH_ROOT, q as resolveRequestUrl, w as buildUserAgent, x as createMSTeamsAdapter, z as isDownloadableAttachment } from "./graph-users-9uQJepqr.js";
|
|
3
|
+
import { c as resolveMSTeamsUserAllowlist, s as resolveMSTeamsChannelAllowlist } from "./resolve-allowlist-D41JSziq.js";
|
|
4
|
+
import { a as resolveMSTeamsRouteConfig, i as resolveMSTeamsReplyPolicy, n as resolveMSTeamsAllowlistMatch, t as isMSTeamsGroupAllowed } from "./policy-DTnU2GR7.js";
|
|
5
|
+
import { C as readJsonFile, S as createMSTeamsConversationStoreFs, T as writeJsonFile, _ as buildFileInfoCard, b as createMSTeamsPollStoreFs, c as renderReplyPayloadsToMessages, d as withRevokedProxyFallback, f as resolveGraphChatId, g as removePendingUploadFs, h as getPendingUploadFs, l as sendMSTeamsMessages, m as removePendingUpload, p as getPendingUpload, s as buildConversationReference, u as AI_GENERATED_ENTITY, v as parseFileConsentInvoke, w as withFileLock, x as extractMSTeamsPollVote, y as uploadToConsentUrl } from "./probe-D_H8yFps.js";
|
|
6
|
+
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
|
|
7
|
+
import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
|
8
|
+
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
9
|
+
import { readResponseWithLimit } from "openclaw/plugin-sdk/media-runtime";
|
|
10
|
+
import { dispatchReplyFromConfigWithSettledDispatcher, hasFinalInboundReplyDispatch, resolveInboundReplyDispatchCounts } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
|
11
|
+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
12
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
|
16
|
+
import { logInboundDrop, resolveInboundMentionDecision, resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-inbound";
|
|
17
|
+
import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
|
18
|
+
import { filterSupplementalContextItems, resolveChannelContextVisibilityMode, shouldIncludeSupplementalContext } from "openclaw/plugin-sdk/context-visibility-runtime";
|
|
19
|
+
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
|
20
|
+
import { DEFAULT_GROUP_HISTORY_LIMIT, buildPendingHistoryContextFromMap, recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history";
|
|
21
|
+
//#region extensions/msteams/src/feedback-reflection-prompt.ts
|
|
22
|
+
/** Max chars of the thumbed-down response to include in the reflection prompt. */
|
|
23
|
+
const MAX_RESPONSE_CHARS = 500;
|
|
24
|
+
function buildReflectionPrompt(params) {
|
|
25
|
+
const parts = ["A user indicated your previous response wasn't helpful."];
|
|
26
|
+
if (params.thumbedDownResponse) {
|
|
27
|
+
const truncated = params.thumbedDownResponse.length > MAX_RESPONSE_CHARS ? `${params.thumbedDownResponse.slice(0, MAX_RESPONSE_CHARS)}...` : params.thumbedDownResponse;
|
|
28
|
+
parts.push(`\nYour response was:\n> ${truncated}`);
|
|
29
|
+
}
|
|
30
|
+
if (params.userComment) parts.push(`\nUser's comment: "${params.userComment}"`);
|
|
31
|
+
parts.push("\nBriefly reflect: what could you improve? Consider tone, length, accuracy, relevance, and specificity. Reply with a single JSON object only, no markdown or prose, using this exact shape:\n{\"learning\":\"...\",\"followUp\":false,\"userMessage\":\"\"}\n- learning: a short internal adjustment note (1-2 sentences) for your future behavior in this conversation.\n- followUp: true only if the user needs a direct follow-up message.\n- userMessage: only the exact user-facing message to send; empty string when followUp is false.");
|
|
32
|
+
return parts.join("\n");
|
|
33
|
+
}
|
|
34
|
+
function parseBooleanLike(value) {
|
|
35
|
+
if (typeof value === "boolean") return value;
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
const normalized = normalizeOptionalLowercaseString(value);
|
|
38
|
+
if (normalized === "true" || normalized === "yes") return true;
|
|
39
|
+
if (normalized === "false" || normalized === "no") return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function parseStructuredReflectionValue(value) {
|
|
43
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
44
|
+
const candidate = value;
|
|
45
|
+
const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : void 0;
|
|
46
|
+
if (!learning) return null;
|
|
47
|
+
return {
|
|
48
|
+
learning,
|
|
49
|
+
followUp: parseBooleanLike(candidate.followUp) ?? false,
|
|
50
|
+
userMessage: typeof candidate.userMessage === "string" && candidate.userMessage.trim() ? candidate.userMessage.trim() : void 0
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function parseReflectionResponse(text) {
|
|
54
|
+
const trimmed = text.trim();
|
|
55
|
+
if (!trimmed) return null;
|
|
56
|
+
const candidates = [trimmed, ...trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []];
|
|
57
|
+
for (const candidateText of candidates) {
|
|
58
|
+
const candidate = candidateText.trim();
|
|
59
|
+
if (!candidate) continue;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
|
|
62
|
+
if (parsed) return parsed;
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
learning: trimmed,
|
|
67
|
+
followUp: false
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Tracks last reflection time per session to enforce cooldown. */
|
|
71
|
+
const lastReflectionBySession = /* @__PURE__ */ new Map();
|
|
72
|
+
/** Maximum cooldown entries before pruning expired ones. */
|
|
73
|
+
const MAX_COOLDOWN_ENTRIES = 500;
|
|
74
|
+
function legacySanitizeSessionKey(sessionKey) {
|
|
75
|
+
return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
76
|
+
}
|
|
77
|
+
function encodeSessionKey(sessionKey) {
|
|
78
|
+
return Buffer.from(sessionKey, "utf8").toString("base64url");
|
|
79
|
+
}
|
|
80
|
+
function resolveLearningsFilePath(storePath, sessionKey) {
|
|
81
|
+
return `${storePath}/${encodeSessionKey(sessionKey)}.learnings.json`;
|
|
82
|
+
}
|
|
83
|
+
function resolveLegacyLearningsFilePath(storePath, sessionKey) {
|
|
84
|
+
return `${storePath}/${legacySanitizeSessionKey(sessionKey)}.learnings.json`;
|
|
85
|
+
}
|
|
86
|
+
async function readLearningsFile(filePath) {
|
|
87
|
+
try {
|
|
88
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
89
|
+
const parsed = JSON.parse(content);
|
|
90
|
+
return {
|
|
91
|
+
exists: true,
|
|
92
|
+
learnings: Array.isArray(parsed) ? parsed : []
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return {
|
|
96
|
+
exists: false,
|
|
97
|
+
learnings: []
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Prune expired cooldown entries to prevent unbounded memory growth. */
|
|
102
|
+
function pruneExpiredCooldowns(cooldownMs) {
|
|
103
|
+
if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) return;
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
for (const [key, time] of lastReflectionBySession) if (now - time >= cooldownMs) lastReflectionBySession.delete(key);
|
|
106
|
+
}
|
|
107
|
+
/** Check if a reflection is allowed (cooldown not active). */
|
|
108
|
+
function isReflectionAllowed(sessionKey, cooldownMs) {
|
|
109
|
+
const cooldown = cooldownMs ?? 3e5;
|
|
110
|
+
const lastTime = lastReflectionBySession.get(sessionKey);
|
|
111
|
+
if (lastTime == null) return true;
|
|
112
|
+
return Date.now() - lastTime >= cooldown;
|
|
113
|
+
}
|
|
114
|
+
/** Record that a reflection was run for a session. */
|
|
115
|
+
function recordReflectionTime(sessionKey, cooldownMs) {
|
|
116
|
+
lastReflectionBySession.set(sessionKey, Date.now());
|
|
117
|
+
pruneExpiredCooldowns(cooldownMs ?? 3e5);
|
|
118
|
+
}
|
|
119
|
+
/** Store a learning derived from feedback reflection in a session companion file. */
|
|
120
|
+
async function storeSessionLearning(params) {
|
|
121
|
+
const learningsFile = resolveLearningsFilePath(params.storePath, params.sessionKey);
|
|
122
|
+
const legacyLearningsFile = resolveLegacyLearningsFilePath(params.storePath, params.sessionKey);
|
|
123
|
+
const { exists, learnings: existingLearnings } = await readLearningsFile(learningsFile);
|
|
124
|
+
const { learnings: legacyLearnings } = exists || legacyLearningsFile === learningsFile ? { learnings: [] } : await readLearningsFile(legacyLearningsFile);
|
|
125
|
+
let learnings = exists ? existingLearnings : legacyLearnings;
|
|
126
|
+
learnings.push(params.learning);
|
|
127
|
+
if (learnings.length > 10) learnings = learnings.slice(-10);
|
|
128
|
+
await fs.mkdir(path.dirname(learningsFile), { recursive: true });
|
|
129
|
+
await fs.writeFile(learningsFile, JSON.stringify(learnings, null, 2), "utf-8");
|
|
130
|
+
if (!exists && legacyLearningsFile !== learningsFile) await fs.rm(legacyLearningsFile, { force: true }).catch(() => void 0);
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region extensions/msteams/src/feedback-reflection.ts
|
|
134
|
+
/**
|
|
135
|
+
* Background reflection triggered by negative user feedback (thumbs-down).
|
|
136
|
+
*
|
|
137
|
+
* Flow:
|
|
138
|
+
* 1. User thumbs-down -> invoke handler acks immediately
|
|
139
|
+
* 2. This module runs in the background (fire-and-forget)
|
|
140
|
+
* 3. Reads recent session context
|
|
141
|
+
* 4. Sends a synthetic reflection prompt to the agent
|
|
142
|
+
* 5. Stores the derived learning in session
|
|
143
|
+
* 6. Optionally sends a proactive follow-up to the user
|
|
144
|
+
*/
|
|
145
|
+
function buildFeedbackEvent(params) {
|
|
146
|
+
return {
|
|
147
|
+
type: "custom",
|
|
148
|
+
event: "feedback",
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
messageId: params.messageId,
|
|
151
|
+
value: params.value,
|
|
152
|
+
comment: params.comment,
|
|
153
|
+
sessionKey: params.sessionKey,
|
|
154
|
+
agentId: params.agentId,
|
|
155
|
+
conversationId: params.conversationId
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function buildReflectionContext(params) {
|
|
159
|
+
const core = getMSTeamsRuntime();
|
|
160
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
|
|
161
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
162
|
+
channel: "Teams",
|
|
163
|
+
from: "system",
|
|
164
|
+
body: params.reflectionPrompt,
|
|
165
|
+
envelope: envelopeOptions
|
|
166
|
+
});
|
|
167
|
+
return { ctxPayload: core.channel.reply.finalizeInboundContext({
|
|
168
|
+
Body: body,
|
|
169
|
+
BodyForAgent: params.reflectionPrompt,
|
|
170
|
+
RawBody: params.reflectionPrompt,
|
|
171
|
+
CommandBody: params.reflectionPrompt,
|
|
172
|
+
From: `msteams:system:${params.conversationId}`,
|
|
173
|
+
To: `conversation:${params.conversationId}`,
|
|
174
|
+
SessionKey: params.sessionKey,
|
|
175
|
+
ChatType: "direct",
|
|
176
|
+
SenderName: "system",
|
|
177
|
+
SenderId: "system",
|
|
178
|
+
Provider: "msteams",
|
|
179
|
+
Surface: "msteams",
|
|
180
|
+
Timestamp: Date.now(),
|
|
181
|
+
WasMentioned: true,
|
|
182
|
+
CommandAuthorized: false,
|
|
183
|
+
OriginatingChannel: "msteams",
|
|
184
|
+
OriginatingTo: `conversation:${params.conversationId}`
|
|
185
|
+
}) };
|
|
186
|
+
}
|
|
187
|
+
function createReflectionCaptureDispatcher(params) {
|
|
188
|
+
const core = getMSTeamsRuntime();
|
|
189
|
+
let response = "";
|
|
190
|
+
const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
191
|
+
deliver: async (payload) => {
|
|
192
|
+
if (payload.text) response += (response ? "\n" : "") + payload.text;
|
|
193
|
+
},
|
|
194
|
+
typingCallbacks: {
|
|
195
|
+
onReplyStart: async () => {},
|
|
196
|
+
onIdle: () => {},
|
|
197
|
+
onCleanup: () => {}
|
|
198
|
+
},
|
|
199
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
|
200
|
+
onError: (err) => {
|
|
201
|
+
params.log.debug?.("reflection reply error", { error: formatUnknownError(err) });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
dispatcher,
|
|
206
|
+
replyOptions,
|
|
207
|
+
readResponse: () => response
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function sendReflectionFollowUp(params) {
|
|
211
|
+
const proactiveRef = {
|
|
212
|
+
...buildConversationReference(params.conversationRef),
|
|
213
|
+
activityId: void 0
|
|
214
|
+
};
|
|
215
|
+
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
|
216
|
+
await ctx.sendActivity({
|
|
217
|
+
type: "message",
|
|
218
|
+
text: params.userMessage
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Run a background reflection after negative feedback.
|
|
224
|
+
* This is designed to be called fire-and-forget (don't await in the invoke handler).
|
|
225
|
+
*/
|
|
226
|
+
async function runFeedbackReflection(params) {
|
|
227
|
+
const { cfg, log, sessionKey } = params;
|
|
228
|
+
const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? 3e5;
|
|
229
|
+
if (!isReflectionAllowed(sessionKey, cooldownMs)) {
|
|
230
|
+
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const reflectionPrompt = buildReflectionPrompt({
|
|
234
|
+
thumbedDownResponse: params.thumbedDownResponse,
|
|
235
|
+
userComment: params.userComment
|
|
236
|
+
});
|
|
237
|
+
const storePath = getMSTeamsRuntime().channel.session.resolveStorePath(cfg.session?.store, { agentId: params.agentId });
|
|
238
|
+
const { ctxPayload } = buildReflectionContext({
|
|
239
|
+
cfg,
|
|
240
|
+
conversationId: params.conversationId,
|
|
241
|
+
sessionKey: params.sessionKey,
|
|
242
|
+
reflectionPrompt
|
|
243
|
+
});
|
|
244
|
+
const capture = createReflectionCaptureDispatcher({
|
|
245
|
+
cfg,
|
|
246
|
+
agentId: params.agentId,
|
|
247
|
+
log
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
await dispatchReplyFromConfigWithSettledDispatcher$1({
|
|
251
|
+
ctxPayload,
|
|
252
|
+
cfg,
|
|
253
|
+
dispatcher: capture.dispatcher,
|
|
254
|
+
onSettled: () => {},
|
|
255
|
+
replyOptions: capture.replyOptions
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log.error("reflection dispatch failed", { error: formatUnknownError(err) });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const reflectionResponse = capture.readResponse().trim();
|
|
262
|
+
if (!reflectionResponse) {
|
|
263
|
+
log.debug?.("reflection produced no output");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const parsedReflection = parseReflectionResponse(reflectionResponse);
|
|
267
|
+
if (!parsedReflection) {
|
|
268
|
+
log.debug?.("reflection produced no structured output");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
recordReflectionTime(sessionKey, cooldownMs);
|
|
272
|
+
log.info("reflection complete", {
|
|
273
|
+
sessionKey,
|
|
274
|
+
responseLength: reflectionResponse.length,
|
|
275
|
+
followUp: parsedReflection.followUp
|
|
276
|
+
});
|
|
277
|
+
try {
|
|
278
|
+
await storeSessionLearning({
|
|
279
|
+
storePath,
|
|
280
|
+
sessionKey: params.sessionKey,
|
|
281
|
+
learning: parsedReflection.learning
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
|
|
285
|
+
}
|
|
286
|
+
const conversationType = normalizeOptionalLowercaseString(params.conversationRef.conversation?.conversationType);
|
|
287
|
+
if (!(conversationType === "personal" && parsedReflection.followUp && Boolean(parsedReflection.userMessage))) {
|
|
288
|
+
if (parsedReflection.followUp && conversationType !== "personal") log.debug?.("skipping reflection follow-up outside direct message", {
|
|
289
|
+
sessionKey,
|
|
290
|
+
conversationType
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
await sendReflectionFollowUp({
|
|
296
|
+
adapter: params.adapter,
|
|
297
|
+
appId: params.appId,
|
|
298
|
+
conversationRef: params.conversationRef,
|
|
299
|
+
userMessage: parsedReflection.userMessage
|
|
300
|
+
});
|
|
301
|
+
log.info("sent reflection follow-up", { sessionKey });
|
|
302
|
+
} catch (err) {
|
|
303
|
+
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region extensions/msteams/src/inbound.ts
|
|
308
|
+
/**
|
|
309
|
+
* Decode common HTML entities to plain text.
|
|
310
|
+
*/
|
|
311
|
+
function decodeHtmlEntities(html) {
|
|
312
|
+
return html.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " ").replace(/&/g, "&");
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Strip HTML tags, preserving text content.
|
|
316
|
+
*/
|
|
317
|
+
function htmlToPlainText(html) {
|
|
318
|
+
return decodeHtmlEntities(html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim());
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Extract quote info from MS Teams HTML reply attachments.
|
|
322
|
+
* Teams wraps quoted content in a blockquote with itemtype="http://schema.skype.com/Reply".
|
|
323
|
+
*/
|
|
324
|
+
function extractMSTeamsQuoteInfo(attachments) {
|
|
325
|
+
for (const att of attachments) {
|
|
326
|
+
let content = "";
|
|
327
|
+
if (typeof att.content === "string") content = att.content;
|
|
328
|
+
else if (typeof att.content === "object" && att.content !== null) {
|
|
329
|
+
const record = att.content;
|
|
330
|
+
content = typeof record.text === "string" ? record.text : typeof record.body === "string" ? record.body : "";
|
|
331
|
+
}
|
|
332
|
+
if (!content) continue;
|
|
333
|
+
if (!content.includes("http://schema.skype.com/Reply")) continue;
|
|
334
|
+
const senderMatch = /<strong[^>]*itemprop=["']mri["'][^>]*>(.*?)<\/strong>/i.exec(content);
|
|
335
|
+
const sender = senderMatch?.[1] ? htmlToPlainText(senderMatch[1]) : void 0;
|
|
336
|
+
const bodyMatch = /<p[^>]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content);
|
|
337
|
+
const body = bodyMatch?.[1] ? htmlToPlainText(bodyMatch[1]) : void 0;
|
|
338
|
+
if (body) return {
|
|
339
|
+
sender: sender ?? "unknown",
|
|
340
|
+
body
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function normalizeMSTeamsConversationId(raw) {
|
|
345
|
+
return raw.split(";")[0] ?? raw;
|
|
346
|
+
}
|
|
347
|
+
function extractMSTeamsConversationMessageId(raw) {
|
|
348
|
+
if (!raw) return;
|
|
349
|
+
return (/(?:^|;)messageid=([^;]+)/i.exec(raw)?.[1]?.trim() ?? "") || void 0;
|
|
350
|
+
}
|
|
351
|
+
function parseMSTeamsActivityTimestamp(value) {
|
|
352
|
+
if (!value) return;
|
|
353
|
+
if (value instanceof Date) return value;
|
|
354
|
+
if (typeof value !== "string") return;
|
|
355
|
+
const date = new Date(value);
|
|
356
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
357
|
+
}
|
|
358
|
+
function stripMSTeamsMentionTags(text) {
|
|
359
|
+
return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Bot Framework uses 'a:xxx' conversation IDs for personal chats, but Graph API
|
|
363
|
+
* requires the '19:{userId}_{botAppId}@unq.gbl.spaces' format.
|
|
364
|
+
*
|
|
365
|
+
* This is the documented Graph API format for 1:1 chat thread IDs between a user
|
|
366
|
+
* and a bot/app. See Microsoft docs "Get chat between user and app":
|
|
367
|
+
* https://learn.microsoft.com/en-us/graph/api/userscopeteamsappinstallation-get-chat
|
|
368
|
+
*
|
|
369
|
+
* The format is only synthesized when the Bot Framework conversation ID starts with
|
|
370
|
+
* 'a:' (the opaque format used by BF but not recognized by Graph). If the ID already
|
|
371
|
+
* has the '19:...' Graph format, it is passed through unchanged.
|
|
372
|
+
*/
|
|
373
|
+
function translateMSTeamsDmConversationIdForGraph(params) {
|
|
374
|
+
const { isDirectMessage, conversationId, aadObjectId, appId } = params;
|
|
375
|
+
return isDirectMessage && conversationId.startsWith("a:") && aadObjectId && appId ? `19:${aadObjectId}_${appId}@unq.gbl.spaces` : conversationId;
|
|
376
|
+
}
|
|
377
|
+
function wasMSTeamsBotMentioned(activity) {
|
|
378
|
+
const botId = activity.recipient?.id;
|
|
379
|
+
if (!botId) return false;
|
|
380
|
+
return (activity.entities ?? []).some((e) => e.type === "mention" && e.mentioned?.id === botId);
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region extensions/msteams/src/file-consent-invoke.ts
|
|
384
|
+
/**
|
|
385
|
+
* Handle fileConsent/invoke activities for large file uploads.
|
|
386
|
+
*/
|
|
387
|
+
async function handleMSTeamsFileConsentInvoke(context, log) {
|
|
388
|
+
const expiredUploadMessage = "The file upload request has expired. Please try sending the file again.";
|
|
389
|
+
const activity = context.activity;
|
|
390
|
+
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") return false;
|
|
391
|
+
const consentResponse = parseFileConsentInvoke(activity);
|
|
392
|
+
if (!consentResponse) {
|
|
393
|
+
log.debug?.("invalid file consent invoke", { value: activity.value });
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
const uploadId = typeof consentResponse.context?.uploadId === "string" ? consentResponse.context.uploadId : void 0;
|
|
397
|
+
const inMemoryFile = getPendingUpload(uploadId);
|
|
398
|
+
const fsFile = inMemoryFile ? void 0 : await getPendingUploadFs(uploadId);
|
|
399
|
+
const pendingFile = inMemoryFile ?? fsFile;
|
|
400
|
+
if (pendingFile) {
|
|
401
|
+
const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
|
|
402
|
+
const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
|
|
403
|
+
if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
|
|
404
|
+
log.info("file consent conversation mismatch", {
|
|
405
|
+
uploadId,
|
|
406
|
+
expectedConversationId: pendingConversationId,
|
|
407
|
+
receivedConversationId: invokeConversationId || void 0
|
|
408
|
+
});
|
|
409
|
+
if (consentResponse.action === "accept") await context.sendActivity(expiredUploadMessage);
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (consentResponse.action === "accept" && consentResponse.uploadInfo) if (pendingFile) {
|
|
414
|
+
log.debug?.("user accepted file consent, uploading", {
|
|
415
|
+
uploadId,
|
|
416
|
+
filename: pendingFile.filename,
|
|
417
|
+
size: pendingFile.buffer.length
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
await uploadToConsentUrl({
|
|
421
|
+
url: consentResponse.uploadInfo.uploadUrl,
|
|
422
|
+
buffer: pendingFile.buffer,
|
|
423
|
+
contentType: pendingFile.contentType
|
|
424
|
+
});
|
|
425
|
+
const fileInfoCard = buildFileInfoCard({
|
|
426
|
+
filename: consentResponse.uploadInfo.name,
|
|
427
|
+
contentUrl: consentResponse.uploadInfo.contentUrl,
|
|
428
|
+
uniqueId: consentResponse.uploadInfo.uniqueId,
|
|
429
|
+
fileType: consentResponse.uploadInfo.fileType
|
|
430
|
+
});
|
|
431
|
+
if (!pendingFile.consentCardActivityId) await context.sendActivity({
|
|
432
|
+
type: "message",
|
|
433
|
+
attachments: [fileInfoCard]
|
|
434
|
+
});
|
|
435
|
+
if (pendingFile.consentCardActivityId) try {
|
|
436
|
+
await context.updateActivity({
|
|
437
|
+
id: pendingFile.consentCardActivityId,
|
|
438
|
+
type: "message",
|
|
439
|
+
attachments: [fileInfoCard]
|
|
440
|
+
});
|
|
441
|
+
} catch {
|
|
442
|
+
await context.sendActivity({
|
|
443
|
+
type: "message",
|
|
444
|
+
attachments: [fileInfoCard]
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
log.info("file upload complete", {
|
|
448
|
+
uploadId,
|
|
449
|
+
filename: consentResponse.uploadInfo.name,
|
|
450
|
+
uniqueId: consentResponse.uploadInfo.uniqueId
|
|
451
|
+
});
|
|
452
|
+
} catch (err) {
|
|
453
|
+
log.error("file upload failed", {
|
|
454
|
+
uploadId,
|
|
455
|
+
error: formatUnknownError(err)
|
|
456
|
+
});
|
|
457
|
+
await context.sendActivity("File upload failed. Please try again.");
|
|
458
|
+
} finally {
|
|
459
|
+
removePendingUpload(uploadId);
|
|
460
|
+
await removePendingUploadFs(uploadId);
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
log.debug?.("pending file not found for consent", { uploadId });
|
|
464
|
+
await context.sendActivity(expiredUploadMessage);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
log.debug?.("user declined file consent", { uploadId });
|
|
468
|
+
removePendingUpload(uploadId);
|
|
469
|
+
await removePendingUploadFs(uploadId);
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
async function respondToMSTeamsFileConsentInvoke(context, log) {
|
|
474
|
+
await context.sendActivity({
|
|
475
|
+
type: "invokeResponse",
|
|
476
|
+
value: { status: 200 }
|
|
477
|
+
});
|
|
478
|
+
try {
|
|
479
|
+
await withRevokedProxyFallback({
|
|
480
|
+
run: async () => await handleMSTeamsFileConsentInvoke(context, log),
|
|
481
|
+
onRevoked: async () => true,
|
|
482
|
+
onRevokedLog: () => {
|
|
483
|
+
log.debug?.("turn context revoked during file consent invoke; skipping delayed response");
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
} catch (err) {
|
|
487
|
+
log.debug?.("file consent handler error", { error: formatUnknownError(err) });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region extensions/msteams/src/monitor-handler/access.ts
|
|
492
|
+
async function resolveMSTeamsSenderAccess(params) {
|
|
493
|
+
const activity = params.activity;
|
|
494
|
+
const msteamsCfg = params.cfg.channels?.msteams;
|
|
495
|
+
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "unknown");
|
|
496
|
+
const convType = normalizeOptionalLowercaseString(activity.conversation?.conversationType);
|
|
497
|
+
const isDirectMessage = convType === "personal" || !convType && !activity.conversation?.isGroup;
|
|
498
|
+
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
|
|
499
|
+
const senderName = activity.from?.name ?? activity.from?.id ?? senderId;
|
|
500
|
+
const pairing = createChannelPairingController({
|
|
501
|
+
core: getMSTeamsRuntime(),
|
|
502
|
+
channel: "msteams",
|
|
503
|
+
accountId: DEFAULT_ACCOUNT_ID
|
|
504
|
+
});
|
|
505
|
+
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
|
|
506
|
+
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
|
|
507
|
+
provider: "msteams",
|
|
508
|
+
accountId: pairing.accountId,
|
|
509
|
+
dmPolicy,
|
|
510
|
+
readStore: pairing.readStoreForDmPolicy
|
|
511
|
+
});
|
|
512
|
+
const configuredDmAllowFrom = msteamsCfg?.allowFrom ?? [];
|
|
513
|
+
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
|
|
514
|
+
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
|
|
515
|
+
allowFrom: configuredDmAllowFrom,
|
|
516
|
+
groupAllowFrom,
|
|
517
|
+
storeAllowFrom: storedAllowFrom,
|
|
518
|
+
dmPolicy
|
|
519
|
+
});
|
|
520
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
|
|
521
|
+
const groupPolicy = !isDirectMessage && msteamsCfg ? msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist" : "disabled";
|
|
522
|
+
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
|
|
523
|
+
const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
|
|
524
|
+
const channelGate = resolveMSTeamsRouteConfig({
|
|
525
|
+
cfg: msteamsCfg,
|
|
526
|
+
teamId: activity.channelData?.team?.id,
|
|
527
|
+
teamName: activity.channelData?.team?.name,
|
|
528
|
+
conversationId,
|
|
529
|
+
channelName: activity.channelData?.channel?.name,
|
|
530
|
+
allowNameMatching
|
|
531
|
+
});
|
|
532
|
+
const senderGroupPolicy = channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0 ? groupPolicy : resolveSenderScopedGroupPolicy({
|
|
533
|
+
groupPolicy,
|
|
534
|
+
groupAllowFrom: effectiveGroupAllowFrom
|
|
535
|
+
});
|
|
536
|
+
const access = resolveDmGroupAccessWithLists({
|
|
537
|
+
isGroup: !isDirectMessage,
|
|
538
|
+
dmPolicy,
|
|
539
|
+
groupPolicy: senderGroupPolicy,
|
|
540
|
+
allowFrom: configuredDmAllowFrom,
|
|
541
|
+
groupAllowFrom,
|
|
542
|
+
storeAllowFrom: storedAllowFrom,
|
|
543
|
+
groupAllowFromFallbackToAllowFrom: false,
|
|
544
|
+
isSenderAllowed: (allowFrom) => resolveMSTeamsAllowlistMatch({
|
|
545
|
+
allowFrom,
|
|
546
|
+
senderId,
|
|
547
|
+
senderName,
|
|
548
|
+
allowNameMatching
|
|
549
|
+
}).allowed
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
msteamsCfg,
|
|
553
|
+
pairing,
|
|
554
|
+
isDirectMessage,
|
|
555
|
+
conversationId,
|
|
556
|
+
senderId,
|
|
557
|
+
senderName,
|
|
558
|
+
dmPolicy,
|
|
559
|
+
channelGate,
|
|
560
|
+
access,
|
|
561
|
+
senderGroupAccess: evaluateSenderGroupAccessForPolicy$1({
|
|
562
|
+
groupPolicy,
|
|
563
|
+
groupAllowFrom: effectiveGroupAllowFrom,
|
|
564
|
+
senderId,
|
|
565
|
+
isSenderAllowed: (_senderId, allowFrom) => resolveMSTeamsAllowlistMatch({
|
|
566
|
+
allowFrom,
|
|
567
|
+
senderId,
|
|
568
|
+
senderName,
|
|
569
|
+
allowNameMatching
|
|
570
|
+
}).allowed
|
|
571
|
+
}),
|
|
572
|
+
configuredDmAllowFrom,
|
|
573
|
+
effectiveDmAllowFrom: access.effectiveAllowFrom,
|
|
574
|
+
effectiveGroupAllowFrom,
|
|
575
|
+
allowNameMatching,
|
|
576
|
+
groupPolicy
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region extensions/msteams/src/attachments/bot-framework.ts
|
|
581
|
+
/**
|
|
582
|
+
* Bot Framework Service token scope for requesting a token used against
|
|
583
|
+
* the Bot Connector (v3) REST endpoints such as `/v3/attachments/{id}`.
|
|
584
|
+
*/
|
|
585
|
+
const BOT_FRAMEWORK_SCOPE = "https://api.botframework.com";
|
|
586
|
+
/**
|
|
587
|
+
* Detect Bot Framework personal chat ("a:") and MSA orgid ("8:orgid:") conversation
|
|
588
|
+
* IDs. These identifiers are not recognized by Graph's `/chats/{id}` endpoint, so we
|
|
589
|
+
* must fetch media via the Bot Framework v3 attachments endpoint instead.
|
|
590
|
+
*
|
|
591
|
+
* Graph-compatible IDs start with `19:` and are left untouched by this detector.
|
|
592
|
+
*/
|
|
593
|
+
function isBotFrameworkPersonalChatId(conversationId) {
|
|
594
|
+
if (typeof conversationId !== "string") return false;
|
|
595
|
+
const trimmed = conversationId.trim();
|
|
596
|
+
return trimmed.startsWith("a:") || trimmed.startsWith("8:orgid:");
|
|
597
|
+
}
|
|
598
|
+
function normalizeServiceUrl(serviceUrl) {
|
|
599
|
+
return serviceUrl.replace(/\/+$/, "");
|
|
600
|
+
}
|
|
601
|
+
async function fetchBotFrameworkAttachmentInfo(params) {
|
|
602
|
+
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
|
|
603
|
+
let response;
|
|
604
|
+
try {
|
|
605
|
+
response = await safeFetchWithPolicy({
|
|
606
|
+
url,
|
|
607
|
+
policy: params.policy,
|
|
608
|
+
fetchFn: params.fetchFn,
|
|
609
|
+
resolveFn: params.resolveFn,
|
|
610
|
+
requestInit: { headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }) }
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
params.logger?.warn?.("msteams botFramework attachmentInfo fetch failed", { error: err instanceof Error ? err.message : String(err) });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!response.ok) {
|
|
617
|
+
params.logger?.warn?.("msteams botFramework attachmentInfo non-ok", { status: response.status });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
return await response.json();
|
|
622
|
+
} catch (err) {
|
|
623
|
+
params.logger?.warn?.("msteams botFramework attachmentInfo parse failed", { error: err instanceof Error ? err.message : String(err) });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async function fetchBotFrameworkAttachmentView(params) {
|
|
628
|
+
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
|
629
|
+
let response;
|
|
630
|
+
try {
|
|
631
|
+
response = await safeFetchWithPolicy({
|
|
632
|
+
url,
|
|
633
|
+
policy: params.policy,
|
|
634
|
+
fetchFn: params.fetchFn,
|
|
635
|
+
resolveFn: params.resolveFn,
|
|
636
|
+
requestInit: { headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }) }
|
|
637
|
+
});
|
|
638
|
+
} catch (err) {
|
|
639
|
+
params.logger?.warn?.("msteams botFramework attachmentView fetch failed", { error: err instanceof Error ? err.message : String(err) });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!response.ok) {
|
|
643
|
+
params.logger?.warn?.("msteams botFramework attachmentView non-ok", { status: response.status });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const contentLength = response.headers.get("content-length");
|
|
647
|
+
if (contentLength && Number(contentLength) > params.maxBytes) return;
|
|
648
|
+
try {
|
|
649
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
650
|
+
const buffer = Buffer$1.from(arrayBuffer);
|
|
651
|
+
if (buffer.byteLength > params.maxBytes) return;
|
|
652
|
+
return buffer;
|
|
653
|
+
} catch (err) {
|
|
654
|
+
params.logger?.warn?.("msteams botFramework attachmentView body read failed", { error: err instanceof Error ? err.message : String(err) });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Download media for a single attachment via the Bot Framework v3 attachments
|
|
660
|
+
* endpoint. Used for personal DM conversations where the Graph `/chats/{id}`
|
|
661
|
+
* path is not usable because the Bot Framework conversation ID (`a:...`) is
|
|
662
|
+
* not a valid Graph chat identifier.
|
|
663
|
+
*/
|
|
664
|
+
async function downloadMSTeamsBotFrameworkAttachment(params) {
|
|
665
|
+
if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) return;
|
|
666
|
+
const policy = resolveAttachmentFetchPolicy({
|
|
667
|
+
allowHosts: params.allowHosts,
|
|
668
|
+
authAllowHosts: params.authAllowHosts
|
|
669
|
+
});
|
|
670
|
+
if (!isUrlAllowed(`${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`, policy.allowHosts)) return;
|
|
671
|
+
let accessToken;
|
|
672
|
+
try {
|
|
673
|
+
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
|
|
674
|
+
} catch (err) {
|
|
675
|
+
params.logger?.warn?.("msteams botFramework token acquisition failed", { error: err instanceof Error ? err.message : String(err) });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (!accessToken) return;
|
|
679
|
+
const info = await fetchBotFrameworkAttachmentInfo({
|
|
680
|
+
serviceUrl: params.serviceUrl,
|
|
681
|
+
attachmentId: params.attachmentId,
|
|
682
|
+
accessToken,
|
|
683
|
+
policy,
|
|
684
|
+
fetchFn: params.fetchFn,
|
|
685
|
+
resolveFn: params.resolveFn,
|
|
686
|
+
logger: params.logger
|
|
687
|
+
});
|
|
688
|
+
if (!info) return;
|
|
689
|
+
const views = Array.isArray(info.views) ? info.views : [];
|
|
690
|
+
const candidateView = views.find((view) => view?.viewId === "original") ?? views.find((view) => typeof view?.viewId === "string");
|
|
691
|
+
const viewId = typeof candidateView?.viewId === "string" && candidateView.viewId ? candidateView.viewId : void 0;
|
|
692
|
+
if (!viewId) return;
|
|
693
|
+
if (typeof candidateView?.size === "number" && candidateView.size > 0 && candidateView.size > params.maxBytes) return;
|
|
694
|
+
const buffer = await fetchBotFrameworkAttachmentView({
|
|
695
|
+
serviceUrl: params.serviceUrl,
|
|
696
|
+
attachmentId: params.attachmentId,
|
|
697
|
+
viewId,
|
|
698
|
+
accessToken,
|
|
699
|
+
maxBytes: params.maxBytes,
|
|
700
|
+
policy,
|
|
701
|
+
fetchFn: params.fetchFn,
|
|
702
|
+
resolveFn: params.resolveFn,
|
|
703
|
+
logger: params.logger
|
|
704
|
+
});
|
|
705
|
+
if (!buffer) return;
|
|
706
|
+
const fileNameHint = typeof params.fileNameHint === "string" && params.fileNameHint || typeof info.name === "string" && info.name || void 0;
|
|
707
|
+
const contentTypeHint = typeof params.contentTypeHint === "string" && params.contentTypeHint || typeof info.type === "string" && info.type || void 0;
|
|
708
|
+
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
709
|
+
buffer,
|
|
710
|
+
headerMime: contentTypeHint,
|
|
711
|
+
filePath: fileNameHint
|
|
712
|
+
});
|
|
713
|
+
try {
|
|
714
|
+
const originalFilename = params.preserveFilenames ? fileNameHint : void 0;
|
|
715
|
+
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(buffer, mime ?? contentTypeHint, "inbound", params.maxBytes, originalFilename);
|
|
716
|
+
return {
|
|
717
|
+
path: saved.path,
|
|
718
|
+
contentType: saved.contentType,
|
|
719
|
+
placeholder: inferPlaceholder({
|
|
720
|
+
contentType: saved.contentType,
|
|
721
|
+
fileName: fileNameHint
|
|
722
|
+
})
|
|
723
|
+
};
|
|
724
|
+
} catch (err) {
|
|
725
|
+
params.logger?.warn?.("msteams botFramework save failed", { error: err instanceof Error ? err.message : String(err) });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Download media for every attachment referenced by a Bot Framework personal
|
|
731
|
+
* chat activity. Returns all successfully fetched media along with diagnostics
|
|
732
|
+
* compatible with `downloadMSTeamsGraphMedia`'s result shape so callers can
|
|
733
|
+
* reuse the existing logging path.
|
|
734
|
+
*/
|
|
735
|
+
async function downloadMSTeamsBotFrameworkAttachments(params) {
|
|
736
|
+
const seen = /* @__PURE__ */ new Set();
|
|
737
|
+
const unique = [];
|
|
738
|
+
for (const id of params.attachmentIds ?? []) {
|
|
739
|
+
if (typeof id !== "string") continue;
|
|
740
|
+
const trimmed = id.trim();
|
|
741
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
742
|
+
seen.add(trimmed);
|
|
743
|
+
unique.push(trimmed);
|
|
744
|
+
}
|
|
745
|
+
if (unique.length === 0 || !params.serviceUrl || !params.tokenProvider) return {
|
|
746
|
+
media: [],
|
|
747
|
+
attachmentCount: unique.length
|
|
748
|
+
};
|
|
749
|
+
const media = [];
|
|
750
|
+
for (const attachmentId of unique) try {
|
|
751
|
+
const item = await downloadMSTeamsBotFrameworkAttachment({
|
|
752
|
+
serviceUrl: params.serviceUrl,
|
|
753
|
+
attachmentId,
|
|
754
|
+
tokenProvider: params.tokenProvider,
|
|
755
|
+
maxBytes: params.maxBytes,
|
|
756
|
+
allowHosts: params.allowHosts,
|
|
757
|
+
authAllowHosts: params.authAllowHosts,
|
|
758
|
+
fetchFn: params.fetchFn,
|
|
759
|
+
resolveFn: params.resolveFn,
|
|
760
|
+
fileNameHint: params.fileNameHint,
|
|
761
|
+
contentTypeHint: params.contentTypeHint,
|
|
762
|
+
preserveFilenames: params.preserveFilenames,
|
|
763
|
+
logger: params.logger
|
|
764
|
+
});
|
|
765
|
+
if (item) media.push(item);
|
|
766
|
+
} catch (err) {
|
|
767
|
+
params.logger?.warn?.("msteams botFramework attachment download failed", {
|
|
768
|
+
error: err instanceof Error ? err.message : String(err),
|
|
769
|
+
attachmentId
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
media,
|
|
774
|
+
attachmentCount: unique.length
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region extensions/msteams/src/attachments/remote-media.ts
|
|
779
|
+
/**
|
|
780
|
+
* Direct fetch path used when the caller's `fetchImpl` has already validated
|
|
781
|
+
* the URL against a hostname allowlist (for example `safeFetchWithPolicy`).
|
|
782
|
+
*
|
|
783
|
+
* Bypasses the strict SSRF dispatcher on `fetchRemoteMedia` because:
|
|
784
|
+
* 1. The pinned undici dispatcher used by `fetchRemoteMedia` is incompatible
|
|
785
|
+
* with Node 24+'s built-in undici v7 (fails with "invalid onRequestStart
|
|
786
|
+
* method"), which silently breaks SharePoint/OneDrive downloads. See
|
|
787
|
+
* issue #63396.
|
|
788
|
+
* 2. SSRF protection is already enforced by the caller's `fetchImpl`
|
|
789
|
+
* (`safeFetch` validates every redirect hop against the hostname
|
|
790
|
+
* allowlist before following).
|
|
791
|
+
*/
|
|
792
|
+
async function fetchRemoteMediaDirect(params) {
|
|
793
|
+
const response = await params.fetchImpl(params.url, { redirect: "follow" });
|
|
794
|
+
if (!response.ok) {
|
|
795
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
796
|
+
throw new Error(`HTTP ${response.status}${statusText}`);
|
|
797
|
+
}
|
|
798
|
+
const contentLength = response.headers.get("content-length");
|
|
799
|
+
if (contentLength) {
|
|
800
|
+
const length = Number(contentLength);
|
|
801
|
+
if (Number.isFinite(length) && length > params.maxBytes) throw new Error(`content length ${length} exceeds maxBytes ${params.maxBytes}`);
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
buffer: await readResponseWithLimit(response, params.maxBytes, { onOverflow: ({ size, maxBytes }) => /* @__PURE__ */ new Error(`payload size ${size} exceeds maxBytes ${maxBytes}`) }),
|
|
805
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
async function downloadAndStoreMSTeamsRemoteMedia(params) {
|
|
809
|
+
let fetched;
|
|
810
|
+
if (params.useDirectFetch && params.fetchImpl) fetched = await fetchRemoteMediaDirect({
|
|
811
|
+
url: params.url,
|
|
812
|
+
fetchImpl: params.fetchImpl,
|
|
813
|
+
maxBytes: params.maxBytes
|
|
814
|
+
});
|
|
815
|
+
else fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
|
|
816
|
+
url: params.url,
|
|
817
|
+
fetchImpl: params.fetchImpl,
|
|
818
|
+
filePathHint: params.filePathHint,
|
|
819
|
+
maxBytes: params.maxBytes,
|
|
820
|
+
ssrfPolicy: params.ssrfPolicy
|
|
821
|
+
});
|
|
822
|
+
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
823
|
+
buffer: fetched.buffer,
|
|
824
|
+
headerMime: fetched.contentType ?? params.contentTypeHint,
|
|
825
|
+
filePath: params.filePathHint
|
|
826
|
+
});
|
|
827
|
+
const originalFilename = params.preserveFilenames ? params.filePathHint : void 0;
|
|
828
|
+
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(fetched.buffer, mime ?? params.contentTypeHint, "inbound", params.maxBytes, originalFilename);
|
|
829
|
+
return {
|
|
830
|
+
path: saved.path,
|
|
831
|
+
contentType: saved.contentType,
|
|
832
|
+
placeholder: params.placeholder ?? inferPlaceholder({
|
|
833
|
+
contentType: saved.contentType,
|
|
834
|
+
fileName: params.filePathHint
|
|
835
|
+
})
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region extensions/msteams/src/attachments/download.ts
|
|
840
|
+
function resolveDownloadCandidate(att) {
|
|
841
|
+
const contentType = normalizeContentType(att.contentType);
|
|
842
|
+
const name = normalizeOptionalString(att.name) ?? "";
|
|
843
|
+
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
|
|
844
|
+
if (!isRecord(att.content)) return null;
|
|
845
|
+
const downloadUrl = normalizeOptionalString(att.content.downloadUrl) ?? "";
|
|
846
|
+
if (!downloadUrl) return null;
|
|
847
|
+
const fileType = normalizeOptionalString(att.content.fileType) ?? "";
|
|
848
|
+
const uniqueId = normalizeOptionalString(att.content.uniqueId) ?? "";
|
|
849
|
+
const fileName = normalizeOptionalString(att.content.fileName) ?? "";
|
|
850
|
+
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
|
|
851
|
+
return {
|
|
852
|
+
url: downloadUrl,
|
|
853
|
+
fileHint: fileHint || void 0,
|
|
854
|
+
contentTypeHint: void 0,
|
|
855
|
+
placeholder: inferPlaceholder({
|
|
856
|
+
contentType,
|
|
857
|
+
fileName: fileHint,
|
|
858
|
+
fileType
|
|
859
|
+
})
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
const contentUrl = normalizeOptionalString(att.contentUrl) ?? "";
|
|
863
|
+
if (!contentUrl) return null;
|
|
864
|
+
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
|
|
865
|
+
return {
|
|
866
|
+
url: sharesUrl ?? contentUrl,
|
|
867
|
+
fileHint: name || void 0,
|
|
868
|
+
contentTypeHint: sharesUrl ? void 0 : contentType,
|
|
869
|
+
placeholder: inferPlaceholder({
|
|
870
|
+
contentType,
|
|
871
|
+
fileName: name
|
|
872
|
+
})
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function scopeCandidatesForUrl(url) {
|
|
876
|
+
try {
|
|
877
|
+
const host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
|
|
878
|
+
return host.endsWith("graph.microsoft.com") || host.endsWith("sharepoint.com") || host.endsWith("1drv.ms") || host.includes("sharepoint") ? ["https://graph.microsoft.com", "https://api.botframework.com"] : ["https://api.botframework.com", "https://graph.microsoft.com"];
|
|
879
|
+
} catch {
|
|
880
|
+
return ["https://api.botframework.com", "https://graph.microsoft.com"];
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function isRedirectStatus(status) {
|
|
884
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
885
|
+
}
|
|
886
|
+
async function fetchWithAuthFallback(params) {
|
|
887
|
+
const firstAttempt = await safeFetchWithPolicy({
|
|
888
|
+
url: params.url,
|
|
889
|
+
policy: params.policy,
|
|
890
|
+
fetchFn: params.fetchFn,
|
|
891
|
+
requestInit: params.requestInit,
|
|
892
|
+
resolveFn: params.resolveFn
|
|
893
|
+
});
|
|
894
|
+
if (firstAttempt.ok) return firstAttempt;
|
|
895
|
+
if (!params.tokenProvider) return firstAttempt;
|
|
896
|
+
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) return firstAttempt;
|
|
897
|
+
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) return firstAttempt;
|
|
898
|
+
const scopes = scopeCandidatesForUrl(params.url);
|
|
899
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
900
|
+
for (const scope of scopes) try {
|
|
901
|
+
const token = await params.tokenProvider.getAccessToken(scope);
|
|
902
|
+
const authHeaders = new Headers(params.requestInit?.headers);
|
|
903
|
+
authHeaders.set("Authorization", `Bearer ${token}`);
|
|
904
|
+
const authAttempt = await safeFetchWithPolicy({
|
|
905
|
+
url: params.url,
|
|
906
|
+
policy: params.policy,
|
|
907
|
+
fetchFn,
|
|
908
|
+
requestInit: {
|
|
909
|
+
...params.requestInit,
|
|
910
|
+
headers: authHeaders
|
|
911
|
+
},
|
|
912
|
+
resolveFn: params.resolveFn
|
|
913
|
+
});
|
|
914
|
+
if (authAttempt.ok) return authAttempt;
|
|
915
|
+
if (isRedirectStatus(authAttempt.status)) return authAttempt;
|
|
916
|
+
if (authAttempt.status !== 401 && authAttempt.status !== 403) continue;
|
|
917
|
+
} catch {}
|
|
918
|
+
return firstAttempt;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Download all file attachments from a Teams message (images, documents, etc.).
|
|
922
|
+
* Renamed from downloadMSTeamsImageAttachments to support all file types.
|
|
923
|
+
*/
|
|
924
|
+
async function downloadMSTeamsAttachments(params) {
|
|
925
|
+
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
|
926
|
+
if (list.length === 0) return [];
|
|
927
|
+
const policy = resolveAttachmentFetchPolicy({
|
|
928
|
+
allowHosts: params.allowHosts,
|
|
929
|
+
authAllowHosts: params.authAllowHosts
|
|
930
|
+
});
|
|
931
|
+
const allowHosts = policy.allowHosts;
|
|
932
|
+
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
|
933
|
+
const candidates = list.filter(isDownloadableAttachment).map(resolveDownloadCandidate).filter(Boolean);
|
|
934
|
+
const inlineCandidates = extractInlineImageCandidates(list, {
|
|
935
|
+
maxInlineBytes: params.maxBytes,
|
|
936
|
+
maxInlineTotalBytes: params.maxBytes
|
|
937
|
+
});
|
|
938
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
939
|
+
for (const inline of inlineCandidates) if (inline.kind === "url") {
|
|
940
|
+
if (!isUrlAllowed(inline.url, allowHosts)) continue;
|
|
941
|
+
if (seenUrls.has(inline.url)) continue;
|
|
942
|
+
seenUrls.add(inline.url);
|
|
943
|
+
candidates.push({
|
|
944
|
+
url: inline.url,
|
|
945
|
+
fileHint: inline.fileHint,
|
|
946
|
+
contentTypeHint: inline.contentType,
|
|
947
|
+
placeholder: inline.placeholder
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
|
|
951
|
+
const out = [];
|
|
952
|
+
for (const inline of inlineCandidates) {
|
|
953
|
+
if (inline.kind !== "data") continue;
|
|
954
|
+
if (inline.data.byteLength > params.maxBytes) continue;
|
|
955
|
+
try {
|
|
956
|
+
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(inline.data, inline.contentType, "inbound", params.maxBytes);
|
|
957
|
+
out.push({
|
|
958
|
+
path: saved.path,
|
|
959
|
+
contentType: saved.contentType,
|
|
960
|
+
placeholder: inline.placeholder
|
|
961
|
+
});
|
|
962
|
+
} catch (err) {
|
|
963
|
+
params.logger?.warn?.("msteams inline attachment decode failed", { error: err instanceof Error ? err.message : String(err) });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
for (const candidate of candidates) {
|
|
967
|
+
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
|
|
968
|
+
try {
|
|
969
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
970
|
+
url: candidate.url,
|
|
971
|
+
filePathHint: candidate.fileHint ?? candidate.url,
|
|
972
|
+
maxBytes: params.maxBytes,
|
|
973
|
+
contentTypeHint: candidate.contentTypeHint,
|
|
974
|
+
placeholder: candidate.placeholder,
|
|
975
|
+
preserveFilenames: params.preserveFilenames,
|
|
976
|
+
ssrfPolicy,
|
|
977
|
+
useDirectFetch: true,
|
|
978
|
+
fetchImpl: (input, init) => fetchWithAuthFallback({
|
|
979
|
+
url: resolveRequestUrl(input),
|
|
980
|
+
tokenProvider: params.tokenProvider,
|
|
981
|
+
fetchFn: params.fetchFn,
|
|
982
|
+
requestInit: init,
|
|
983
|
+
resolveFn: params.resolveFn,
|
|
984
|
+
policy
|
|
985
|
+
})
|
|
986
|
+
});
|
|
987
|
+
out.push(media);
|
|
988
|
+
} catch (err) {
|
|
989
|
+
params.logger?.warn?.("msteams attachment download failed", {
|
|
990
|
+
error: err instanceof Error ? err.message : String(err),
|
|
991
|
+
host: safeHostForLog(candidate.url)
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return out;
|
|
996
|
+
}
|
|
997
|
+
function safeHostForLog(url) {
|
|
998
|
+
try {
|
|
999
|
+
return new URL(url).host;
|
|
1000
|
+
} catch {
|
|
1001
|
+
return "invalid-url";
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
//#endregion
|
|
1005
|
+
//#region extensions/msteams/src/attachments/graph.ts
|
|
1006
|
+
function buildMSTeamsGraphMessageUrls(params) {
|
|
1007
|
+
const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
|
|
1008
|
+
const messageIdCandidates = /* @__PURE__ */ new Set();
|
|
1009
|
+
const pushCandidate = (value) => {
|
|
1010
|
+
const trimmed = normalizeOptionalString(value) ?? "";
|
|
1011
|
+
if (trimmed) messageIdCandidates.add(trimmed);
|
|
1012
|
+
};
|
|
1013
|
+
pushCandidate(params.messageId);
|
|
1014
|
+
pushCandidate(params.conversationMessageId);
|
|
1015
|
+
pushCandidate(readNestedString(params.channelData, ["messageId"]));
|
|
1016
|
+
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
|
|
1017
|
+
const replyToId = normalizeOptionalString(params.replyToId) ?? "";
|
|
1018
|
+
if (conversationType === "channel") {
|
|
1019
|
+
const teamId = readNestedString(params.channelData, ["team", "id"]) ?? readNestedString(params.channelData, ["teamId"]);
|
|
1020
|
+
const channelId = readNestedString(params.channelData, ["channel", "id"]) ?? readNestedString(params.channelData, ["channelId"]) ?? readNestedString(params.channelData, ["teamsChannelId"]);
|
|
1021
|
+
if (!teamId || !channelId) return [];
|
|
1022
|
+
const urls = [];
|
|
1023
|
+
if (replyToId) for (const candidate of messageIdCandidates) {
|
|
1024
|
+
if (candidate === replyToId) continue;
|
|
1025
|
+
urls.push(`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`);
|
|
1026
|
+
}
|
|
1027
|
+
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
|
|
1028
|
+
for (const candidate of messageIdCandidates) urls.push(`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`);
|
|
1029
|
+
return Array.from(new Set(urls));
|
|
1030
|
+
}
|
|
1031
|
+
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
|
|
1032
|
+
if (!chatId) return [];
|
|
1033
|
+
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
|
|
1034
|
+
const urls = Array.from(messageIdCandidates).map((candidate) => `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`);
|
|
1035
|
+
return Array.from(new Set(urls));
|
|
1036
|
+
}
|
|
1037
|
+
async function fetchGraphCollection(params) {
|
|
1038
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
1039
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
1040
|
+
url: params.url,
|
|
1041
|
+
fetchImpl: fetchFn,
|
|
1042
|
+
init: { headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }) },
|
|
1043
|
+
policy: params.ssrfPolicy,
|
|
1044
|
+
auditContext: "msteams.graph.collection"
|
|
1045
|
+
});
|
|
1046
|
+
try {
|
|
1047
|
+
const status = response.status;
|
|
1048
|
+
if (!response.ok) return {
|
|
1049
|
+
status,
|
|
1050
|
+
items: []
|
|
1051
|
+
};
|
|
1052
|
+
try {
|
|
1053
|
+
const data = await response.json();
|
|
1054
|
+
return {
|
|
1055
|
+
status,
|
|
1056
|
+
items: Array.isArray(data.value) ? data.value : []
|
|
1057
|
+
};
|
|
1058
|
+
} catch {
|
|
1059
|
+
return {
|
|
1060
|
+
status,
|
|
1061
|
+
items: []
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
} finally {
|
|
1065
|
+
await release();
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
function normalizeGraphAttachment(att) {
|
|
1069
|
+
let content = att.content;
|
|
1070
|
+
if (typeof content === "string") try {
|
|
1071
|
+
content = JSON.parse(content);
|
|
1072
|
+
} catch {}
|
|
1073
|
+
return {
|
|
1074
|
+
contentType: normalizeContentType(att.contentType) ?? void 0,
|
|
1075
|
+
contentUrl: att.contentUrl ?? void 0,
|
|
1076
|
+
name: att.name ?? void 0,
|
|
1077
|
+
thumbnailUrl: att.thumbnailUrl ?? void 0,
|
|
1078
|
+
content
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Download all hosted content from a Teams message (images, documents, etc.).
|
|
1083
|
+
* Renamed from downloadGraphHostedImages to support all file types.
|
|
1084
|
+
*/
|
|
1085
|
+
async function downloadGraphHostedContent(params) {
|
|
1086
|
+
const hosted = await fetchGraphCollection({
|
|
1087
|
+
url: `${params.messageUrl}/hostedContents`,
|
|
1088
|
+
accessToken: params.accessToken,
|
|
1089
|
+
fetchFn: params.fetchFn,
|
|
1090
|
+
ssrfPolicy: params.ssrfPolicy
|
|
1091
|
+
});
|
|
1092
|
+
if (hosted.items.length === 0) return {
|
|
1093
|
+
media: [],
|
|
1094
|
+
status: hosted.status,
|
|
1095
|
+
count: 0
|
|
1096
|
+
};
|
|
1097
|
+
const out = [];
|
|
1098
|
+
for (const item of hosted.items) {
|
|
1099
|
+
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
|
|
1100
|
+
let buffer;
|
|
1101
|
+
if (contentBytes) {
|
|
1102
|
+
if (estimateBase64DecodedBytes(contentBytes) > params.maxBytes) continue;
|
|
1103
|
+
try {
|
|
1104
|
+
buffer = Buffer.from(contentBytes, "base64");
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
params.logger?.warn?.("msteams graph hostedContent base64 decode failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
} else if (item.id) try {
|
|
1110
|
+
const { response: valRes, release } = await fetchWithSsrFGuard({
|
|
1111
|
+
url: `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`,
|
|
1112
|
+
fetchImpl: params.fetchFn ?? fetch,
|
|
1113
|
+
init: { headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }) },
|
|
1114
|
+
policy: params.ssrfPolicy,
|
|
1115
|
+
auditContext: "msteams.graph.hostedContent.value"
|
|
1116
|
+
});
|
|
1117
|
+
try {
|
|
1118
|
+
if (!valRes.ok) continue;
|
|
1119
|
+
const cl = valRes.headers.get("content-length");
|
|
1120
|
+
if (cl && Number(cl) > params.maxBytes) continue;
|
|
1121
|
+
const ab = await valRes.arrayBuffer();
|
|
1122
|
+
buffer = Buffer.from(ab);
|
|
1123
|
+
} finally {
|
|
1124
|
+
await release();
|
|
1125
|
+
}
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
params.logger?.warn?.("msteams graph hostedContent value fetch failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
else continue;
|
|
1131
|
+
if (buffer.byteLength > params.maxBytes) continue;
|
|
1132
|
+
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
1133
|
+
buffer,
|
|
1134
|
+
headerMime: item.contentType ?? void 0
|
|
1135
|
+
});
|
|
1136
|
+
try {
|
|
1137
|
+
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(buffer, mime ?? item.contentType ?? void 0, "inbound", params.maxBytes);
|
|
1138
|
+
out.push({
|
|
1139
|
+
path: saved.path,
|
|
1140
|
+
contentType: saved.contentType,
|
|
1141
|
+
placeholder: inferPlaceholder({ contentType: saved.contentType })
|
|
1142
|
+
});
|
|
1143
|
+
} catch (err) {
|
|
1144
|
+
params.logger?.warn?.("msteams graph hostedContent save failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
media: out,
|
|
1149
|
+
status: hosted.status,
|
|
1150
|
+
count: hosted.items.length
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
async function downloadMSTeamsGraphMedia(params) {
|
|
1154
|
+
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
|
|
1155
|
+
const policy = resolveAttachmentFetchPolicy({
|
|
1156
|
+
allowHosts: params.allowHosts,
|
|
1157
|
+
authAllowHosts: params.authAllowHosts
|
|
1158
|
+
});
|
|
1159
|
+
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
|
|
1160
|
+
const messageUrl = params.messageUrl;
|
|
1161
|
+
const debugLog = params.log ?? params.logger ?? void 0;
|
|
1162
|
+
let accessToken;
|
|
1163
|
+
try {
|
|
1164
|
+
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
debugLog?.debug?.("graph media token acquisition failed", {
|
|
1167
|
+
messageUrl,
|
|
1168
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1169
|
+
});
|
|
1170
|
+
params.logger?.warn?.("msteams graph token acquisition failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1171
|
+
return {
|
|
1172
|
+
media: [],
|
|
1173
|
+
messageUrl,
|
|
1174
|
+
tokenError: true
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
1178
|
+
const sharePointMedia = [];
|
|
1179
|
+
const downloadedReferenceUrls = /* @__PURE__ */ new Set();
|
|
1180
|
+
let messageAttachments = [];
|
|
1181
|
+
let messageStatus;
|
|
1182
|
+
try {
|
|
1183
|
+
const { response: msgRes, release } = await fetchWithSsrFGuard({
|
|
1184
|
+
url: messageUrl,
|
|
1185
|
+
fetchImpl: fetchFn,
|
|
1186
|
+
init: { headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }) },
|
|
1187
|
+
policy: ssrfPolicy,
|
|
1188
|
+
auditContext: "msteams.graph.message"
|
|
1189
|
+
});
|
|
1190
|
+
try {
|
|
1191
|
+
messageStatus = msgRes.status;
|
|
1192
|
+
if (msgRes.ok) {
|
|
1193
|
+
let msgData;
|
|
1194
|
+
try {
|
|
1195
|
+
msgData = await msgRes.json();
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
debugLog?.debug?.("graph media message parse failed", {
|
|
1198
|
+
messageUrl,
|
|
1199
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1200
|
+
});
|
|
1201
|
+
params.logger?.warn?.("msteams graph message parse failed", {
|
|
1202
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1203
|
+
messageUrl
|
|
1204
|
+
});
|
|
1205
|
+
msgData = {};
|
|
1206
|
+
}
|
|
1207
|
+
messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
|
|
1208
|
+
const spAttachments = messageAttachments.filter((a) => a.contentType === "reference" && a.contentUrl && a.name);
|
|
1209
|
+
for (const att of spAttachments) {
|
|
1210
|
+
const name = att.name ?? "file";
|
|
1211
|
+
const shareUrl = att.contentUrl ?? "";
|
|
1212
|
+
if (!shareUrl) continue;
|
|
1213
|
+
try {
|
|
1214
|
+
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
|
|
1215
|
+
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
|
|
1216
|
+
debugLog?.debug?.("graph media sharepoint url not in allowHosts", {
|
|
1217
|
+
messageUrl,
|
|
1218
|
+
sharesUrl
|
|
1219
|
+
});
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
1223
|
+
url: sharesUrl,
|
|
1224
|
+
filePathHint: name,
|
|
1225
|
+
maxBytes: params.maxBytes,
|
|
1226
|
+
contentTypeHint: "application/octet-stream",
|
|
1227
|
+
preserveFilenames: params.preserveFilenames,
|
|
1228
|
+
ssrfPolicy,
|
|
1229
|
+
useDirectFetch: true,
|
|
1230
|
+
fetchImpl: async (input, init) => {
|
|
1231
|
+
const requestUrl = resolveRequestUrl(input);
|
|
1232
|
+
const headers = ensureUserAgentHeader(init?.headers);
|
|
1233
|
+
applyAuthorizationHeaderForUrl({
|
|
1234
|
+
headers,
|
|
1235
|
+
url: requestUrl,
|
|
1236
|
+
authAllowHosts: policy.authAllowHosts,
|
|
1237
|
+
bearerToken: accessToken
|
|
1238
|
+
});
|
|
1239
|
+
return await safeFetchWithPolicy({
|
|
1240
|
+
url: requestUrl,
|
|
1241
|
+
policy,
|
|
1242
|
+
fetchFn,
|
|
1243
|
+
requestInit: {
|
|
1244
|
+
...init,
|
|
1245
|
+
headers
|
|
1246
|
+
},
|
|
1247
|
+
resolveFn: params.resolveFn
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
sharePointMedia.push(media);
|
|
1252
|
+
downloadedReferenceUrls.add(shareUrl);
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
params.logger?.warn?.("msteams SharePoint reference download failed", {
|
|
1255
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1256
|
+
name
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
} else debugLog?.debug?.("graph media message fetch not ok", {
|
|
1261
|
+
messageUrl,
|
|
1262
|
+
status: messageStatus
|
|
1263
|
+
});
|
|
1264
|
+
} finally {
|
|
1265
|
+
await release();
|
|
1266
|
+
}
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
debugLog?.debug?.("graph media message fetch failed", {
|
|
1269
|
+
messageUrl,
|
|
1270
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1271
|
+
});
|
|
1272
|
+
params.logger?.warn?.("msteams graph message fetch failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1273
|
+
}
|
|
1274
|
+
const hosted = await downloadGraphHostedContent({
|
|
1275
|
+
accessToken,
|
|
1276
|
+
messageUrl,
|
|
1277
|
+
maxBytes: params.maxBytes,
|
|
1278
|
+
fetchFn: params.fetchFn,
|
|
1279
|
+
preserveFilenames: params.preserveFilenames,
|
|
1280
|
+
ssrfPolicy,
|
|
1281
|
+
logger: params.logger
|
|
1282
|
+
});
|
|
1283
|
+
const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
|
|
1284
|
+
const filteredAttachments = sharePointMedia.length > 0 ? normalizedAttachments.filter((att) => {
|
|
1285
|
+
if (normalizeOptionalLowercaseString(att.contentType) !== "reference") return true;
|
|
1286
|
+
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
|
1287
|
+
if (!url) return true;
|
|
1288
|
+
return !downloadedReferenceUrls.has(url);
|
|
1289
|
+
}) : normalizedAttachments;
|
|
1290
|
+
let attachmentMedia = [];
|
|
1291
|
+
try {
|
|
1292
|
+
attachmentMedia = await downloadMSTeamsAttachments({
|
|
1293
|
+
attachments: filteredAttachments,
|
|
1294
|
+
maxBytes: params.maxBytes,
|
|
1295
|
+
tokenProvider: params.tokenProvider,
|
|
1296
|
+
allowHosts: policy.allowHosts,
|
|
1297
|
+
authAllowHosts: policy.authAllowHosts,
|
|
1298
|
+
fetchFn: params.fetchFn,
|
|
1299
|
+
resolveFn: params.resolveFn,
|
|
1300
|
+
preserveFilenames: params.preserveFilenames,
|
|
1301
|
+
logger: params.logger
|
|
1302
|
+
});
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
params.logger?.warn?.("msteams graph attachment download failed", {
|
|
1305
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1306
|
+
messageUrl
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
return {
|
|
1310
|
+
media: [
|
|
1311
|
+
...sharePointMedia,
|
|
1312
|
+
...hosted.media,
|
|
1313
|
+
...attachmentMedia
|
|
1314
|
+
],
|
|
1315
|
+
hostedCount: hosted.count,
|
|
1316
|
+
attachmentCount: filteredAttachments.length + sharePointMedia.length,
|
|
1317
|
+
hostedStatus: hosted.status,
|
|
1318
|
+
attachmentStatus: messageStatus,
|
|
1319
|
+
messageUrl
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region extensions/msteams/src/attachments/html.ts
|
|
1324
|
+
/**
|
|
1325
|
+
* Extract every `<attachment id="...">` reference from the HTML attachments in
|
|
1326
|
+
* the inbound activity. Returns the complete (non-sliced) list; callers that
|
|
1327
|
+
* need a capped diagnostic summary can truncate after calling this helper.
|
|
1328
|
+
*/
|
|
1329
|
+
function extractMSTeamsHtmlAttachmentIds(attachments) {
|
|
1330
|
+
const list = Array.isArray(attachments) ? attachments : [];
|
|
1331
|
+
if (list.length === 0) return [];
|
|
1332
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1333
|
+
for (const att of list) {
|
|
1334
|
+
const html = extractHtmlFromAttachment(att);
|
|
1335
|
+
if (!html) continue;
|
|
1336
|
+
ATTACHMENT_TAG_RE.lastIndex = 0;
|
|
1337
|
+
let match = ATTACHMENT_TAG_RE.exec(html);
|
|
1338
|
+
while (match) {
|
|
1339
|
+
const id = match[1]?.trim();
|
|
1340
|
+
if (id) ids.add(id);
|
|
1341
|
+
match = ATTACHMENT_TAG_RE.exec(html);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return Array.from(ids);
|
|
1345
|
+
}
|
|
1346
|
+
function summarizeMSTeamsHtmlAttachments(attachments) {
|
|
1347
|
+
const list = Array.isArray(attachments) ? attachments : [];
|
|
1348
|
+
if (list.length === 0) return;
|
|
1349
|
+
let htmlAttachments = 0;
|
|
1350
|
+
let imgTags = 0;
|
|
1351
|
+
let dataImages = 0;
|
|
1352
|
+
let cidImages = 0;
|
|
1353
|
+
const srcHosts = /* @__PURE__ */ new Set();
|
|
1354
|
+
let attachmentTags = 0;
|
|
1355
|
+
const attachmentIds = /* @__PURE__ */ new Set();
|
|
1356
|
+
for (const att of list) {
|
|
1357
|
+
const html = extractHtmlFromAttachment(att);
|
|
1358
|
+
if (!html) continue;
|
|
1359
|
+
htmlAttachments += 1;
|
|
1360
|
+
IMG_SRC_RE.lastIndex = 0;
|
|
1361
|
+
let match = IMG_SRC_RE.exec(html);
|
|
1362
|
+
while (match) {
|
|
1363
|
+
imgTags += 1;
|
|
1364
|
+
const src = match[1]?.trim();
|
|
1365
|
+
if (src) if (src.startsWith("data:")) dataImages += 1;
|
|
1366
|
+
else if (src.startsWith("cid:")) cidImages += 1;
|
|
1367
|
+
else srcHosts.add(safeHostForUrl(src));
|
|
1368
|
+
match = IMG_SRC_RE.exec(html);
|
|
1369
|
+
}
|
|
1370
|
+
ATTACHMENT_TAG_RE.lastIndex = 0;
|
|
1371
|
+
let attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
|
|
1372
|
+
while (attachmentMatch) {
|
|
1373
|
+
attachmentTags += 1;
|
|
1374
|
+
const id = attachmentMatch[1]?.trim();
|
|
1375
|
+
if (id) attachmentIds.add(id);
|
|
1376
|
+
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (htmlAttachments === 0) return;
|
|
1380
|
+
return {
|
|
1381
|
+
htmlAttachments,
|
|
1382
|
+
imgTags,
|
|
1383
|
+
dataImages,
|
|
1384
|
+
cidImages,
|
|
1385
|
+
srcHosts: Array.from(srcHosts).slice(0, 5),
|
|
1386
|
+
attachmentTags,
|
|
1387
|
+
attachmentIds: Array.from(attachmentIds).slice(0, 5)
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function buildMSTeamsAttachmentPlaceholder(attachments, limits) {
|
|
1391
|
+
const list = Array.isArray(attachments) ? attachments : [];
|
|
1392
|
+
if (list.length === 0) return "";
|
|
1393
|
+
const totalImages = list.filter(isLikelyImageAttachment).length + extractInlineImageCandidates(list, limits).length;
|
|
1394
|
+
if (totalImages > 0) return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
|
|
1395
|
+
const count = list.length;
|
|
1396
|
+
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
|
|
1397
|
+
}
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region extensions/msteams/src/attachments/payload.ts
|
|
1400
|
+
function buildMSTeamsMediaPayload(mediaList) {
|
|
1401
|
+
return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true });
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region extensions/msteams/src/graph-thread.ts
|
|
1405
|
+
const teamGroupIdCache = /* @__PURE__ */ new Map();
|
|
1406
|
+
const CACHE_TTL_MS = 600 * 1e3;
|
|
1407
|
+
/**
|
|
1408
|
+
* Strip HTML tags from Teams message content, preserving @mention display names.
|
|
1409
|
+
* Teams wraps mentions in <at>Name</at> tags.
|
|
1410
|
+
*/
|
|
1411
|
+
function stripHtmlFromTeamsMessage(html) {
|
|
1412
|
+
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
|
|
1413
|
+
text = text.replace(/<[^>]*>/g, " ");
|
|
1414
|
+
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/ /g, " ");
|
|
1415
|
+
return text.replace(/\s+/g, " ").trim();
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Resolve the Azure AD group GUID for a Teams conversation team ID.
|
|
1419
|
+
* Results are cached with a TTL to avoid repeated Graph API calls.
|
|
1420
|
+
*/
|
|
1421
|
+
async function resolveTeamGroupId(token, conversationTeamId) {
|
|
1422
|
+
const cached = teamGroupIdCache.get(conversationTeamId);
|
|
1423
|
+
if (cached && cached.expiresAt > Date.now()) return cached.groupId;
|
|
1424
|
+
try {
|
|
1425
|
+
const groupId = (await fetchGraphJson({
|
|
1426
|
+
token,
|
|
1427
|
+
path: `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`
|
|
1428
|
+
})).id ?? conversationTeamId;
|
|
1429
|
+
teamGroupIdCache.set(conversationTeamId, {
|
|
1430
|
+
groupId,
|
|
1431
|
+
expiresAt: Date.now() + CACHE_TTL_MS
|
|
1432
|
+
});
|
|
1433
|
+
return groupId;
|
|
1434
|
+
} catch {
|
|
1435
|
+
return conversationTeamId;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Fetch a single channel message (the parent/root of a thread).
|
|
1440
|
+
* Returns undefined on error so callers can degrade gracefully.
|
|
1441
|
+
*/
|
|
1442
|
+
async function fetchChannelMessage(token, groupId, channelId, messageId) {
|
|
1443
|
+
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;
|
|
1444
|
+
try {
|
|
1445
|
+
return await fetchGraphJson({
|
|
1446
|
+
token,
|
|
1447
|
+
path
|
|
1448
|
+
});
|
|
1449
|
+
} catch {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Fetch thread replies for a channel message, ordered chronologically.
|
|
1455
|
+
*
|
|
1456
|
+
* **Limitation:** The Graph API replies endpoint (`/messages/{id}/replies`) does not
|
|
1457
|
+
* support `$orderby`, so results are always returned in ascending (oldest-first) order.
|
|
1458
|
+
* Combined with the `$top` cap of 50, this means only the **oldest 50 replies** are
|
|
1459
|
+
* returned for long threads — newer replies are silently omitted. There is currently no
|
|
1460
|
+
* Graph API workaround for this; pagination via `@odata.nextLink` can retrieve more
|
|
1461
|
+
* replies but still in ascending order only.
|
|
1462
|
+
*/
|
|
1463
|
+
async function fetchThreadReplies(token, groupId, channelId, messageId, limit = 50) {
|
|
1464
|
+
return (await fetchGraphJson({
|
|
1465
|
+
token,
|
|
1466
|
+
path: `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${Math.min(Math.max(limit, 1), 50)}&$select=id,from,body,createdDateTime`
|
|
1467
|
+
})).value ?? [];
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Format thread messages into a context string for the agent.
|
|
1471
|
+
* Skips the current message (by id) and blank messages.
|
|
1472
|
+
*/
|
|
1473
|
+
function formatThreadContext(messages, currentMessageId) {
|
|
1474
|
+
const lines = [];
|
|
1475
|
+
for (const msg of messages) {
|
|
1476
|
+
if (msg.id && msg.id === currentMessageId) continue;
|
|
1477
|
+
const sender = msg.from?.user?.displayName ?? msg.from?.application?.displayName ?? "unknown";
|
|
1478
|
+
const contentType = msg.body?.contentType ?? "text";
|
|
1479
|
+
const rawContent = msg.body?.content ?? "";
|
|
1480
|
+
const content = contentType === "html" ? stripHtmlFromTeamsMessage(rawContent) : rawContent.trim();
|
|
1481
|
+
if (!content) continue;
|
|
1482
|
+
lines.push(`${sender}: ${content}`);
|
|
1483
|
+
}
|
|
1484
|
+
return lines.join("\n");
|
|
1485
|
+
}
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region extensions/msteams/src/thread-parent-context.ts
|
|
1488
|
+
const PARENT_CACHE_TTL_MS = 300 * 1e3;
|
|
1489
|
+
const PARENT_CACHE_MAX = 100;
|
|
1490
|
+
const parentCache = /* @__PURE__ */ new Map();
|
|
1491
|
+
const INJECTED_MAX = 200;
|
|
1492
|
+
const injectedParents = /* @__PURE__ */ new Map();
|
|
1493
|
+
function touchLru(map, key, value, max) {
|
|
1494
|
+
if (map.has(key)) map.delete(key);
|
|
1495
|
+
else if (map.size >= max) {
|
|
1496
|
+
const firstKey = map.keys().next().value;
|
|
1497
|
+
if (firstKey !== void 0) map.delete(firstKey);
|
|
1498
|
+
}
|
|
1499
|
+
map.set(key, value);
|
|
1500
|
+
}
|
|
1501
|
+
function buildParentCacheKey(groupId, channelId, parentId) {
|
|
1502
|
+
return `${groupId}\u0000${channelId}\u0000${parentId}`;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Fetch a channel parent message with an LRU+TTL cache.
|
|
1506
|
+
*
|
|
1507
|
+
* Uses the injected `fetchParent` (defaults to `fetchChannelMessage`) so
|
|
1508
|
+
* tests can swap in a stub without mocking the Graph transport.
|
|
1509
|
+
*/
|
|
1510
|
+
async function fetchParentMessageCached(token, groupId, channelId, parentId, fetchParent = fetchChannelMessage) {
|
|
1511
|
+
const key = buildParentCacheKey(groupId, channelId, parentId);
|
|
1512
|
+
const now = Date.now();
|
|
1513
|
+
const cached = parentCache.get(key);
|
|
1514
|
+
if (cached && cached.expiresAt > now) {
|
|
1515
|
+
parentCache.delete(key);
|
|
1516
|
+
parentCache.set(key, cached);
|
|
1517
|
+
return cached.message;
|
|
1518
|
+
}
|
|
1519
|
+
const message = await fetchParent(token, groupId, channelId, parentId);
|
|
1520
|
+
touchLru(parentCache, key, {
|
|
1521
|
+
message,
|
|
1522
|
+
expiresAt: now + PARENT_CACHE_TTL_MS
|
|
1523
|
+
}, PARENT_CACHE_MAX);
|
|
1524
|
+
return message;
|
|
1525
|
+
}
|
|
1526
|
+
const PARENT_TEXT_MAX_CHARS = 400;
|
|
1527
|
+
/**
|
|
1528
|
+
* Extract a compact summary (sender + plain-text body) from a Graph parent
|
|
1529
|
+
* message. Returns undefined when the parent cannot be summarized (missing
|
|
1530
|
+
* or blank body).
|
|
1531
|
+
*/
|
|
1532
|
+
function summarizeParentMessage(message) {
|
|
1533
|
+
if (!message) return;
|
|
1534
|
+
const sender = message.from?.user?.displayName ?? message.from?.application?.displayName ?? "unknown";
|
|
1535
|
+
const contentType = message.body?.contentType ?? "text";
|
|
1536
|
+
const raw = message.body?.content ?? "";
|
|
1537
|
+
const text = contentType === "html" ? stripHtmlFromTeamsMessage(raw) : raw.replace(/\s+/g, " ").trim();
|
|
1538
|
+
if (!text) return;
|
|
1539
|
+
return {
|
|
1540
|
+
sender,
|
|
1541
|
+
text: text.length > PARENT_TEXT_MAX_CHARS ? `${text.slice(0, PARENT_TEXT_MAX_CHARS - 1)}…` : text
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Build the single-line `Replying to @sender: body` system event text.
|
|
1546
|
+
* Callers should pass this text to `enqueueSystemEvent` together with a
|
|
1547
|
+
* stable contextKey derived from the parent id.
|
|
1548
|
+
*/
|
|
1549
|
+
function formatParentContextEvent(summary) {
|
|
1550
|
+
return `Replying to @${summary.sender}: ${summary.text}`;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Decide whether a parent context event should be enqueued for the current
|
|
1554
|
+
* session. Returns `false` when we already injected the same parent for this
|
|
1555
|
+
* session recently (prevents re-prepending identical context on every reply
|
|
1556
|
+
* in the thread).
|
|
1557
|
+
*/
|
|
1558
|
+
function shouldInjectParentContext(sessionKey, parentId) {
|
|
1559
|
+
const key = sessionKey;
|
|
1560
|
+
return injectedParents.get(key) !== parentId;
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Record that `parentId` was just injected for `sessionKey` so subsequent
|
|
1564
|
+
* replies with the same parent can short-circuit via `shouldInjectParentContext`.
|
|
1565
|
+
*/
|
|
1566
|
+
function markParentContextInjected(sessionKey, parentId) {
|
|
1567
|
+
touchLru(injectedParents, sessionKey, parentId, INJECTED_MAX);
|
|
1568
|
+
}
|
|
1569
|
+
//#endregion
|
|
1570
|
+
//#region extensions/msteams/src/streaming-message.ts
|
|
1571
|
+
/**
|
|
1572
|
+
* Teams streaming message using the streaminfo entity protocol.
|
|
1573
|
+
*
|
|
1574
|
+
* Follows the official Teams SDK pattern:
|
|
1575
|
+
* 1. First chunk → POST a typing activity with streaminfo entity (streamType: "streaming")
|
|
1576
|
+
* 2. Subsequent chunks → POST typing activities with streaminfo + incrementing streamSequence
|
|
1577
|
+
* 3. Finalize → POST a message activity with streaminfo (streamType: "final")
|
|
1578
|
+
*
|
|
1579
|
+
* Uses the shared draft-stream-loop for throttling (avoids rate limits).
|
|
1580
|
+
*/
|
|
1581
|
+
/** Default throttle interval between stream updates (ms).
|
|
1582
|
+
* Teams docs recommend buffering tokens for 1.5-2s; limit is 1 req/s. */
|
|
1583
|
+
const DEFAULT_THROTTLE_MS = 1500;
|
|
1584
|
+
/** Minimum chars before sending the first streaming message. */
|
|
1585
|
+
const MIN_INITIAL_CHARS = 20;
|
|
1586
|
+
/** Teams message text limit. */
|
|
1587
|
+
const TEAMS_MAX_CHARS = 4e3;
|
|
1588
|
+
/**
|
|
1589
|
+
* Stop streaming before Teams expires the content stream server-side.
|
|
1590
|
+
* The exact service limit is opaque, so stay comfortably under it.
|
|
1591
|
+
*/
|
|
1592
|
+
const MAX_STREAM_AGE_MS = 45e3;
|
|
1593
|
+
function extractId(response) {
|
|
1594
|
+
if (response && typeof response === "object" && "id" in response) return readStringValue(response.id);
|
|
1595
|
+
}
|
|
1596
|
+
function buildStreamInfoEntity(streamId, streamType, streamSequence) {
|
|
1597
|
+
const entity = {
|
|
1598
|
+
type: "streaminfo",
|
|
1599
|
+
streamType
|
|
1600
|
+
};
|
|
1601
|
+
if (streamId) entity.streamId = streamId;
|
|
1602
|
+
if (streamSequence != null) entity.streamSequence = streamSequence;
|
|
1603
|
+
return entity;
|
|
1604
|
+
}
|
|
1605
|
+
var TeamsHttpStream = class {
|
|
1606
|
+
constructor(options) {
|
|
1607
|
+
this.accumulatedText = "";
|
|
1608
|
+
this.streamId = void 0;
|
|
1609
|
+
this.sequenceNumber = 0;
|
|
1610
|
+
this.stopped = false;
|
|
1611
|
+
this.finalized = false;
|
|
1612
|
+
this.streamFailed = false;
|
|
1613
|
+
this.lastStreamedText = "";
|
|
1614
|
+
this.streamStartedAt = void 0;
|
|
1615
|
+
this.sendActivity = options.sendActivity;
|
|
1616
|
+
this.feedbackLoopEnabled = options.feedbackLoopEnabled ?? false;
|
|
1617
|
+
this.onError = options.onError;
|
|
1618
|
+
this.loop = createDraftStreamLoop({
|
|
1619
|
+
throttleMs: options.throttleMs ?? DEFAULT_THROTTLE_MS,
|
|
1620
|
+
isStopped: () => this.stopped,
|
|
1621
|
+
sendOrEditStreamMessage: (text) => this.pushStreamChunk(text)
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Send an informative status update (blue progress bar in Teams).
|
|
1626
|
+
* Call this immediately when a message is received, before LLM starts generating.
|
|
1627
|
+
* Establishes the stream so subsequent chunks continue from this stream ID.
|
|
1628
|
+
*/
|
|
1629
|
+
async sendInformativeUpdate(text) {
|
|
1630
|
+
if (this.stopped || this.finalized) return;
|
|
1631
|
+
this.sequenceNumber++;
|
|
1632
|
+
const activity = {
|
|
1633
|
+
type: "typing",
|
|
1634
|
+
text,
|
|
1635
|
+
entities: [buildStreamInfoEntity(this.streamId, "informative", this.sequenceNumber)]
|
|
1636
|
+
};
|
|
1637
|
+
try {
|
|
1638
|
+
const response = await this.sendActivity(activity);
|
|
1639
|
+
if (!this.streamId) this.streamId = extractId(response);
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
this.onError?.(err);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Ingest partial text from the LLM token stream.
|
|
1646
|
+
* Called by onPartialReply — accumulates text and throttles updates.
|
|
1647
|
+
*/
|
|
1648
|
+
update(text) {
|
|
1649
|
+
if (this.stopped || this.finalized) return;
|
|
1650
|
+
this.accumulatedText = text;
|
|
1651
|
+
if (!this.streamId && this.accumulatedText.length < MIN_INITIAL_CHARS) return;
|
|
1652
|
+
if (this.accumulatedText.length > TEAMS_MAX_CHARS) {
|
|
1653
|
+
this.streamFailed = true;
|
|
1654
|
+
this.finalize();
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
if (this.streamStartedAt && Date.now() - this.streamStartedAt >= MAX_STREAM_AGE_MS) {
|
|
1658
|
+
this.streamFailed = true;
|
|
1659
|
+
this.finalize();
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
this.loop.update(this.accumulatedText);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Finalize the stream — send the final message activity.
|
|
1666
|
+
*/
|
|
1667
|
+
async finalize() {
|
|
1668
|
+
if (this.finalized) return;
|
|
1669
|
+
this.finalized = true;
|
|
1670
|
+
this.stopped = true;
|
|
1671
|
+
this.loop.stop();
|
|
1672
|
+
await this.loop.waitForInFlight();
|
|
1673
|
+
if (!this.accumulatedText.trim()) return;
|
|
1674
|
+
if (this.streamFailed) {
|
|
1675
|
+
if (this.streamId) try {
|
|
1676
|
+
await this.sendActivity({
|
|
1677
|
+
type: "message",
|
|
1678
|
+
text: this.lastStreamedText || "",
|
|
1679
|
+
channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled },
|
|
1680
|
+
entities: [AI_GENERATED_ENTITY, buildStreamInfoEntity(this.streamId, "final")]
|
|
1681
|
+
});
|
|
1682
|
+
} catch {}
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
try {
|
|
1686
|
+
const entities = [AI_GENERATED_ENTITY];
|
|
1687
|
+
if (this.streamId) entities.push(buildStreamInfoEntity(this.streamId, "final"));
|
|
1688
|
+
const finalActivity = {
|
|
1689
|
+
type: "message",
|
|
1690
|
+
text: this.accumulatedText,
|
|
1691
|
+
channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled },
|
|
1692
|
+
entities
|
|
1693
|
+
};
|
|
1694
|
+
await this.sendActivity(finalActivity);
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
this.onError?.(err);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
/** Whether streaming successfully delivered content (at least one chunk sent, not failed). */
|
|
1700
|
+
get hasContent() {
|
|
1701
|
+
return this.accumulatedText.length > 0 && !this.streamFailed;
|
|
1702
|
+
}
|
|
1703
|
+
/** Whether streaming failed and fallback delivery is needed. */
|
|
1704
|
+
get isFailed() {
|
|
1705
|
+
return this.streamFailed;
|
|
1706
|
+
}
|
|
1707
|
+
/** Number of characters successfully streamed before failure. */
|
|
1708
|
+
get streamedLength() {
|
|
1709
|
+
return this.lastStreamedText.length;
|
|
1710
|
+
}
|
|
1711
|
+
/** Whether the stream has been finalized. */
|
|
1712
|
+
get isFinalized() {
|
|
1713
|
+
return this.finalized;
|
|
1714
|
+
}
|
|
1715
|
+
/** Whether streaming fell back (not used in this implementation). */
|
|
1716
|
+
get isFallback() {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Send a single streaming chunk as a typing activity with streaminfo.
|
|
1721
|
+
* Per the Teams REST API spec:
|
|
1722
|
+
* - First chunk: no streamId, streamSequence=1 → returns 201 with { id: streamId }
|
|
1723
|
+
* - Subsequent chunks: include streamId, increment streamSequence → returns 202
|
|
1724
|
+
*/
|
|
1725
|
+
async pushStreamChunk(text) {
|
|
1726
|
+
if (this.stopped && !this.finalized) return false;
|
|
1727
|
+
this.sequenceNumber++;
|
|
1728
|
+
const activity = {
|
|
1729
|
+
type: "typing",
|
|
1730
|
+
text,
|
|
1731
|
+
entities: [buildStreamInfoEntity(this.streamId, "streaming", this.sequenceNumber)]
|
|
1732
|
+
};
|
|
1733
|
+
try {
|
|
1734
|
+
const response = await this.sendActivity(activity);
|
|
1735
|
+
if (!this.streamStartedAt) this.streamStartedAt = Date.now();
|
|
1736
|
+
if (!this.streamId) this.streamId = extractId(response);
|
|
1737
|
+
this.lastStreamedText = text;
|
|
1738
|
+
return true;
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
const axiosData = err?.response;
|
|
1741
|
+
const statusCode = axiosData?.status ?? err?.statusCode;
|
|
1742
|
+
const responseBody = axiosData?.data ? JSON.stringify(axiosData.data).slice(0, 300) : "";
|
|
1743
|
+
const msg = formatUnknownError(err);
|
|
1744
|
+
this.onError?.(/* @__PURE__ */ new Error(`stream POST failed (HTTP ${statusCode ?? "?"}): ${msg}${responseBody ? ` body=${responseBody}` : ""}`));
|
|
1745
|
+
this.streamFailed = true;
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
//#endregion
|
|
1751
|
+
//#region extensions/msteams/src/reply-stream-controller.ts
|
|
1752
|
+
const INFORMATIVE_STATUS_TEXTS = [
|
|
1753
|
+
"Thinking...",
|
|
1754
|
+
"Working on that...",
|
|
1755
|
+
"Checking the details...",
|
|
1756
|
+
"Putting an answer together..."
|
|
1757
|
+
];
|
|
1758
|
+
function pickInformativeStatusText(random = Math.random) {
|
|
1759
|
+
return INFORMATIVE_STATUS_TEXTS[Math.floor(random() * INFORMATIVE_STATUS_TEXTS.length)] ?? INFORMATIVE_STATUS_TEXTS[0];
|
|
1760
|
+
}
|
|
1761
|
+
function createTeamsReplyStreamController(params) {
|
|
1762
|
+
const stream = normalizeOptionalLowercaseString(params.conversationType) === "personal" ? new TeamsHttpStream({
|
|
1763
|
+
sendActivity: (activity) => params.context.sendActivity(activity),
|
|
1764
|
+
feedbackLoopEnabled: params.feedbackLoopEnabled,
|
|
1765
|
+
onError: (err) => {
|
|
1766
|
+
params.log.debug?.(`stream error: ${formatUnknownError(err)}`);
|
|
1767
|
+
}
|
|
1768
|
+
}) : void 0;
|
|
1769
|
+
let streamReceivedTokens = false;
|
|
1770
|
+
let informativeUpdateSent = false;
|
|
1771
|
+
let pendingFinalize;
|
|
1772
|
+
return {
|
|
1773
|
+
async onReplyStart() {
|
|
1774
|
+
if (!stream || informativeUpdateSent) return;
|
|
1775
|
+
informativeUpdateSent = true;
|
|
1776
|
+
await stream.sendInformativeUpdate(pickInformativeStatusText(params.random));
|
|
1777
|
+
},
|
|
1778
|
+
onPartialReply(payload) {
|
|
1779
|
+
if (!stream || !payload.text) return;
|
|
1780
|
+
streamReceivedTokens = true;
|
|
1781
|
+
stream.update(payload.text);
|
|
1782
|
+
},
|
|
1783
|
+
preparePayload(payload) {
|
|
1784
|
+
if (!stream || !streamReceivedTokens) return payload;
|
|
1785
|
+
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
|
|
1786
|
+
if (stream.isFailed) {
|
|
1787
|
+
streamReceivedTokens = false;
|
|
1788
|
+
if (!payload.text) return payload;
|
|
1789
|
+
const streamedLength = stream.streamedLength;
|
|
1790
|
+
if (streamedLength <= 0) return payload;
|
|
1791
|
+
const remainingText = payload.text.slice(streamedLength);
|
|
1792
|
+
if (!remainingText) return hasMedia ? {
|
|
1793
|
+
...payload,
|
|
1794
|
+
text: void 0
|
|
1795
|
+
} : void 0;
|
|
1796
|
+
return {
|
|
1797
|
+
...payload,
|
|
1798
|
+
text: remainingText
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
if (!stream.hasContent || stream.isFinalized) return payload;
|
|
1802
|
+
streamReceivedTokens = false;
|
|
1803
|
+
pendingFinalize = stream.finalize();
|
|
1804
|
+
if (!hasMedia) return;
|
|
1805
|
+
return {
|
|
1806
|
+
...payload,
|
|
1807
|
+
text: void 0
|
|
1808
|
+
};
|
|
1809
|
+
},
|
|
1810
|
+
async finalize() {
|
|
1811
|
+
await pendingFinalize;
|
|
1812
|
+
await stream?.finalize();
|
|
1813
|
+
},
|
|
1814
|
+
hasStream() {
|
|
1815
|
+
return Boolean(stream);
|
|
1816
|
+
},
|
|
1817
|
+
/**
|
|
1818
|
+
* Whether the Teams streaming card is currently receiving LLM tokens.
|
|
1819
|
+
* Used to gate side-channel keepalive activity so we don't overlay plain
|
|
1820
|
+
* "typing" indicators on top of a live streaming card.
|
|
1821
|
+
*
|
|
1822
|
+
* Returns true only while the stream is actively chunking text into the
|
|
1823
|
+
* streaming card. The informative update (blue progress bar) is short
|
|
1824
|
+
* lived so we intentionally do not count it as "active"; this way the
|
|
1825
|
+
* typing keepalive can still fire during the informative window and
|
|
1826
|
+
* during tool chains between text segments.
|
|
1827
|
+
*
|
|
1828
|
+
* Returns false when:
|
|
1829
|
+
* - No stream exists (non-personal conversation).
|
|
1830
|
+
* - Stream has not yet received any text tokens.
|
|
1831
|
+
* - Stream has been finalized (e.g. after the first text segment, while
|
|
1832
|
+
* tools run before the next segment).
|
|
1833
|
+
*/
|
|
1834
|
+
isStreamActive() {
|
|
1835
|
+
if (!stream) return false;
|
|
1836
|
+
if (stream.isFinalized || stream.isFailed) return false;
|
|
1837
|
+
return streamReceivedTokens;
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
//#endregion
|
|
1842
|
+
//#region extensions/msteams/src/reply-dispatcher.ts
|
|
1843
|
+
function createMSTeamsReplyDispatcher(params) {
|
|
1844
|
+
const core = getMSTeamsRuntime();
|
|
1845
|
+
const msteamsCfg = params.cfg.channels?.msteams;
|
|
1846
|
+
const conversationType = normalizeOptionalLowercaseString(params.conversationRef.conversation?.conversationType);
|
|
1847
|
+
const isTypingSupported = conversationType === "personal" || conversationType === "groupchat";
|
|
1848
|
+
/**
|
|
1849
|
+
* Keepalive cadence for the typing indicator while the bot is running
|
|
1850
|
+
* (including long tool chains). Bot Framework 1:1 TurnContext proxies
|
|
1851
|
+
* expire after ~30s of inactivity; sending a typing activity every 8s
|
|
1852
|
+
* keeps the proxy alive so the post-tool reply can still land via the
|
|
1853
|
+
* turn context. Sits in the middle of the 5-10s range recommended in
|
|
1854
|
+
* #59731.
|
|
1855
|
+
*/
|
|
1856
|
+
const TYPING_KEEPALIVE_INTERVAL_MS = 8e3;
|
|
1857
|
+
/**
|
|
1858
|
+
* TTL ceiling for the typing keepalive loop. The default in
|
|
1859
|
+
* createTypingCallbacks is 60s, which is too short for the Teams long tool
|
|
1860
|
+
* chains described in #59731 (60s+ total runs are common). Give tool
|
|
1861
|
+
* chains up to 10 minutes before auto-stopping the keepalive.
|
|
1862
|
+
*/
|
|
1863
|
+
const TYPING_KEEPALIVE_MAX_DURATION_MS = 10 * 6e4;
|
|
1864
|
+
const streamActiveRef = { current: () => false };
|
|
1865
|
+
const rawSendTypingIndicator = async () => {
|
|
1866
|
+
await withRevokedProxyFallback({
|
|
1867
|
+
run: async () => {
|
|
1868
|
+
await params.context.sendActivity({ type: "typing" });
|
|
1869
|
+
},
|
|
1870
|
+
onRevoked: async () => {
|
|
1871
|
+
const baseRef = buildConversationReference(params.conversationRef);
|
|
1872
|
+
await params.adapter.continueConversation(params.appId, {
|
|
1873
|
+
...baseRef,
|
|
1874
|
+
activityId: void 0
|
|
1875
|
+
}, async (ctx) => {
|
|
1876
|
+
await ctx.sendActivity({ type: "typing" });
|
|
1877
|
+
});
|
|
1878
|
+
},
|
|
1879
|
+
onRevokedLog: () => {
|
|
1880
|
+
params.log.debug?.("turn context revoked, sending typing via proactive messaging");
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
};
|
|
1884
|
+
const sendTypingIndicator = isTypingSupported ? async () => {
|
|
1885
|
+
if (streamActiveRef.current()) return;
|
|
1886
|
+
await rawSendTypingIndicator();
|
|
1887
|
+
} : async () => {};
|
|
1888
|
+
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
|
|
1889
|
+
cfg: params.cfg,
|
|
1890
|
+
agentId: params.agentId,
|
|
1891
|
+
channel: "msteams",
|
|
1892
|
+
accountId: params.accountId,
|
|
1893
|
+
typing: {
|
|
1894
|
+
start: sendTypingIndicator,
|
|
1895
|
+
keepaliveIntervalMs: TYPING_KEEPALIVE_INTERVAL_MS,
|
|
1896
|
+
maxDurationMs: TYPING_KEEPALIVE_MAX_DURATION_MS,
|
|
1897
|
+
onStartError: (err) => {
|
|
1898
|
+
logTypingFailure({
|
|
1899
|
+
log: (message) => params.log.debug?.(message),
|
|
1900
|
+
channel: "msteams",
|
|
1901
|
+
action: "start",
|
|
1902
|
+
error: err
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
});
|
|
1907
|
+
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
|
|
1908
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
1909
|
+
cfg: params.cfg,
|
|
1910
|
+
channel: "msteams"
|
|
1911
|
+
});
|
|
1912
|
+
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
|
1913
|
+
cfg: params.cfg,
|
|
1914
|
+
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb
|
|
1915
|
+
});
|
|
1916
|
+
const feedbackLoopEnabled = params.cfg.channels?.msteams?.feedbackEnabled !== false;
|
|
1917
|
+
const streamController = createTeamsReplyStreamController({
|
|
1918
|
+
conversationType,
|
|
1919
|
+
context: params.context,
|
|
1920
|
+
feedbackLoopEnabled,
|
|
1921
|
+
log: params.log
|
|
1922
|
+
});
|
|
1923
|
+
streamActiveRef.current = () => streamController.isStreamActive();
|
|
1924
|
+
const blockStreamingEnabled = typeof msteamsCfg?.blockStreaming === "boolean" ? msteamsCfg.blockStreaming : false;
|
|
1925
|
+
const typingIndicatorEnabled = typeof msteamsCfg?.typingIndicator === "boolean" ? msteamsCfg.typingIndicator : true;
|
|
1926
|
+
const pendingMessages = [];
|
|
1927
|
+
const sendMessages = async (messages) => {
|
|
1928
|
+
return sendMSTeamsMessages({
|
|
1929
|
+
replyStyle: params.replyStyle,
|
|
1930
|
+
adapter: params.adapter,
|
|
1931
|
+
appId: params.appId,
|
|
1932
|
+
conversationRef: params.conversationRef,
|
|
1933
|
+
context: params.context,
|
|
1934
|
+
messages,
|
|
1935
|
+
retry: {},
|
|
1936
|
+
onRetry: (event) => {
|
|
1937
|
+
params.log.debug?.("retrying send", {
|
|
1938
|
+
replyStyle: params.replyStyle,
|
|
1939
|
+
...event
|
|
1940
|
+
});
|
|
1941
|
+
},
|
|
1942
|
+
tokenProvider: params.tokenProvider,
|
|
1943
|
+
sharePointSiteId: params.sharePointSiteId,
|
|
1944
|
+
mediaMaxBytes,
|
|
1945
|
+
feedbackLoopEnabled
|
|
1946
|
+
});
|
|
1947
|
+
};
|
|
1948
|
+
const queueDeliveryFailureSystemEvent = (failure) => {
|
|
1949
|
+
const classification = classifyMSTeamsSendError(failure.error);
|
|
1950
|
+
const errorText = formatUnknownError(failure.error);
|
|
1951
|
+
const failedAll = failure.failed >= failure.total;
|
|
1952
|
+
const sentences = [
|
|
1953
|
+
`Microsoft Teams delivery failed: ${failedAll ? "the previous reply was not delivered" : `${failure.failed} of ${failure.total} message blocks were not delivered`}.`,
|
|
1954
|
+
`The user may not have received ${failedAll ? "that reply" : "the full reply"}.`,
|
|
1955
|
+
`Error: ${errorText}.`,
|
|
1956
|
+
classification.statusCode != null ? `Status: ${classification.statusCode}.` : void 0,
|
|
1957
|
+
classification.kind === "transient" || classification.kind === "throttled" ? "Retrying later may succeed." : void 0
|
|
1958
|
+
].filter(Boolean);
|
|
1959
|
+
core.system.enqueueSystemEvent(sentences.join(" "), {
|
|
1960
|
+
sessionKey: params.sessionKey,
|
|
1961
|
+
contextKey: `msteams:delivery-failure:${params.conversationRef.conversation?.id ?? "unknown"}`
|
|
1962
|
+
});
|
|
1963
|
+
};
|
|
1964
|
+
const flushPendingMessages = async () => {
|
|
1965
|
+
if (pendingMessages.length === 0) return;
|
|
1966
|
+
const toSend = pendingMessages.splice(0);
|
|
1967
|
+
const total = toSend.length;
|
|
1968
|
+
let ids;
|
|
1969
|
+
try {
|
|
1970
|
+
ids = await sendMessages(toSend);
|
|
1971
|
+
} catch (batchError) {
|
|
1972
|
+
ids = [];
|
|
1973
|
+
let failed = 0;
|
|
1974
|
+
let lastFailedError = batchError;
|
|
1975
|
+
for (const msg of toSend) try {
|
|
1976
|
+
const msgIds = await sendMessages([msg]);
|
|
1977
|
+
ids.push(...msgIds);
|
|
1978
|
+
} catch (msgError) {
|
|
1979
|
+
failed += 1;
|
|
1980
|
+
lastFailedError = msgError;
|
|
1981
|
+
params.log.debug?.("individual message send failed, continuing with remaining blocks");
|
|
1982
|
+
}
|
|
1983
|
+
if (failed > 0) {
|
|
1984
|
+
params.log.warn?.(`failed to deliver ${failed} of ${total} message blocks`, {
|
|
1985
|
+
failed,
|
|
1986
|
+
total
|
|
1987
|
+
});
|
|
1988
|
+
queueDeliveryFailureSystemEvent({
|
|
1989
|
+
failed,
|
|
1990
|
+
total,
|
|
1991
|
+
error: lastFailedError
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
|
1996
|
+
};
|
|
1997
|
+
const { dispatcher, replyOptions, markDispatchIdle: baseMarkDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
1998
|
+
...replyPipeline,
|
|
1999
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
|
2000
|
+
onReplyStart: async () => {
|
|
2001
|
+
await streamController.onReplyStart();
|
|
2002
|
+
if (typingIndicatorEnabled) await typingCallbacks?.onReplyStart?.();
|
|
2003
|
+
},
|
|
2004
|
+
typingCallbacks,
|
|
2005
|
+
deliver: async (payload) => {
|
|
2006
|
+
const preparedPayload = streamController.preparePayload(payload);
|
|
2007
|
+
if (!preparedPayload) return;
|
|
2008
|
+
const messages = renderReplyPayloadsToMessages([preparedPayload], {
|
|
2009
|
+
textChunkLimit: params.textLimit,
|
|
2010
|
+
chunkText: true,
|
|
2011
|
+
mediaMode: "split",
|
|
2012
|
+
tableMode,
|
|
2013
|
+
chunkMode
|
|
2014
|
+
});
|
|
2015
|
+
pendingMessages.push(...messages);
|
|
2016
|
+
if (blockStreamingEnabled) await flushPendingMessages();
|
|
2017
|
+
},
|
|
2018
|
+
onError: (err, info) => {
|
|
2019
|
+
const errMsg = formatUnknownError(err);
|
|
2020
|
+
const classification = classifyMSTeamsSendError(err);
|
|
2021
|
+
const hint = formatMSTeamsSendErrorHint(classification);
|
|
2022
|
+
params.runtime.error?.(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`);
|
|
2023
|
+
params.log.error("reply failed", {
|
|
2024
|
+
kind: info.kind,
|
|
2025
|
+
error: errMsg,
|
|
2026
|
+
classification,
|
|
2027
|
+
hint
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
const markDispatchIdle = () => {
|
|
2032
|
+
return flushPendingMessages().catch((err) => {
|
|
2033
|
+
const errMsg = formatUnknownError(err);
|
|
2034
|
+
const classification = classifyMSTeamsSendError(err);
|
|
2035
|
+
const hint = formatMSTeamsSendErrorHint(classification);
|
|
2036
|
+
params.runtime.error?.(`msteams flush reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`);
|
|
2037
|
+
params.log.error("flush reply failed", {
|
|
2038
|
+
error: errMsg,
|
|
2039
|
+
classification,
|
|
2040
|
+
hint
|
|
2041
|
+
});
|
|
2042
|
+
}).then(() => {
|
|
2043
|
+
return streamController.finalize().catch((err) => {
|
|
2044
|
+
params.log.debug?.("stream finalize failed", { error: formatUnknownError(err) });
|
|
2045
|
+
});
|
|
2046
|
+
}).finally(() => {
|
|
2047
|
+
baseMarkDispatchIdle();
|
|
2048
|
+
});
|
|
2049
|
+
};
|
|
2050
|
+
return {
|
|
2051
|
+
dispatcher,
|
|
2052
|
+
replyOptions: {
|
|
2053
|
+
...replyOptions,
|
|
2054
|
+
...streamController.hasStream() ? { onPartialReply: (payload) => streamController.onPartialReply(payload) } : {},
|
|
2055
|
+
disableBlockStreaming: typeof msteamsCfg?.blockStreaming === "boolean" ? !msteamsCfg.blockStreaming : void 0,
|
|
2056
|
+
onModelSelected
|
|
2057
|
+
},
|
|
2058
|
+
markDispatchIdle
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
//#endregion
|
|
2062
|
+
//#region extensions/msteams/src/sent-message-cache.ts
|
|
2063
|
+
const TTL_MS = 1440 * 60 * 1e3;
|
|
2064
|
+
const PERSISTENT_MAX_ENTRIES = 1e3;
|
|
2065
|
+
const PERSISTENT_NAMESPACE = "msteams.sent-messages";
|
|
2066
|
+
const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("openclaw.msteamsSentMessages");
|
|
2067
|
+
let sentMessageCache;
|
|
2068
|
+
let persistentStore;
|
|
2069
|
+
let persistentStoreDisabled = false;
|
|
2070
|
+
function getSentMessageCache() {
|
|
2071
|
+
if (!sentMessageCache) {
|
|
2072
|
+
const globalStore = globalThis;
|
|
2073
|
+
sentMessageCache = globalStore[MSTEAMS_SENT_MESSAGES_KEY] ?? /* @__PURE__ */ new Map();
|
|
2074
|
+
globalStore[MSTEAMS_SENT_MESSAGES_KEY] = sentMessageCache;
|
|
2075
|
+
}
|
|
2076
|
+
return sentMessageCache;
|
|
2077
|
+
}
|
|
2078
|
+
function makePersistentKey(conversationId, messageId) {
|
|
2079
|
+
return `${conversationId}:${messageId}`;
|
|
2080
|
+
}
|
|
2081
|
+
function reportPersistentSentMessageError(error) {
|
|
2082
|
+
try {
|
|
2083
|
+
getOptionalMSTeamsRuntime()?.logging.getChildLogger({
|
|
2084
|
+
plugin: "msteams",
|
|
2085
|
+
feature: "sent-message-state"
|
|
2086
|
+
}).warn("Microsoft Teams persistent sent-message state failed", { error: String(error) });
|
|
2087
|
+
} catch {}
|
|
2088
|
+
}
|
|
2089
|
+
function disablePersistentSentMessageStore(error) {
|
|
2090
|
+
persistentStoreDisabled = true;
|
|
2091
|
+
persistentStore = void 0;
|
|
2092
|
+
reportPersistentSentMessageError(error);
|
|
2093
|
+
}
|
|
2094
|
+
function getPersistentSentMessageStore() {
|
|
2095
|
+
if (persistentStoreDisabled) return;
|
|
2096
|
+
if (persistentStore) return persistentStore;
|
|
2097
|
+
const runtime = getOptionalMSTeamsRuntime();
|
|
2098
|
+
if (!runtime) return;
|
|
2099
|
+
try {
|
|
2100
|
+
persistentStore = runtime.state.openKeyedStore({
|
|
2101
|
+
namespace: PERSISTENT_NAMESPACE,
|
|
2102
|
+
maxEntries: PERSISTENT_MAX_ENTRIES,
|
|
2103
|
+
defaultTtlMs: TTL_MS
|
|
2104
|
+
});
|
|
2105
|
+
return persistentStore;
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
disablePersistentSentMessageStore(error);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
function cleanupExpired(scopeKey, entry, now) {
|
|
2112
|
+
for (const [id, timestamp] of entry) if (now - timestamp > TTL_MS) entry.delete(id);
|
|
2113
|
+
if (entry.size === 0) getSentMessageCache().delete(scopeKey);
|
|
2114
|
+
}
|
|
2115
|
+
function rememberSentMessageInMemory(conversationId, messageId, sentAt) {
|
|
2116
|
+
const store = getSentMessageCache();
|
|
2117
|
+
let entry = store.get(conversationId);
|
|
2118
|
+
if (!entry) {
|
|
2119
|
+
entry = /* @__PURE__ */ new Map();
|
|
2120
|
+
store.set(conversationId, entry);
|
|
2121
|
+
}
|
|
2122
|
+
entry.set(messageId, sentAt);
|
|
2123
|
+
if (entry.size > 200) cleanupExpired(conversationId, entry, sentAt);
|
|
2124
|
+
}
|
|
2125
|
+
function rememberPersistentSentMessage(params) {
|
|
2126
|
+
const store = getPersistentSentMessageStore();
|
|
2127
|
+
if (!store) return;
|
|
2128
|
+
store.register(makePersistentKey(params.conversationId, params.messageId), { sentAt: params.sentAt }).catch(disablePersistentSentMessageStore);
|
|
2129
|
+
}
|
|
2130
|
+
async function lookupPersistentSentMessage(params) {
|
|
2131
|
+
const store = getPersistentSentMessageStore();
|
|
2132
|
+
if (!store) return;
|
|
2133
|
+
try {
|
|
2134
|
+
return (await store.lookup(makePersistentKey(params.conversationId, params.messageId)))?.sentAt;
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
disablePersistentSentMessageStore(error);
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
function recordMSTeamsSentMessage(conversationId, messageId) {
|
|
2141
|
+
if (!conversationId || !messageId) return;
|
|
2142
|
+
const now = Date.now();
|
|
2143
|
+
rememberSentMessageInMemory(conversationId, messageId, now);
|
|
2144
|
+
rememberPersistentSentMessage({
|
|
2145
|
+
conversationId,
|
|
2146
|
+
messageId,
|
|
2147
|
+
sentAt: now
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
function wasMSTeamsMessageSent(conversationId, messageId) {
|
|
2151
|
+
const entry = getSentMessageCache().get(conversationId);
|
|
2152
|
+
if (!entry) return false;
|
|
2153
|
+
cleanupExpired(conversationId, entry, Date.now());
|
|
2154
|
+
return entry.has(messageId);
|
|
2155
|
+
}
|
|
2156
|
+
async function wasMSTeamsMessageSentWithPersistence(params) {
|
|
2157
|
+
if (!params.conversationId || !params.messageId) return false;
|
|
2158
|
+
if (wasMSTeamsMessageSent(params.conversationId, params.messageId)) return true;
|
|
2159
|
+
const sentAt = await lookupPersistentSentMessage(params);
|
|
2160
|
+
if (sentAt == null) return false;
|
|
2161
|
+
rememberSentMessageInMemory(params.conversationId, params.messageId, sentAt);
|
|
2162
|
+
return wasMSTeamsMessageSent(params.conversationId, params.messageId);
|
|
2163
|
+
}
|
|
2164
|
+
//#endregion
|
|
2165
|
+
//#region extensions/msteams/src/monitor-handler/inbound-media.ts
|
|
2166
|
+
async function resolveMSTeamsInboundMedia(params) {
|
|
2167
|
+
const { attachments, htmlSummary, maxBytes, tokenProvider, allowHosts, conversationType, conversationId, conversationMessageId, serviceUrl, activity, log, preserveFilenames } = params;
|
|
2168
|
+
let mediaList = await downloadMSTeamsAttachments({
|
|
2169
|
+
attachments,
|
|
2170
|
+
maxBytes,
|
|
2171
|
+
tokenProvider,
|
|
2172
|
+
allowHosts,
|
|
2173
|
+
authAllowHosts: params.authAllowHosts,
|
|
2174
|
+
preserveFilenames,
|
|
2175
|
+
logger: log
|
|
2176
|
+
});
|
|
2177
|
+
if (mediaList.length === 0) {
|
|
2178
|
+
const attachmentIds = extractMSTeamsHtmlAttachmentIds(attachments);
|
|
2179
|
+
const hasHtmlFileAttachment = attachmentIds.length > 0;
|
|
2180
|
+
if (hasHtmlFileAttachment && isBotFrameworkPersonalChatId(conversationId)) if (!serviceUrl) log.debug?.("bot framework attachment skipped (missing serviceUrl)", {
|
|
2181
|
+
conversationType,
|
|
2182
|
+
conversationId
|
|
2183
|
+
});
|
|
2184
|
+
else {
|
|
2185
|
+
const bfMedia = await downloadMSTeamsBotFrameworkAttachments({
|
|
2186
|
+
serviceUrl,
|
|
2187
|
+
attachmentIds,
|
|
2188
|
+
tokenProvider,
|
|
2189
|
+
maxBytes,
|
|
2190
|
+
allowHosts,
|
|
2191
|
+
authAllowHosts: params.authAllowHosts,
|
|
2192
|
+
preserveFilenames
|
|
2193
|
+
});
|
|
2194
|
+
if (bfMedia.media.length > 0) mediaList = bfMedia.media;
|
|
2195
|
+
else log.debug?.("bot framework attachments fetch empty", {
|
|
2196
|
+
conversationType,
|
|
2197
|
+
attachmentCount: bfMedia.attachmentCount ?? attachmentIds.length
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
if (hasHtmlFileAttachment && mediaList.length === 0 && !isBotFrameworkPersonalChatId(conversationId)) {
|
|
2201
|
+
const messageUrls = buildMSTeamsGraphMessageUrls({
|
|
2202
|
+
conversationType,
|
|
2203
|
+
conversationId,
|
|
2204
|
+
messageId: activity.id ?? void 0,
|
|
2205
|
+
replyToId: activity.replyToId ?? void 0,
|
|
2206
|
+
conversationMessageId,
|
|
2207
|
+
channelData: activity.channelData
|
|
2208
|
+
});
|
|
2209
|
+
if (messageUrls.length === 0) log.debug?.("graph message url unavailable", {
|
|
2210
|
+
conversationType,
|
|
2211
|
+
hasChannelData: Boolean(activity.channelData),
|
|
2212
|
+
messageId: activity.id ?? void 0,
|
|
2213
|
+
replyToId: activity.replyToId ?? void 0
|
|
2214
|
+
});
|
|
2215
|
+
else {
|
|
2216
|
+
const attempts = [];
|
|
2217
|
+
for (const messageUrl of messageUrls) {
|
|
2218
|
+
const graphMedia = await downloadMSTeamsGraphMedia({
|
|
2219
|
+
messageUrl,
|
|
2220
|
+
tokenProvider,
|
|
2221
|
+
maxBytes,
|
|
2222
|
+
allowHosts,
|
|
2223
|
+
authAllowHosts: params.authAllowHosts,
|
|
2224
|
+
preserveFilenames,
|
|
2225
|
+
log,
|
|
2226
|
+
logger: log
|
|
2227
|
+
});
|
|
2228
|
+
attempts.push({
|
|
2229
|
+
url: messageUrl,
|
|
2230
|
+
hostedStatus: graphMedia.hostedStatus,
|
|
2231
|
+
attachmentStatus: graphMedia.attachmentStatus,
|
|
2232
|
+
hostedCount: graphMedia.hostedCount,
|
|
2233
|
+
attachmentCount: graphMedia.attachmentCount,
|
|
2234
|
+
tokenError: graphMedia.tokenError
|
|
2235
|
+
});
|
|
2236
|
+
if (graphMedia.media.length > 0) {
|
|
2237
|
+
mediaList = graphMedia.media;
|
|
2238
|
+
break;
|
|
2239
|
+
}
|
|
2240
|
+
if (graphMedia.tokenError) break;
|
|
2241
|
+
}
|
|
2242
|
+
if (mediaList.length === 0) log.debug?.("graph media fetch empty", {
|
|
2243
|
+
attempts,
|
|
2244
|
+
attachmentIdCount: attachmentIds.length
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
if (mediaList.length > 0) log.debug?.("downloaded attachments", { count: mediaList.length });
|
|
2250
|
+
else if (htmlSummary?.imgTags) log.debug?.("inline images detected but none downloaded", {
|
|
2251
|
+
imgTags: htmlSummary.imgTags,
|
|
2252
|
+
srcHosts: htmlSummary.srcHosts,
|
|
2253
|
+
dataImages: htmlSummary.dataImages,
|
|
2254
|
+
cidImages: htmlSummary.cidImages
|
|
2255
|
+
});
|
|
2256
|
+
return mediaList;
|
|
2257
|
+
}
|
|
2258
|
+
//#endregion
|
|
2259
|
+
//#region extensions/msteams/src/monitor-handler/thread-session.ts
|
|
2260
|
+
function resolveMSTeamsRouteSessionKey(params) {
|
|
2261
|
+
const channelThreadId = params.isChannel ? params.conversationMessageId ?? params.replyToId ?? void 0 : void 0;
|
|
2262
|
+
return resolveThreadSessionKeys({
|
|
2263
|
+
baseSessionKey: params.baseSessionKey,
|
|
2264
|
+
threadId: channelThreadId,
|
|
2265
|
+
parentSessionKey: channelThreadId ? params.baseSessionKey : void 0
|
|
2266
|
+
}).sessionKey;
|
|
2267
|
+
}
|
|
2268
|
+
//#endregion
|
|
2269
|
+
//#region extensions/msteams/src/monitor-handler/message-handler.ts
|
|
2270
|
+
function extractTextFromHtmlAttachments(attachments) {
|
|
2271
|
+
for (const attachment of attachments) {
|
|
2272
|
+
if (attachment.contentType !== "text/html") continue;
|
|
2273
|
+
const content = attachment.content;
|
|
2274
|
+
const raw = typeof content === "string" ? content : isRecord(content) && typeof content.text === "string" ? content.text : isRecord(content) && typeof content.body === "string" ? content.body : "";
|
|
2275
|
+
if (!raw) continue;
|
|
2276
|
+
const text = raw.replace(/<at[^>]*>.*?<\/at>/gis, " ").replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gis, "$2 $1").replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/ /gi, " ").replace(/&/gi, "&").replace(/\s+/g, " ").trim();
|
|
2277
|
+
if (text) return text;
|
|
2278
|
+
}
|
|
2279
|
+
return "";
|
|
2280
|
+
}
|
|
2281
|
+
function buildStoredConversationReference(params) {
|
|
2282
|
+
const { activity, conversationId, conversationType, teamId, threadId } = params;
|
|
2283
|
+
const from = activity.from;
|
|
2284
|
+
const conversation = activity.conversation;
|
|
2285
|
+
const agent = activity.recipient;
|
|
2286
|
+
const clientInfo = activity.entities?.find((e) => e.type === "clientInfo");
|
|
2287
|
+
const tenantId = activity.channelData?.tenant?.id ?? conversation?.tenantId;
|
|
2288
|
+
const aadObjectId = from?.aadObjectId;
|
|
2289
|
+
return {
|
|
2290
|
+
activityId: activity.id,
|
|
2291
|
+
user: from ? {
|
|
2292
|
+
id: from.id,
|
|
2293
|
+
name: from.name,
|
|
2294
|
+
aadObjectId: from.aadObjectId
|
|
2295
|
+
} : void 0,
|
|
2296
|
+
agent,
|
|
2297
|
+
bot: agent ? {
|
|
2298
|
+
id: agent.id,
|
|
2299
|
+
name: agent.name
|
|
2300
|
+
} : void 0,
|
|
2301
|
+
conversation: {
|
|
2302
|
+
id: conversationId,
|
|
2303
|
+
conversationType,
|
|
2304
|
+
tenantId
|
|
2305
|
+
},
|
|
2306
|
+
...tenantId ? { tenantId } : {},
|
|
2307
|
+
...aadObjectId ? { aadObjectId } : {},
|
|
2308
|
+
teamId,
|
|
2309
|
+
channelId: activity.channelId,
|
|
2310
|
+
serviceUrl: activity.serviceUrl,
|
|
2311
|
+
locale: activity.locale,
|
|
2312
|
+
...clientInfo?.timezone ? { timezone: clientInfo.timezone } : {},
|
|
2313
|
+
...threadId ? { threadId } : {}
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
function createMSTeamsMessageHandler(deps) {
|
|
2317
|
+
const { cfg, runtime, appId, adapter, tokenProvider, textLimit, mediaMaxBytes, conversationStore, pollStore, log } = deps;
|
|
2318
|
+
const core = getMSTeamsRuntime();
|
|
2319
|
+
const logVerboseMessage = (message) => {
|
|
2320
|
+
if (core.logging.shouldLogVerbose()) log.debug?.(message);
|
|
2321
|
+
};
|
|
2322
|
+
const msteamsCfg = cfg.channels?.msteams;
|
|
2323
|
+
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
|
2324
|
+
cfg,
|
|
2325
|
+
channel: "msteams"
|
|
2326
|
+
});
|
|
2327
|
+
const historyLimit = Math.max(0, msteamsCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT);
|
|
2328
|
+
const conversationHistories = /* @__PURE__ */ new Map();
|
|
2329
|
+
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
|
2330
|
+
cfg,
|
|
2331
|
+
channel: "msteams"
|
|
2332
|
+
});
|
|
2333
|
+
const handleTeamsMessageNow = async (params) => {
|
|
2334
|
+
const context = params.context;
|
|
2335
|
+
const activity = context.activity;
|
|
2336
|
+
const rawText = params.rawText;
|
|
2337
|
+
const text = params.text;
|
|
2338
|
+
const attachments = params.attachments;
|
|
2339
|
+
const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments, {
|
|
2340
|
+
maxInlineBytes: mediaMaxBytes,
|
|
2341
|
+
maxInlineTotalBytes: mediaMaxBytes
|
|
2342
|
+
});
|
|
2343
|
+
const rawBody = text || attachmentPlaceholder;
|
|
2344
|
+
const quoteInfo = extractMSTeamsQuoteInfo(attachments);
|
|
2345
|
+
let quoteSenderId;
|
|
2346
|
+
let quoteSenderName;
|
|
2347
|
+
const from = activity.from;
|
|
2348
|
+
const conversation = activity.conversation;
|
|
2349
|
+
const attachmentTypes = attachments.map((att) => typeof att.contentType === "string" ? att.contentType : void 0).filter(Boolean).slice(0, 3);
|
|
2350
|
+
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
|
|
2351
|
+
log.info("received message", {
|
|
2352
|
+
rawText: rawText.slice(0, 50),
|
|
2353
|
+
text: text.slice(0, 50),
|
|
2354
|
+
attachments: attachments.length,
|
|
2355
|
+
attachmentTypes,
|
|
2356
|
+
from: from?.id,
|
|
2357
|
+
conversation: conversation?.id
|
|
2358
|
+
});
|
|
2359
|
+
if (htmlSummary) log.debug?.("html attachment summary", htmlSummary);
|
|
2360
|
+
if (!from?.id) {
|
|
2361
|
+
log.debug?.("skipping message without from.id");
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const rawConversationId = conversation?.id ?? "";
|
|
2365
|
+
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
|
2366
|
+
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
|
|
2367
|
+
const conversationType = conversation?.conversationType ?? "personal";
|
|
2368
|
+
const teamId = activity.channelData?.team?.id;
|
|
2369
|
+
const conversationRef = buildStoredConversationReference({
|
|
2370
|
+
activity,
|
|
2371
|
+
conversationId,
|
|
2372
|
+
conversationType,
|
|
2373
|
+
teamId,
|
|
2374
|
+
threadId: conversationType === "channel" ? conversationMessageId ?? activity.replyToId ?? void 0 : void 0
|
|
2375
|
+
});
|
|
2376
|
+
const { dmPolicy, senderId, senderName, pairing, isDirectMessage, channelGate, access, configuredDmAllowFrom, effectiveDmAllowFrom, effectiveGroupAllowFrom, allowNameMatching, groupPolicy } = await resolveMSTeamsSenderAccess({
|
|
2377
|
+
cfg,
|
|
2378
|
+
activity
|
|
2379
|
+
});
|
|
2380
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
2381
|
+
const isChannel = conversationType === "channel";
|
|
2382
|
+
if (isDirectMessage && msteamsCfg && access.decision !== "allow") {
|
|
2383
|
+
if (access.reason === "dmPolicy=disabled") {
|
|
2384
|
+
log.info("dropping dm (dms disabled)", {
|
|
2385
|
+
sender: senderId,
|
|
2386
|
+
label: senderName
|
|
2387
|
+
});
|
|
2388
|
+
log.debug?.("dropping dm (dms disabled)");
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
const allowMatch = resolveMSTeamsAllowlistMatch({
|
|
2392
|
+
allowFrom: effectiveDmAllowFrom,
|
|
2393
|
+
senderId,
|
|
2394
|
+
senderName,
|
|
2395
|
+
allowNameMatching
|
|
2396
|
+
});
|
|
2397
|
+
if (access.decision === "pairing") {
|
|
2398
|
+
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
|
|
2399
|
+
log.debug?.("failed to save conversation reference", { error: formatUnknownError(err) });
|
|
2400
|
+
});
|
|
2401
|
+
if (await pairing.upsertPairingRequest({
|
|
2402
|
+
id: senderId,
|
|
2403
|
+
meta: { name: senderName }
|
|
2404
|
+
})) log.info("msteams pairing request created", {
|
|
2405
|
+
sender: senderId,
|
|
2406
|
+
label: senderName
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
log.debug?.("dropping dm (not allowlisted)", {
|
|
2410
|
+
sender: senderId,
|
|
2411
|
+
label: senderName,
|
|
2412
|
+
allowlistMatch: formatAllowlistMatchMeta(allowMatch)
|
|
2413
|
+
});
|
|
2414
|
+
log.info("dropping dm (not allowlisted)", {
|
|
2415
|
+
sender: senderId,
|
|
2416
|
+
label: senderName,
|
|
2417
|
+
dmPolicy,
|
|
2418
|
+
reason: access.reason,
|
|
2419
|
+
allowlistMatch: formatAllowlistMatchMeta(allowMatch)
|
|
2420
|
+
});
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (!isDirectMessage && msteamsCfg) {
|
|
2424
|
+
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
|
2425
|
+
log.info("dropping group message (not in team/channel allowlist)", {
|
|
2426
|
+
conversationId,
|
|
2427
|
+
teamKey: channelGate.teamKey ?? "none",
|
|
2428
|
+
channelKey: channelGate.channelKey ?? "none",
|
|
2429
|
+
channelMatchKey: channelGate.channelMatchKey ?? "none",
|
|
2430
|
+
channelMatchSource: channelGate.channelMatchSource ?? "none"
|
|
2431
|
+
});
|
|
2432
|
+
log.debug?.("dropping group message (not in team/channel allowlist)", {
|
|
2433
|
+
conversationId,
|
|
2434
|
+
teamKey: channelGate.teamKey ?? "none",
|
|
2435
|
+
channelKey: channelGate.channelKey ?? "none",
|
|
2436
|
+
channelMatchKey: channelGate.channelMatchKey ?? "none",
|
|
2437
|
+
channelMatchSource: channelGate.channelMatchSource ?? "none"
|
|
2438
|
+
});
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
|
|
2442
|
+
groupPolicy,
|
|
2443
|
+
groupAllowFrom: effectiveGroupAllowFrom,
|
|
2444
|
+
senderId,
|
|
2445
|
+
isSenderAllowed: (_senderId, allowFrom) => resolveMSTeamsAllowlistMatch({
|
|
2446
|
+
allowFrom,
|
|
2447
|
+
senderId,
|
|
2448
|
+
senderName,
|
|
2449
|
+
allowNameMatching
|
|
2450
|
+
}).allowed
|
|
2451
|
+
});
|
|
2452
|
+
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
|
|
2453
|
+
log.info("dropping group message (groupPolicy: disabled)", { conversationId });
|
|
2454
|
+
log.debug?.("dropping group message (groupPolicy: disabled)", { conversationId });
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
|
|
2458
|
+
log.info("dropping group message (groupPolicy: allowlist, no allowlist)", { conversationId });
|
|
2459
|
+
log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", { conversationId });
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
|
|
2463
|
+
const allowMatch = resolveMSTeamsAllowlistMatch({
|
|
2464
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
2465
|
+
senderId,
|
|
2466
|
+
senderName,
|
|
2467
|
+
allowNameMatching
|
|
2468
|
+
});
|
|
2469
|
+
log.debug?.("dropping group message (not in groupAllowFrom)", {
|
|
2470
|
+
sender: senderId,
|
|
2471
|
+
label: senderName,
|
|
2472
|
+
allowlistMatch: formatAllowlistMatchMeta(allowMatch)
|
|
2473
|
+
});
|
|
2474
|
+
log.info("dropping group message (not in groupAllowFrom)", {
|
|
2475
|
+
sender: senderId,
|
|
2476
|
+
label: senderName,
|
|
2477
|
+
allowlistMatch: formatAllowlistMatchMeta(allowMatch)
|
|
2478
|
+
});
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom;
|
|
2483
|
+
const ownerAllowedForCommands = isMSTeamsGroupAllowed({
|
|
2484
|
+
groupPolicy: "allowlist",
|
|
2485
|
+
allowFrom: commandDmAllowFrom,
|
|
2486
|
+
senderId,
|
|
2487
|
+
senderName,
|
|
2488
|
+
allowNameMatching
|
|
2489
|
+
});
|
|
2490
|
+
const groupAllowedForCommands = isMSTeamsGroupAllowed({
|
|
2491
|
+
groupPolicy: "allowlist",
|
|
2492
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
2493
|
+
senderId,
|
|
2494
|
+
senderName,
|
|
2495
|
+
allowNameMatching
|
|
2496
|
+
});
|
|
2497
|
+
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
|
|
2498
|
+
useAccessGroups,
|
|
2499
|
+
primaryConfigured: commandDmAllowFrom.length > 0,
|
|
2500
|
+
primaryAllowed: ownerAllowedForCommands,
|
|
2501
|
+
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
|
|
2502
|
+
secondaryAllowed: groupAllowedForCommands,
|
|
2503
|
+
hasControlCommand: core.channel.text.hasControlCommand(text, cfg)
|
|
2504
|
+
});
|
|
2505
|
+
if (shouldBlock) {
|
|
2506
|
+
logInboundDrop({
|
|
2507
|
+
log: logVerboseMessage,
|
|
2508
|
+
channel: "msteams",
|
|
2509
|
+
reason: "control command (unauthorized)",
|
|
2510
|
+
target: senderId
|
|
2511
|
+
});
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
|
|
2515
|
+
log.debug?.("failed to save conversation reference", { error: formatUnknownError(err) });
|
|
2516
|
+
});
|
|
2517
|
+
const pollVote = extractMSTeamsPollVote(activity);
|
|
2518
|
+
if (pollVote) {
|
|
2519
|
+
try {
|
|
2520
|
+
if (!await pollStore.recordVote({
|
|
2521
|
+
pollId: pollVote.pollId,
|
|
2522
|
+
voterId: senderId,
|
|
2523
|
+
selections: pollVote.selections
|
|
2524
|
+
})) log.debug?.("poll vote ignored (poll not found)", { pollId: pollVote.pollId });
|
|
2525
|
+
else log.info("recorded poll vote", {
|
|
2526
|
+
pollId: pollVote.pollId,
|
|
2527
|
+
voter: senderId,
|
|
2528
|
+
selections: pollVote.selections
|
|
2529
|
+
});
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
log.error("failed to record poll vote", {
|
|
2532
|
+
pollId: pollVote.pollId,
|
|
2533
|
+
error: formatUnknownError(err)
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
if (!rawBody) {
|
|
2539
|
+
log.debug?.("skipping empty message after stripping mentions");
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
const teamsFrom = isDirectMessage ? `msteams:${senderId}` : isChannel ? `msteams:channel:${conversationId}` : `msteams:group:${conversationId}`;
|
|
2543
|
+
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
|
2544
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
2545
|
+
cfg,
|
|
2546
|
+
channel: "msteams",
|
|
2547
|
+
teamId,
|
|
2548
|
+
peer: {
|
|
2549
|
+
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
|
2550
|
+
id: isDirectMessage ? senderId : conversationId
|
|
2551
|
+
}
|
|
2552
|
+
});
|
|
2553
|
+
route.sessionKey = resolveMSTeamsRouteSessionKey({
|
|
2554
|
+
baseSessionKey: route.sessionKey,
|
|
2555
|
+
isChannel,
|
|
2556
|
+
conversationMessageId,
|
|
2557
|
+
replyToId: activity.replyToId
|
|
2558
|
+
});
|
|
2559
|
+
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
|
2560
|
+
const inboundLabel = isDirectMessage ? `Teams DM from ${senderName}` : `Teams message in ${conversationType} from ${senderName}`;
|
|
2561
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
2562
|
+
sessionKey: route.sessionKey,
|
|
2563
|
+
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`
|
|
2564
|
+
});
|
|
2565
|
+
const channelId = conversationId;
|
|
2566
|
+
const { teamConfig, channelConfig } = channelGate;
|
|
2567
|
+
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
|
|
2568
|
+
isDirectMessage,
|
|
2569
|
+
globalConfig: msteamsCfg,
|
|
2570
|
+
teamConfig,
|
|
2571
|
+
channelConfig
|
|
2572
|
+
});
|
|
2573
|
+
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
|
2574
|
+
const mentionDecision = resolveInboundMentionDecision({
|
|
2575
|
+
facts: {
|
|
2576
|
+
canDetectMention: true,
|
|
2577
|
+
wasMentioned: params.wasMentioned,
|
|
2578
|
+
implicitMentionKinds: params.implicitMentionKinds
|
|
2579
|
+
},
|
|
2580
|
+
policy: {
|
|
2581
|
+
isGroup: !isDirectMessage,
|
|
2582
|
+
requireMention,
|
|
2583
|
+
allowTextCommands: false,
|
|
2584
|
+
hasControlCommand: false,
|
|
2585
|
+
commandAuthorized: false
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2588
|
+
if (!isDirectMessage) {
|
|
2589
|
+
const mentioned = mentionDecision.effectiveWasMentioned;
|
|
2590
|
+
if (requireMention && mentionDecision.shouldSkip) {
|
|
2591
|
+
log.debug?.("skipping message (mention required)", {
|
|
2592
|
+
teamId,
|
|
2593
|
+
channelId,
|
|
2594
|
+
requireMention,
|
|
2595
|
+
mentioned
|
|
2596
|
+
});
|
|
2597
|
+
recordPendingHistoryEntryIfEnabled({
|
|
2598
|
+
historyMap: conversationHistories,
|
|
2599
|
+
historyKey: conversationId,
|
|
2600
|
+
limit: historyLimit,
|
|
2601
|
+
entry: {
|
|
2602
|
+
sender: senderName,
|
|
2603
|
+
body: rawBody,
|
|
2604
|
+
timestamp: timestamp?.getTime(),
|
|
2605
|
+
messageId: activity.id ?? void 0
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
let graphConversationId = translateMSTeamsDmConversationIdForGraph({
|
|
2612
|
+
isDirectMessage,
|
|
2613
|
+
conversationId,
|
|
2614
|
+
aadObjectId: from.aadObjectId,
|
|
2615
|
+
appId
|
|
2616
|
+
});
|
|
2617
|
+
if (isDirectMessage && conversationId.startsWith("a:")) {
|
|
2618
|
+
const cached = await conversationStore.get(conversationId);
|
|
2619
|
+
if (cached?.graphChatId) graphConversationId = cached.graphChatId;
|
|
2620
|
+
else try {
|
|
2621
|
+
const resolved = await resolveGraphChatId({
|
|
2622
|
+
botFrameworkConversationId: conversationId,
|
|
2623
|
+
userAadObjectId: from.aadObjectId ?? void 0,
|
|
2624
|
+
tokenProvider
|
|
2625
|
+
});
|
|
2626
|
+
if (resolved) {
|
|
2627
|
+
graphConversationId = resolved;
|
|
2628
|
+
conversationStore.upsert(conversationId, {
|
|
2629
|
+
...conversationRef,
|
|
2630
|
+
graphChatId: resolved
|
|
2631
|
+
}).catch(() => {});
|
|
2632
|
+
}
|
|
2633
|
+
} catch {
|
|
2634
|
+
log.debug?.("failed to resolve Graph chat ID for inbound media", { conversationId });
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
const mediaPayload = buildMSTeamsMediaPayload(await resolveMSTeamsInboundMedia({
|
|
2638
|
+
attachments,
|
|
2639
|
+
htmlSummary: htmlSummary ?? void 0,
|
|
2640
|
+
maxBytes: mediaMaxBytes,
|
|
2641
|
+
tokenProvider,
|
|
2642
|
+
allowHosts: msteamsCfg?.mediaAllowHosts,
|
|
2643
|
+
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
|
|
2644
|
+
conversationType,
|
|
2645
|
+
conversationId: graphConversationId,
|
|
2646
|
+
conversationMessageId: conversationMessageId ?? void 0,
|
|
2647
|
+
serviceUrl: activity.serviceUrl,
|
|
2648
|
+
activity: {
|
|
2649
|
+
id: activity.id,
|
|
2650
|
+
replyToId: activity.replyToId,
|
|
2651
|
+
channelData: activity.channelData
|
|
2652
|
+
},
|
|
2653
|
+
log,
|
|
2654
|
+
preserveFilenames: cfg.media?.preserveFilenames
|
|
2655
|
+
}));
|
|
2656
|
+
let threadContext;
|
|
2657
|
+
if (activity.replyToId && isChannel && teamId) try {
|
|
2658
|
+
const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
|
2659
|
+
const groupId = await resolveTeamGroupId(graphToken, teamId);
|
|
2660
|
+
const [parentResult, repliesResult] = await Promise.allSettled([fetchParentMessageCached(graphToken, groupId, conversationId, activity.replyToId), fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId)]);
|
|
2661
|
+
const parentMsg = parentResult.status === "fulfilled" ? parentResult.value : void 0;
|
|
2662
|
+
const replies = repliesResult.status === "fulfilled" ? repliesResult.value : [];
|
|
2663
|
+
if (parentResult.status === "rejected") log.debug?.("failed to fetch parent message", { error: formatUnknownError(parentResult.reason) });
|
|
2664
|
+
if (repliesResult.status === "rejected") log.debug?.("failed to fetch thread replies", { error: formatUnknownError(repliesResult.reason) });
|
|
2665
|
+
const isThreadSenderAllowed = (msg) => groupPolicy === "allowlist" ? resolveMSTeamsAllowlistMatch({
|
|
2666
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
2667
|
+
senderId: msg.from?.user?.id ?? "",
|
|
2668
|
+
senderName: msg.from?.user?.displayName,
|
|
2669
|
+
allowNameMatching
|
|
2670
|
+
}).allowed : true;
|
|
2671
|
+
const parentSummary = summarizeParentMessage(parentMsg);
|
|
2672
|
+
const visibleParentMessages = parentMsg ? filterSupplementalContextItems({
|
|
2673
|
+
items: [parentMsg],
|
|
2674
|
+
mode: contextVisibilityMode,
|
|
2675
|
+
kind: "thread",
|
|
2676
|
+
isSenderAllowed: isThreadSenderAllowed
|
|
2677
|
+
}).items : [];
|
|
2678
|
+
if (parentSummary && visibleParentMessages.length > 0 && shouldInjectParentContext(route.sessionKey, activity.replyToId)) {
|
|
2679
|
+
core.system.enqueueSystemEvent(formatParentContextEvent(parentSummary), {
|
|
2680
|
+
sessionKey: route.sessionKey,
|
|
2681
|
+
contextKey: `msteams:thread-parent:${conversationId}:${activity.replyToId}`
|
|
2682
|
+
});
|
|
2683
|
+
markParentContextInjected(route.sessionKey, activity.replyToId);
|
|
2684
|
+
}
|
|
2685
|
+
const allMessages = parentMsg ? [parentMsg, ...replies] : replies;
|
|
2686
|
+
quoteSenderId = parentMsg?.from?.user?.id ?? parentMsg?.from?.application?.id ?? void 0;
|
|
2687
|
+
quoteSenderName = parentMsg?.from?.user?.displayName ?? parentMsg?.from?.application?.displayName ?? quoteInfo?.sender;
|
|
2688
|
+
const { items: threadMessages } = filterSupplementalContextItems({
|
|
2689
|
+
items: allMessages,
|
|
2690
|
+
mode: contextVisibilityMode,
|
|
2691
|
+
kind: "thread",
|
|
2692
|
+
isSenderAllowed: isThreadSenderAllowed
|
|
2693
|
+
});
|
|
2694
|
+
const formatted = formatThreadContext(threadMessages, activity.id);
|
|
2695
|
+
if (formatted) threadContext = formatted;
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
log.debug?.("failed to fetch thread history", { error: formatUnknownError(err) });
|
|
2698
|
+
}
|
|
2699
|
+
quoteSenderName ??= quoteInfo?.sender;
|
|
2700
|
+
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
|
2701
|
+
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
|
2702
|
+
cfg,
|
|
2703
|
+
agentId: route.agentId,
|
|
2704
|
+
sessionKey: route.sessionKey
|
|
2705
|
+
});
|
|
2706
|
+
let combinedBody = core.channel.reply.formatAgentEnvelope({
|
|
2707
|
+
channel: "Teams",
|
|
2708
|
+
from: envelopeFrom,
|
|
2709
|
+
timestamp,
|
|
2710
|
+
previousTimestamp,
|
|
2711
|
+
envelope: envelopeOptions,
|
|
2712
|
+
body: rawBody
|
|
2713
|
+
});
|
|
2714
|
+
const isRoomish = !isDirectMessage;
|
|
2715
|
+
const historyKey = isRoomish ? conversationId : void 0;
|
|
2716
|
+
if (isRoomish && historyKey) combinedBody = buildPendingHistoryContextFromMap({
|
|
2717
|
+
historyMap: conversationHistories,
|
|
2718
|
+
historyKey,
|
|
2719
|
+
limit: historyLimit,
|
|
2720
|
+
currentMessage: combinedBody,
|
|
2721
|
+
formatEntry: (entry) => core.channel.reply.formatAgentEnvelope({
|
|
2722
|
+
channel: "Teams",
|
|
2723
|
+
from: conversationType,
|
|
2724
|
+
timestamp: entry.timestamp,
|
|
2725
|
+
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
|
2726
|
+
envelope: envelopeOptions
|
|
2727
|
+
})
|
|
2728
|
+
});
|
|
2729
|
+
const inboundHistory = isRoomish && historyKey && historyLimit > 0 ? (conversationHistories.get(historyKey) ?? []).map((entry) => ({
|
|
2730
|
+
sender: entry.sender,
|
|
2731
|
+
body: entry.body,
|
|
2732
|
+
timestamp: entry.timestamp
|
|
2733
|
+
})) : void 0;
|
|
2734
|
+
const commandBody = text.trim();
|
|
2735
|
+
const quoteSenderAllowed = quoteInfo && quoteInfo.sender ? !isChannel || groupPolicy !== "allowlist" ? true : resolveMSTeamsAllowlistMatch({
|
|
2736
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
2737
|
+
senderId: quoteSenderId ?? "",
|
|
2738
|
+
senderName: quoteSenderName,
|
|
2739
|
+
allowNameMatching
|
|
2740
|
+
}).allowed : true;
|
|
2741
|
+
const includeQuoteContext = quoteInfo && shouldIncludeSupplementalContext({
|
|
2742
|
+
mode: contextVisibilityMode,
|
|
2743
|
+
kind: "quote",
|
|
2744
|
+
senderAllowed: quoteSenderAllowed
|
|
2745
|
+
});
|
|
2746
|
+
const bodyForAgent = threadContext ? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${rawBody}` : rawBody;
|
|
2747
|
+
const nativeChannelId = isChannel && teamId ? `${teamId}/${conversationId}` : void 0;
|
|
2748
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
2749
|
+
Body: combinedBody,
|
|
2750
|
+
BodyForAgent: bodyForAgent,
|
|
2751
|
+
InboundHistory: inboundHistory,
|
|
2752
|
+
RawBody: rawBody,
|
|
2753
|
+
CommandBody: commandBody,
|
|
2754
|
+
BodyForCommands: commandBody,
|
|
2755
|
+
From: teamsFrom,
|
|
2756
|
+
To: teamsTo,
|
|
2757
|
+
SessionKey: route.sessionKey,
|
|
2758
|
+
AccountId: route.accountId,
|
|
2759
|
+
ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
|
2760
|
+
ConversationLabel: envelopeFrom,
|
|
2761
|
+
GroupSubject: !isDirectMessage ? conversationType : void 0,
|
|
2762
|
+
GroupSpace: teamId,
|
|
2763
|
+
SenderName: senderName,
|
|
2764
|
+
SenderId: senderId,
|
|
2765
|
+
Provider: "msteams",
|
|
2766
|
+
Surface: "msteams",
|
|
2767
|
+
MessageSid: activity.id,
|
|
2768
|
+
Timestamp: timestamp?.getTime() ?? Date.now(),
|
|
2769
|
+
WasMentioned: isDirectMessage || mentionDecision.effectiveWasMentioned,
|
|
2770
|
+
CommandAuthorized: commandAuthorized,
|
|
2771
|
+
OriginatingChannel: "msteams",
|
|
2772
|
+
OriginatingTo: teamsTo,
|
|
2773
|
+
NativeChannelId: nativeChannelId,
|
|
2774
|
+
ReplyToId: activity.replyToId ?? void 0,
|
|
2775
|
+
ReplyToBody: includeQuoteContext ? quoteInfo?.body : void 0,
|
|
2776
|
+
ReplyToSender: includeQuoteContext ? quoteInfo?.sender : void 0,
|
|
2777
|
+
ReplyToIsQuote: quoteInfo ? true : void 0,
|
|
2778
|
+
...mediaPayload
|
|
2779
|
+
});
|
|
2780
|
+
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
|
2781
|
+
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
|
|
2782
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
|
2783
|
+
cfg,
|
|
2784
|
+
agentId: route.agentId,
|
|
2785
|
+
sessionKey: route.sessionKey,
|
|
2786
|
+
accountId: route.accountId,
|
|
2787
|
+
runtime,
|
|
2788
|
+
log,
|
|
2789
|
+
adapter,
|
|
2790
|
+
appId,
|
|
2791
|
+
conversationRef,
|
|
2792
|
+
context,
|
|
2793
|
+
replyStyle,
|
|
2794
|
+
textLimit,
|
|
2795
|
+
onSentMessageIds: (ids) => {
|
|
2796
|
+
for (const id of ids) recordMSTeamsSentMessage(conversationId, id);
|
|
2797
|
+
},
|
|
2798
|
+
tokenProvider,
|
|
2799
|
+
sharePointSiteId
|
|
2800
|
+
});
|
|
2801
|
+
const senderTimezone = (activity.entities?.find((e) => e.type === "clientInfo"))?.timezone || conversationRef.timezone;
|
|
2802
|
+
const configOverride = senderTimezone && !cfg.agents?.defaults?.userTimezone ? { agents: { defaults: {
|
|
2803
|
+
...cfg.agents?.defaults,
|
|
2804
|
+
userTimezone: senderTimezone
|
|
2805
|
+
} } } : void 0;
|
|
2806
|
+
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
|
2807
|
+
try {
|
|
2808
|
+
const turnResult = await core.channel.turn.run({
|
|
2809
|
+
channel: "msteams",
|
|
2810
|
+
accountId: route.accountId,
|
|
2811
|
+
raw: context,
|
|
2812
|
+
adapter: {
|
|
2813
|
+
ingest: () => ({
|
|
2814
|
+
id: activity.id ?? `${teamsFrom}:${Date.now()}`,
|
|
2815
|
+
timestamp: timestamp?.getTime(),
|
|
2816
|
+
rawText: rawBody,
|
|
2817
|
+
textForAgent: bodyForAgent,
|
|
2818
|
+
textForCommands: commandBody,
|
|
2819
|
+
raw: activity
|
|
2820
|
+
}),
|
|
2821
|
+
resolveTurn: () => ({
|
|
2822
|
+
channel: "msteams",
|
|
2823
|
+
accountId: route.accountId,
|
|
2824
|
+
routeSessionKey: route.sessionKey,
|
|
2825
|
+
storePath,
|
|
2826
|
+
ctxPayload,
|
|
2827
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
2828
|
+
record: { onRecordError: (err) => {
|
|
2829
|
+
logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`);
|
|
2830
|
+
} },
|
|
2831
|
+
history: {
|
|
2832
|
+
isGroup: isRoomish,
|
|
2833
|
+
historyKey,
|
|
2834
|
+
historyMap: conversationHistories,
|
|
2835
|
+
limit: historyLimit
|
|
2836
|
+
},
|
|
2837
|
+
onPreDispatchFailure: () => core.channel.reply.settleReplyDispatcher({
|
|
2838
|
+
dispatcher,
|
|
2839
|
+
onSettled: () => markDispatchIdle()
|
|
2840
|
+
}),
|
|
2841
|
+
runDispatch: () => dispatchReplyFromConfigWithSettledDispatcher({
|
|
2842
|
+
cfg,
|
|
2843
|
+
ctxPayload,
|
|
2844
|
+
dispatcher,
|
|
2845
|
+
onSettled: () => markDispatchIdle(),
|
|
2846
|
+
replyOptions,
|
|
2847
|
+
configOverride
|
|
2848
|
+
})
|
|
2849
|
+
})
|
|
2850
|
+
}
|
|
2851
|
+
});
|
|
2852
|
+
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : void 0;
|
|
2853
|
+
const queuedFinal = dispatchResult?.queuedFinal ?? false;
|
|
2854
|
+
const counts = resolveInboundReplyDispatchCounts(dispatchResult);
|
|
2855
|
+
const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
|
|
2856
|
+
log.info("dispatch complete", {
|
|
2857
|
+
queuedFinal,
|
|
2858
|
+
counts
|
|
2859
|
+
});
|
|
2860
|
+
if (!hasFinalResponse) return;
|
|
2861
|
+
const finalCount = counts.final;
|
|
2862
|
+
logVerboseMessage(`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`);
|
|
2863
|
+
} catch (err) {
|
|
2864
|
+
log.error("dispatch failed", { error: formatUnknownError(err) });
|
|
2865
|
+
runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`);
|
|
2866
|
+
try {
|
|
2867
|
+
await context.sendActivity("⚠️ Something went wrong. Please try again.");
|
|
2868
|
+
} catch {}
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
const inboundDebouncer = core.channel.debounce.createInboundDebouncer({
|
|
2872
|
+
debounceMs: inboundDebounceMs,
|
|
2873
|
+
buildKey: (entry) => {
|
|
2874
|
+
const conversationId = normalizeMSTeamsConversationId(entry.context.activity.conversation?.id ?? "");
|
|
2875
|
+
const senderId = entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? "";
|
|
2876
|
+
if (!senderId || !conversationId) return null;
|
|
2877
|
+
return `msteams:${appId}:${conversationId}:${senderId}`;
|
|
2878
|
+
},
|
|
2879
|
+
shouldDebounce: (entry) => {
|
|
2880
|
+
if (!entry.text.trim()) return false;
|
|
2881
|
+
if (entry.attachments.length > 0) return false;
|
|
2882
|
+
return !core.channel.text.hasControlCommand(entry.text, cfg);
|
|
2883
|
+
},
|
|
2884
|
+
onFlush: async (entries) => {
|
|
2885
|
+
const last = entries.at(-1);
|
|
2886
|
+
if (!last) return;
|
|
2887
|
+
if (entries.length === 1) {
|
|
2888
|
+
await handleTeamsMessageNow(last);
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
const combinedText = entries.map((entry) => entry.text).filter(Boolean).join("\n");
|
|
2892
|
+
if (!combinedText.trim()) return;
|
|
2893
|
+
const combinedRawText = entries.map((entry) => entry.rawText).filter(Boolean).join("\n");
|
|
2894
|
+
const wasMentioned = entries.some((entry) => entry.wasMentioned);
|
|
2895
|
+
const implicitMentionKinds = entries.flatMap((entry) => entry.implicitMentionKinds);
|
|
2896
|
+
await handleTeamsMessageNow({
|
|
2897
|
+
context: last.context,
|
|
2898
|
+
rawText: combinedRawText,
|
|
2899
|
+
text: combinedText,
|
|
2900
|
+
attachments: [],
|
|
2901
|
+
wasMentioned,
|
|
2902
|
+
implicitMentionKinds
|
|
2903
|
+
});
|
|
2904
|
+
},
|
|
2905
|
+
onError: (err) => {
|
|
2906
|
+
runtime.error?.(`msteams debounce flush failed: ${formatUnknownError(err)}`);
|
|
2907
|
+
}
|
|
2908
|
+
});
|
|
2909
|
+
return async function handleTeamsMessage(context) {
|
|
2910
|
+
const activity = context.activity;
|
|
2911
|
+
const attachments = Array.isArray(activity.attachments) ? activity.attachments : [];
|
|
2912
|
+
const rawText = activity.text?.trim() ?? "";
|
|
2913
|
+
const htmlText = extractTextFromHtmlAttachments(attachments);
|
|
2914
|
+
const text = stripMSTeamsMentionTags(rawText || htmlText);
|
|
2915
|
+
const wasMentioned = wasMSTeamsBotMentioned(activity);
|
|
2916
|
+
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
|
|
2917
|
+
const replyToId = activity.replyToId ?? void 0;
|
|
2918
|
+
const implicitMentionKinds = conversationId && replyToId && await wasMSTeamsMessageSentWithPersistence({
|
|
2919
|
+
conversationId,
|
|
2920
|
+
messageId: replyToId
|
|
2921
|
+
}) ? ["reply_to_bot"] : [];
|
|
2922
|
+
await inboundDebouncer.enqueue({
|
|
2923
|
+
context,
|
|
2924
|
+
rawText,
|
|
2925
|
+
text,
|
|
2926
|
+
attachments,
|
|
2927
|
+
wasMentioned,
|
|
2928
|
+
implicitMentionKinds
|
|
2929
|
+
});
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
//#endregion
|
|
2933
|
+
//#region extensions/msteams/src/monitor-handler/reaction-handler.ts
|
|
2934
|
+
/** Teams reaction type names → Unicode emoji. */
|
|
2935
|
+
const TEAMS_REACTION_EMOJI = {
|
|
2936
|
+
like: "👍",
|
|
2937
|
+
heart: "❤️",
|
|
2938
|
+
laugh: "😆",
|
|
2939
|
+
surprised: "😮",
|
|
2940
|
+
sad: "😢",
|
|
2941
|
+
angry: "😡"
|
|
2942
|
+
};
|
|
2943
|
+
/**
|
|
2944
|
+
* Map a Teams reaction type string to a Unicode emoji.
|
|
2945
|
+
* Falls back to the raw type if not recognized.
|
|
2946
|
+
*/
|
|
2947
|
+
function mapReactionEmoji(reactionType) {
|
|
2948
|
+
return TEAMS_REACTION_EMOJI[reactionType] ?? reactionType;
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Create a handler for MS Teams reaction activities (reactionsAdded / reactionsRemoved).
|
|
2952
|
+
* The returned function accepts a turn context and a direction string.
|
|
2953
|
+
*/
|
|
2954
|
+
function createMSTeamsReactionHandler(deps) {
|
|
2955
|
+
const { cfg, log } = deps;
|
|
2956
|
+
const core = getMSTeamsRuntime();
|
|
2957
|
+
const msteamsCfg = cfg.channels?.msteams;
|
|
2958
|
+
const pairing = createChannelPairingController({
|
|
2959
|
+
core,
|
|
2960
|
+
channel: "msteams",
|
|
2961
|
+
accountId: DEFAULT_ACCOUNT_ID
|
|
2962
|
+
});
|
|
2963
|
+
return async function handleReaction(context, direction) {
|
|
2964
|
+
const activity = context.activity;
|
|
2965
|
+
const reactions = direction === "added" ? activity.reactionsAdded ?? [] : activity.reactionsRemoved ?? [];
|
|
2966
|
+
if (reactions.length === 0) {
|
|
2967
|
+
log.debug?.("reaction activity has no reactions; skipping");
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
const from = activity.from;
|
|
2971
|
+
if (!from?.id) {
|
|
2972
|
+
log.debug?.("reaction activity missing from.id; skipping");
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
|
|
2976
|
+
const conversationType = activity.conversation?.conversationType ?? "personal";
|
|
2977
|
+
const isGroupChat = conversationType === "groupChat" || activity.conversation?.isGroup === true;
|
|
2978
|
+
const isChannel = conversationType === "channel";
|
|
2979
|
+
const isDirectMessage = !isGroupChat && !isChannel;
|
|
2980
|
+
const senderId = from.aadObjectId ?? from.id;
|
|
2981
|
+
const senderName = from.name ?? from.id;
|
|
2982
|
+
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
|
|
2983
|
+
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
|
|
2984
|
+
provider: "msteams",
|
|
2985
|
+
accountId: pairing.accountId,
|
|
2986
|
+
dmPolicy,
|
|
2987
|
+
readStore: pairing.readStoreForDmPolicy
|
|
2988
|
+
});
|
|
2989
|
+
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
|
|
2990
|
+
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
|
|
2991
|
+
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
|
|
2992
|
+
allowFrom: dmAllowFrom,
|
|
2993
|
+
groupAllowFrom,
|
|
2994
|
+
storeAllowFrom: storedAllowFrom,
|
|
2995
|
+
dmPolicy
|
|
2996
|
+
});
|
|
2997
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
2998
|
+
if (isDirectMessage && msteamsCfg) {
|
|
2999
|
+
const access = resolveDmGroupAccessWithLists({
|
|
3000
|
+
isGroup: false,
|
|
3001
|
+
dmPolicy,
|
|
3002
|
+
groupPolicy: msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist",
|
|
3003
|
+
allowFrom: dmAllowFrom,
|
|
3004
|
+
groupAllowFrom,
|
|
3005
|
+
storeAllowFrom: storedAllowFrom,
|
|
3006
|
+
groupAllowFromFallbackToAllowFrom: false,
|
|
3007
|
+
isSenderAllowed: (allowFrom) => resolveMSTeamsAllowlistMatch({
|
|
3008
|
+
allowFrom,
|
|
3009
|
+
senderId,
|
|
3010
|
+
senderName,
|
|
3011
|
+
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg)
|
|
3012
|
+
}).allowed
|
|
3013
|
+
});
|
|
3014
|
+
if (access.decision !== "allow") {
|
|
3015
|
+
log.debug?.("dropping reaction (dm access denied)", {
|
|
3016
|
+
sender: senderId,
|
|
3017
|
+
reason: access.reason
|
|
3018
|
+
});
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
if (!isDirectMessage && msteamsCfg) {
|
|
3023
|
+
const teamId = activity.channelData?.team?.id;
|
|
3024
|
+
const teamName = activity.channelData?.team?.name;
|
|
3025
|
+
const channelName = activity.channelData?.channel?.name;
|
|
3026
|
+
const channelGate = resolveMSTeamsRouteConfig({
|
|
3027
|
+
cfg: msteamsCfg,
|
|
3028
|
+
teamId,
|
|
3029
|
+
teamName,
|
|
3030
|
+
conversationId,
|
|
3031
|
+
channelName,
|
|
3032
|
+
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg)
|
|
3033
|
+
});
|
|
3034
|
+
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
|
3035
|
+
log.debug?.("dropping reaction (not in team/channel allowlist)", { conversationId });
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
|
|
3039
|
+
if (!isMSTeamsGroupAllowed({
|
|
3040
|
+
groupPolicy: msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist",
|
|
3041
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
3042
|
+
senderId,
|
|
3043
|
+
senderName,
|
|
3044
|
+
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg)
|
|
3045
|
+
})) {
|
|
3046
|
+
log.debug?.("dropping reaction (sender not in group allowlist)", { sender: senderId });
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
const teamId = isDirectMessage ? void 0 : activity.channelData?.team?.id;
|
|
3051
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
3052
|
+
cfg,
|
|
3053
|
+
channel: "msteams",
|
|
3054
|
+
peer: {
|
|
3055
|
+
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
|
3056
|
+
id: isDirectMessage ? senderId : conversationId
|
|
3057
|
+
},
|
|
3058
|
+
...teamId ? { teamId } : {}
|
|
3059
|
+
});
|
|
3060
|
+
const targetMessageId = activity.replyToId ?? "unknown";
|
|
3061
|
+
for (const reaction of reactions) {
|
|
3062
|
+
const reactionType = reaction.type ?? "unknown";
|
|
3063
|
+
const emoji = mapReactionEmoji(reactionType);
|
|
3064
|
+
const label = direction === "added" ? `Teams reaction ${emoji} added by ${senderName} on message ${targetMessageId}` : `Teams reaction ${emoji} removed by ${senderName} from message ${targetMessageId}`;
|
|
3065
|
+
log.info(`reaction ${direction}`, {
|
|
3066
|
+
sender: senderId,
|
|
3067
|
+
reactionType,
|
|
3068
|
+
emoji,
|
|
3069
|
+
targetMessageId,
|
|
3070
|
+
conversationId
|
|
3071
|
+
});
|
|
3072
|
+
core.system.enqueueSystemEvent(label, {
|
|
3073
|
+
sessionKey: route.sessionKey,
|
|
3074
|
+
contextKey: `msteams:reaction:${conversationId}:${targetMessageId}:${senderId}:${reactionType}:${direction}`
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
//#endregion
|
|
3080
|
+
//#region extensions/msteams/src/sso.ts
|
|
3081
|
+
/** Scope used to obtain a Bot Framework service token. */
|
|
3082
|
+
const BOT_FRAMEWORK_TOKEN_SCOPE = "https://api.botframework.com/.default";
|
|
3083
|
+
/** Bot Framework User Token service base URL. */
|
|
3084
|
+
const BOT_FRAMEWORK_USER_TOKEN_BASE_URL = "https://token.botframework.com";
|
|
3085
|
+
/**
|
|
3086
|
+
* Extract and validate the `signin/tokenExchange` activity value. Teams
|
|
3087
|
+
* delivers `{ id, connectionName, token }`; any field may be missing on
|
|
3088
|
+
* malformed invocations, so callers should check the parsed result.
|
|
3089
|
+
*/
|
|
3090
|
+
function parseSigninTokenExchangeValue(value) {
|
|
3091
|
+
if (!value || typeof value !== "object") return null;
|
|
3092
|
+
const obj = value;
|
|
3093
|
+
return {
|
|
3094
|
+
id: typeof obj.id === "string" ? obj.id : void 0,
|
|
3095
|
+
connectionName: typeof obj.connectionName === "string" ? obj.connectionName : void 0,
|
|
3096
|
+
token: typeof obj.token === "string" ? obj.token : void 0
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
/** Extract the `signin/verifyState` activity value `{ state }`. */
|
|
3100
|
+
function parseSigninVerifyStateValue(value) {
|
|
3101
|
+
if (!value || typeof value !== "object") return null;
|
|
3102
|
+
const obj = value;
|
|
3103
|
+
return { state: typeof obj.state === "string" ? obj.state : void 0 };
|
|
3104
|
+
}
|
|
3105
|
+
async function callUserTokenService(params) {
|
|
3106
|
+
const qs = new URLSearchParams(params.query).toString();
|
|
3107
|
+
const url = `${params.baseUrl.replace(/\/+$/, "")}${params.path}?${qs}`;
|
|
3108
|
+
const headers = {
|
|
3109
|
+
Accept: "application/json",
|
|
3110
|
+
Authorization: `Bearer ${params.bearerToken}`,
|
|
3111
|
+
"User-Agent": buildUserAgent()
|
|
3112
|
+
};
|
|
3113
|
+
if (params.body !== void 0) headers["Content-Type"] = "application/json";
|
|
3114
|
+
const response = await params.fetchImpl(url, {
|
|
3115
|
+
method: params.method,
|
|
3116
|
+
headers,
|
|
3117
|
+
body: params.body === void 0 ? void 0 : JSON.stringify(params.body)
|
|
3118
|
+
});
|
|
3119
|
+
if (!response.ok) return {
|
|
3120
|
+
error: await response.text().catch(() => "") || `HTTP ${response.status}`,
|
|
3121
|
+
status: response.status
|
|
3122
|
+
};
|
|
3123
|
+
let parsed;
|
|
3124
|
+
try {
|
|
3125
|
+
parsed = await response.json();
|
|
3126
|
+
} catch {
|
|
3127
|
+
return {
|
|
3128
|
+
error: "invalid JSON from User Token service",
|
|
3129
|
+
status: response.status
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
if (!parsed || typeof parsed !== "object") return {
|
|
3133
|
+
error: "empty response from User Token service",
|
|
3134
|
+
status: response.status
|
|
3135
|
+
};
|
|
3136
|
+
const obj = parsed;
|
|
3137
|
+
const token = typeof obj.token === "string" ? obj.token : void 0;
|
|
3138
|
+
const connectionName = typeof obj.connectionName === "string" ? obj.connectionName : void 0;
|
|
3139
|
+
const channelId = typeof obj.channelId === "string" ? obj.channelId : void 0;
|
|
3140
|
+
const expiration = typeof obj.expiration === "string" ? obj.expiration : void 0;
|
|
3141
|
+
if (!token || !connectionName) return {
|
|
3142
|
+
error: "User Token service response missing token/connectionName",
|
|
3143
|
+
status: 502
|
|
3144
|
+
};
|
|
3145
|
+
return {
|
|
3146
|
+
channelId,
|
|
3147
|
+
connectionName,
|
|
3148
|
+
token,
|
|
3149
|
+
expiration
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Exchange a Teams SSO token for a delegated user token via Bot
|
|
3154
|
+
* Framework's User Token service, then persist the result.
|
|
3155
|
+
*/
|
|
3156
|
+
async function handleSigninTokenExchangeInvoke(params) {
|
|
3157
|
+
const { value, user, deps } = params;
|
|
3158
|
+
if (!user.userId) return {
|
|
3159
|
+
ok: false,
|
|
3160
|
+
code: "missing_user",
|
|
3161
|
+
message: "no user id on invoke activity"
|
|
3162
|
+
};
|
|
3163
|
+
const connectionName = value.connectionName?.trim() || deps.connectionName;
|
|
3164
|
+
if (!connectionName) return {
|
|
3165
|
+
ok: false,
|
|
3166
|
+
code: "missing_connection",
|
|
3167
|
+
message: "no OAuth connection name"
|
|
3168
|
+
};
|
|
3169
|
+
if (!value.token) return {
|
|
3170
|
+
ok: false,
|
|
3171
|
+
code: "missing_token",
|
|
3172
|
+
message: "no exchangeable token on invoke"
|
|
3173
|
+
};
|
|
3174
|
+
const bearer = await deps.tokenProvider.getAccessToken(BOT_FRAMEWORK_TOKEN_SCOPE);
|
|
3175
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
3176
|
+
const result = await callUserTokenService({
|
|
3177
|
+
baseUrl: deps.userTokenBaseUrl ?? BOT_FRAMEWORK_USER_TOKEN_BASE_URL,
|
|
3178
|
+
path: "/api/usertoken/exchange",
|
|
3179
|
+
query: {
|
|
3180
|
+
userId: user.userId,
|
|
3181
|
+
connectionName,
|
|
3182
|
+
channelId: user.channelId ?? "msteams"
|
|
3183
|
+
},
|
|
3184
|
+
method: "POST",
|
|
3185
|
+
body: { token: value.token },
|
|
3186
|
+
bearerToken: bearer,
|
|
3187
|
+
fetchImpl
|
|
3188
|
+
});
|
|
3189
|
+
if ("error" in result) return {
|
|
3190
|
+
ok: false,
|
|
3191
|
+
code: result.status >= 500 ? "service_error" : "unexpected_response",
|
|
3192
|
+
message: result.error,
|
|
3193
|
+
status: result.status
|
|
3194
|
+
};
|
|
3195
|
+
await deps.tokenStore.save({
|
|
3196
|
+
connectionName,
|
|
3197
|
+
userId: user.userId,
|
|
3198
|
+
token: result.token,
|
|
3199
|
+
expiresAt: result.expiration,
|
|
3200
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3201
|
+
});
|
|
3202
|
+
return {
|
|
3203
|
+
ok: true,
|
|
3204
|
+
token: result.token,
|
|
3205
|
+
expiresAt: result.expiration
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Finish a magic-code sign-in: look up the user token for the state
|
|
3210
|
+
* code via Bot Framework's User Token service, then persist it.
|
|
3211
|
+
*/
|
|
3212
|
+
async function handleSigninVerifyStateInvoke(params) {
|
|
3213
|
+
const { value, user, deps } = params;
|
|
3214
|
+
if (!user.userId) return {
|
|
3215
|
+
ok: false,
|
|
3216
|
+
code: "missing_user",
|
|
3217
|
+
message: "no user id on invoke activity"
|
|
3218
|
+
};
|
|
3219
|
+
if (!deps.connectionName) return {
|
|
3220
|
+
ok: false,
|
|
3221
|
+
code: "missing_connection",
|
|
3222
|
+
message: "no OAuth connection name"
|
|
3223
|
+
};
|
|
3224
|
+
const state = value.state?.trim();
|
|
3225
|
+
if (!state) return {
|
|
3226
|
+
ok: false,
|
|
3227
|
+
code: "missing_state",
|
|
3228
|
+
message: "no state code on invoke"
|
|
3229
|
+
};
|
|
3230
|
+
const bearer = await deps.tokenProvider.getAccessToken(BOT_FRAMEWORK_TOKEN_SCOPE);
|
|
3231
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
3232
|
+
const result = await callUserTokenService({
|
|
3233
|
+
baseUrl: deps.userTokenBaseUrl ?? BOT_FRAMEWORK_USER_TOKEN_BASE_URL,
|
|
3234
|
+
path: "/api/usertoken/GetToken",
|
|
3235
|
+
query: {
|
|
3236
|
+
userId: user.userId,
|
|
3237
|
+
connectionName: deps.connectionName,
|
|
3238
|
+
channelId: user.channelId ?? "msteams",
|
|
3239
|
+
code: state
|
|
3240
|
+
},
|
|
3241
|
+
method: "GET",
|
|
3242
|
+
bearerToken: bearer,
|
|
3243
|
+
fetchImpl
|
|
3244
|
+
});
|
|
3245
|
+
if ("error" in result) return {
|
|
3246
|
+
ok: false,
|
|
3247
|
+
code: result.status >= 500 ? "service_error" : "unexpected_response",
|
|
3248
|
+
message: result.error,
|
|
3249
|
+
status: result.status
|
|
3250
|
+
};
|
|
3251
|
+
await deps.tokenStore.save({
|
|
3252
|
+
connectionName: deps.connectionName,
|
|
3253
|
+
userId: user.userId,
|
|
3254
|
+
token: result.token,
|
|
3255
|
+
expiresAt: result.expiration,
|
|
3256
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3257
|
+
});
|
|
3258
|
+
return {
|
|
3259
|
+
ok: true,
|
|
3260
|
+
token: result.token,
|
|
3261
|
+
expiresAt: result.expiration
|
|
3262
|
+
};
|
|
3263
|
+
}
|
|
3264
|
+
//#endregion
|
|
3265
|
+
//#region extensions/msteams/src/welcome-card.ts
|
|
3266
|
+
/**
|
|
3267
|
+
* Builds an Adaptive Card for welcoming users when the bot is added to a conversation.
|
|
3268
|
+
*/
|
|
3269
|
+
const DEFAULT_PROMPT_STARTERS = [
|
|
3270
|
+
"What can you do?",
|
|
3271
|
+
"Summarize my last meeting",
|
|
3272
|
+
"Help me draft an email"
|
|
3273
|
+
];
|
|
3274
|
+
/**
|
|
3275
|
+
* Build a welcome Adaptive Card for 1:1 personal chats.
|
|
3276
|
+
*/
|
|
3277
|
+
function buildWelcomeCard(options) {
|
|
3278
|
+
const botName = options?.botName || "OpenClaw";
|
|
3279
|
+
const starters = options?.promptStarters?.length ? options.promptStarters : DEFAULT_PROMPT_STARTERS;
|
|
3280
|
+
return {
|
|
3281
|
+
type: "AdaptiveCard",
|
|
3282
|
+
version: "1.5",
|
|
3283
|
+
body: [{
|
|
3284
|
+
type: "TextBlock",
|
|
3285
|
+
text: `Hi! I'm ${botName}.`,
|
|
3286
|
+
weight: "bolder",
|
|
3287
|
+
size: "medium"
|
|
3288
|
+
}, {
|
|
3289
|
+
type: "TextBlock",
|
|
3290
|
+
text: "I can help you with questions, tasks, and more. Here are some things to try:",
|
|
3291
|
+
wrap: true
|
|
3292
|
+
}],
|
|
3293
|
+
actions: starters.map((label) => ({
|
|
3294
|
+
type: "Action.Submit",
|
|
3295
|
+
title: label,
|
|
3296
|
+
data: { msteams: {
|
|
3297
|
+
type: "imBack",
|
|
3298
|
+
value: label
|
|
3299
|
+
} }
|
|
3300
|
+
}))
|
|
3301
|
+
};
|
|
3302
|
+
}
|
|
3303
|
+
/**
|
|
3304
|
+
* Build a brief welcome message for group chats (when the bot is @mentioned).
|
|
3305
|
+
*/
|
|
3306
|
+
function buildGroupWelcomeText(botName) {
|
|
3307
|
+
const name = botName || "OpenClaw";
|
|
3308
|
+
return `Hi! I'm ${name}. Mention me with @${name} to get started.`;
|
|
3309
|
+
}
|
|
3310
|
+
//#endregion
|
|
3311
|
+
//#region extensions/msteams/src/monitor-handler.ts
|
|
3312
|
+
function serializeAdaptiveCardActionValue(value) {
|
|
3313
|
+
if (typeof value === "string") {
|
|
3314
|
+
const trimmed = value.trim();
|
|
3315
|
+
return trimmed ? trimmed : null;
|
|
3316
|
+
}
|
|
3317
|
+
if (value === void 0) return null;
|
|
3318
|
+
try {
|
|
3319
|
+
return JSON.stringify(value);
|
|
3320
|
+
} catch {
|
|
3321
|
+
return null;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
async function isInvokeAuthorized(params) {
|
|
3325
|
+
const { context, deps, deniedLogs, includeInvokeName = false } = params;
|
|
3326
|
+
const resolved = await resolveMSTeamsSenderAccess({
|
|
3327
|
+
cfg: deps.cfg,
|
|
3328
|
+
activity: context.activity
|
|
3329
|
+
});
|
|
3330
|
+
const { msteamsCfg, isDirectMessage, conversationId, senderId } = resolved;
|
|
3331
|
+
if (!msteamsCfg) return true;
|
|
3332
|
+
const maybeInvokeName = includeInvokeName ? { name: context.activity.name } : void 0;
|
|
3333
|
+
if (isDirectMessage && resolved.access.decision !== "allow") {
|
|
3334
|
+
deps.log.debug?.(deniedLogs.dm, {
|
|
3335
|
+
sender: senderId,
|
|
3336
|
+
conversationId,
|
|
3337
|
+
...maybeInvokeName
|
|
3338
|
+
});
|
|
3339
|
+
return false;
|
|
3340
|
+
}
|
|
3341
|
+
if (!isDirectMessage && resolved.channelGate.allowlistConfigured && !resolved.channelGate.allowed) {
|
|
3342
|
+
deps.log.debug?.(deniedLogs.channel, {
|
|
3343
|
+
conversationId,
|
|
3344
|
+
teamKey: resolved.channelGate.teamKey ?? "none",
|
|
3345
|
+
channelKey: resolved.channelGate.channelKey ?? "none",
|
|
3346
|
+
...maybeInvokeName
|
|
3347
|
+
});
|
|
3348
|
+
return false;
|
|
3349
|
+
}
|
|
3350
|
+
if (!isDirectMessage && !resolved.senderGroupAccess.allowed) {
|
|
3351
|
+
deps.log.debug?.(deniedLogs.group, {
|
|
3352
|
+
sender: senderId,
|
|
3353
|
+
conversationId,
|
|
3354
|
+
...maybeInvokeName
|
|
3355
|
+
});
|
|
3356
|
+
return false;
|
|
3357
|
+
}
|
|
3358
|
+
return true;
|
|
3359
|
+
}
|
|
3360
|
+
async function isFeedbackInvokeAuthorized(context, deps) {
|
|
3361
|
+
return isInvokeAuthorized({
|
|
3362
|
+
context,
|
|
3363
|
+
deps,
|
|
3364
|
+
deniedLogs: {
|
|
3365
|
+
dm: "dropping feedback invoke (dm sender not allowlisted)",
|
|
3366
|
+
channel: "dropping feedback invoke (not in team/channel allowlist)",
|
|
3367
|
+
group: "dropping feedback invoke (group sender not allowlisted)"
|
|
3368
|
+
}
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
async function isSigninInvokeAuthorized(context, deps) {
|
|
3372
|
+
return isInvokeAuthorized({
|
|
3373
|
+
context,
|
|
3374
|
+
deps,
|
|
3375
|
+
deniedLogs: {
|
|
3376
|
+
dm: "dropping signin invoke (dm sender not allowlisted)",
|
|
3377
|
+
channel: "dropping signin invoke (not in team/channel allowlist)",
|
|
3378
|
+
group: "dropping signin invoke (group sender not allowlisted)"
|
|
3379
|
+
},
|
|
3380
|
+
includeInvokeName: true
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Parse and handle feedback invoke activities (thumbs up/down).
|
|
3385
|
+
* Returns true if the activity was a feedback invoke, false otherwise.
|
|
3386
|
+
*/
|
|
3387
|
+
async function handleFeedbackInvoke(context, deps) {
|
|
3388
|
+
const activity = context.activity;
|
|
3389
|
+
const value = activity.value;
|
|
3390
|
+
if (!value) return false;
|
|
3391
|
+
if (value.actionName !== "feedback") return false;
|
|
3392
|
+
const reaction = value.actionValue?.reaction;
|
|
3393
|
+
if (reaction !== "like" && reaction !== "dislike") {
|
|
3394
|
+
deps.log.debug?.("ignoring feedback with unknown reaction", { reaction });
|
|
3395
|
+
return false;
|
|
3396
|
+
}
|
|
3397
|
+
const msteamsCfg = deps.cfg.channels?.msteams;
|
|
3398
|
+
if (msteamsCfg?.feedbackEnabled === false) {
|
|
3399
|
+
deps.log.debug?.("feedback handling disabled");
|
|
3400
|
+
return true;
|
|
3401
|
+
}
|
|
3402
|
+
if (!await isFeedbackInvokeAuthorized(context, deps)) return true;
|
|
3403
|
+
let userComment;
|
|
3404
|
+
if (value.actionValue?.feedback) try {
|
|
3405
|
+
userComment = JSON.parse(value.actionValue.feedback).feedbackText || void 0;
|
|
3406
|
+
} catch {}
|
|
3407
|
+
const rawConversationId = activity.conversation?.id ?? "unknown";
|
|
3408
|
+
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
|
3409
|
+
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
|
|
3410
|
+
const messageId = value.replyToId ?? activity.replyToId ?? "unknown";
|
|
3411
|
+
const isNegative = reaction === "dislike";
|
|
3412
|
+
const convType = normalizeOptionalLowercaseString(activity.conversation?.conversationType);
|
|
3413
|
+
const isDirectMessage = convType === "personal" || !convType && !activity.conversation?.isGroup;
|
|
3414
|
+
const isChannel = convType === "channel";
|
|
3415
|
+
const core = getMSTeamsRuntime();
|
|
3416
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
3417
|
+
cfg: deps.cfg,
|
|
3418
|
+
channel: "msteams",
|
|
3419
|
+
peer: {
|
|
3420
|
+
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
|
3421
|
+
id: isDirectMessage ? senderId : conversationId
|
|
3422
|
+
}
|
|
3423
|
+
});
|
|
3424
|
+
const feedbackThreadId = isChannel ? extractMSTeamsConversationMessageId(rawConversationId) ?? activity.replyToId ?? void 0 : void 0;
|
|
3425
|
+
if (feedbackThreadId) route.sessionKey = resolveThreadSessionKeys({
|
|
3426
|
+
baseSessionKey: route.sessionKey,
|
|
3427
|
+
threadId: feedbackThreadId,
|
|
3428
|
+
parentSessionKey: route.sessionKey
|
|
3429
|
+
}).sessionKey;
|
|
3430
|
+
const feedbackEvent = buildFeedbackEvent({
|
|
3431
|
+
messageId,
|
|
3432
|
+
value: isNegative ? "negative" : "positive",
|
|
3433
|
+
comment: userComment,
|
|
3434
|
+
sessionKey: route.sessionKey,
|
|
3435
|
+
agentId: route.agentId,
|
|
3436
|
+
conversationId
|
|
3437
|
+
});
|
|
3438
|
+
deps.log.info("received feedback", {
|
|
3439
|
+
value: feedbackEvent.value,
|
|
3440
|
+
messageId,
|
|
3441
|
+
conversationId,
|
|
3442
|
+
hasComment: Boolean(userComment)
|
|
3443
|
+
});
|
|
3444
|
+
try {
|
|
3445
|
+
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, { agentId: route.agentId });
|
|
3446
|
+
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
3447
|
+
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
|
|
3448
|
+
await fs.appendFile(transcriptFile, JSON.stringify(feedbackEvent) + "\n", "utf-8").catch(() => {});
|
|
3449
|
+
} catch {}
|
|
3450
|
+
const conversationRef = {
|
|
3451
|
+
activityId: activity.id,
|
|
3452
|
+
user: {
|
|
3453
|
+
id: activity.from?.id,
|
|
3454
|
+
name: activity.from?.name,
|
|
3455
|
+
aadObjectId: activity.from?.aadObjectId
|
|
3456
|
+
},
|
|
3457
|
+
agent: activity.recipient ? {
|
|
3458
|
+
id: activity.recipient.id,
|
|
3459
|
+
name: activity.recipient.name
|
|
3460
|
+
} : void 0,
|
|
3461
|
+
bot: activity.recipient ? {
|
|
3462
|
+
id: activity.recipient.id,
|
|
3463
|
+
name: activity.recipient.name
|
|
3464
|
+
} : void 0,
|
|
3465
|
+
conversation: {
|
|
3466
|
+
id: conversationId,
|
|
3467
|
+
conversationType: activity.conversation?.conversationType,
|
|
3468
|
+
tenantId: activity.conversation?.tenantId
|
|
3469
|
+
},
|
|
3470
|
+
channelId: activity.channelId ?? "msteams",
|
|
3471
|
+
serviceUrl: activity.serviceUrl,
|
|
3472
|
+
locale: activity.locale
|
|
3473
|
+
};
|
|
3474
|
+
if (isNegative && msteamsCfg?.feedbackReflection !== false) runFeedbackReflection({
|
|
3475
|
+
cfg: deps.cfg,
|
|
3476
|
+
adapter: deps.adapter,
|
|
3477
|
+
appId: deps.appId,
|
|
3478
|
+
conversationRef,
|
|
3479
|
+
sessionKey: route.sessionKey,
|
|
3480
|
+
agentId: route.agentId,
|
|
3481
|
+
conversationId,
|
|
3482
|
+
feedbackMessageId: messageId,
|
|
3483
|
+
userComment,
|
|
3484
|
+
log: deps.log
|
|
3485
|
+
}).catch((err) => {
|
|
3486
|
+
deps.log.error("feedback reflection failed", { error: formatUnknownError(err) });
|
|
3487
|
+
});
|
|
3488
|
+
return true;
|
|
3489
|
+
}
|
|
3490
|
+
function registerMSTeamsHandlers(handler, deps) {
|
|
3491
|
+
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
|
|
3492
|
+
const handleReaction = createMSTeamsReactionHandler(deps);
|
|
3493
|
+
const originalRun = handler.run;
|
|
3494
|
+
if (originalRun) handler.run = async (context) => {
|
|
3495
|
+
const ctx = context;
|
|
3496
|
+
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
|
|
3497
|
+
await respondToMSTeamsFileConsentInvoke(ctx, deps.log);
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "message/submitAction") {
|
|
3501
|
+
if (await handleFeedbackInvoke(ctx, deps)) return;
|
|
3502
|
+
}
|
|
3503
|
+
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "adaptiveCard/action") {
|
|
3504
|
+
const text = serializeAdaptiveCardActionValue(ctx.activity?.value);
|
|
3505
|
+
if (text) {
|
|
3506
|
+
await handleTeamsMessage({
|
|
3507
|
+
...ctx,
|
|
3508
|
+
activity: {
|
|
3509
|
+
...ctx.activity,
|
|
3510
|
+
type: "message",
|
|
3511
|
+
text
|
|
3512
|
+
}
|
|
3513
|
+
});
|
|
3514
|
+
return;
|
|
3515
|
+
}
|
|
3516
|
+
deps.log.debug?.("skipping adaptive card action invoke without value payload");
|
|
3517
|
+
}
|
|
3518
|
+
if (ctx.activity?.type === "invoke" && (ctx.activity?.name === "signin/tokenExchange" || ctx.activity?.name === "signin/verifyState")) {
|
|
3519
|
+
await ctx.sendActivity({
|
|
3520
|
+
type: "invokeResponse",
|
|
3521
|
+
value: {
|
|
3522
|
+
status: 200,
|
|
3523
|
+
body: {}
|
|
3524
|
+
}
|
|
3525
|
+
});
|
|
3526
|
+
if (!await isSigninInvokeAuthorized(ctx, deps)) return;
|
|
3527
|
+
if (!deps.sso) {
|
|
3528
|
+
deps.log.debug?.("signin invoke received but msteams.sso is not configured", { name: ctx.activity.name });
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
const user = {
|
|
3532
|
+
userId: ctx.activity.from?.aadObjectId ?? ctx.activity.from?.id ?? "",
|
|
3533
|
+
channelId: ctx.activity.channelId ?? "msteams"
|
|
3534
|
+
};
|
|
3535
|
+
try {
|
|
3536
|
+
if (ctx.activity.name === "signin/tokenExchange") {
|
|
3537
|
+
const parsed = parseSigninTokenExchangeValue(ctx.activity.value);
|
|
3538
|
+
if (!parsed) {
|
|
3539
|
+
deps.log.debug?.("invalid signin/tokenExchange invoke value");
|
|
3540
|
+
return;
|
|
3541
|
+
}
|
|
3542
|
+
const result = await handleSigninTokenExchangeInvoke({
|
|
3543
|
+
value: parsed,
|
|
3544
|
+
user,
|
|
3545
|
+
deps: deps.sso
|
|
3546
|
+
});
|
|
3547
|
+
if (result.ok) deps.log.info("msteams sso token exchanged", {
|
|
3548
|
+
userId: user.userId,
|
|
3549
|
+
hasExpiry: Boolean(result.expiresAt)
|
|
3550
|
+
});
|
|
3551
|
+
else deps.log.error("msteams sso token exchange failed", {
|
|
3552
|
+
code: result.code,
|
|
3553
|
+
status: result.status,
|
|
3554
|
+
message: result.message
|
|
3555
|
+
});
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
const parsed = parseSigninVerifyStateValue(ctx.activity.value);
|
|
3559
|
+
if (!parsed) {
|
|
3560
|
+
deps.log.debug?.("invalid signin/verifyState invoke value");
|
|
3561
|
+
return;
|
|
3562
|
+
}
|
|
3563
|
+
const result = await handleSigninVerifyStateInvoke({
|
|
3564
|
+
value: parsed,
|
|
3565
|
+
user,
|
|
3566
|
+
deps: deps.sso
|
|
3567
|
+
});
|
|
3568
|
+
if (result.ok) deps.log.info("msteams sso verifyState succeeded", {
|
|
3569
|
+
userId: user.userId,
|
|
3570
|
+
hasExpiry: Boolean(result.expiresAt)
|
|
3571
|
+
});
|
|
3572
|
+
else deps.log.error("msteams sso verifyState failed", {
|
|
3573
|
+
code: result.code,
|
|
3574
|
+
status: result.status,
|
|
3575
|
+
message: result.message
|
|
3576
|
+
});
|
|
3577
|
+
} catch (err) {
|
|
3578
|
+
deps.log.error("msteams sso invoke handler error", { error: formatUnknownError(err) });
|
|
3579
|
+
}
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
return originalRun.call(handler, context);
|
|
3583
|
+
};
|
|
3584
|
+
handler.onMessage(async (context, next) => {
|
|
3585
|
+
try {
|
|
3586
|
+
await handleTeamsMessage(context);
|
|
3587
|
+
} catch (err) {
|
|
3588
|
+
deps.runtime.error?.(`msteams handler failed: ${formatUnknownError(err)}`);
|
|
3589
|
+
}
|
|
3590
|
+
await next();
|
|
3591
|
+
});
|
|
3592
|
+
handler.onMembersAdded(async (context, next) => {
|
|
3593
|
+
const ctx = context;
|
|
3594
|
+
const membersAdded = ctx.activity?.membersAdded ?? [];
|
|
3595
|
+
const botId = ctx.activity?.recipient?.id;
|
|
3596
|
+
const msteamsCfg = deps.cfg.channels?.msteams;
|
|
3597
|
+
for (const member of membersAdded) if (member.id === botId) {
|
|
3598
|
+
const isPersonal = (normalizeOptionalLowercaseString(ctx.activity?.conversation?.conversationType) ?? "personal") === "personal";
|
|
3599
|
+
if (isPersonal && msteamsCfg?.welcomeCard !== false) {
|
|
3600
|
+
const card = buildWelcomeCard({
|
|
3601
|
+
botName: ctx.activity?.recipient?.name ?? void 0,
|
|
3602
|
+
promptStarters: msteamsCfg?.promptStarters
|
|
3603
|
+
});
|
|
3604
|
+
try {
|
|
3605
|
+
await ctx.sendActivity({
|
|
3606
|
+
type: "message",
|
|
3607
|
+
attachments: [{
|
|
3608
|
+
contentType: "application/vnd.microsoft.card.adaptive",
|
|
3609
|
+
content: card
|
|
3610
|
+
}]
|
|
3611
|
+
});
|
|
3612
|
+
deps.log.info("sent welcome card");
|
|
3613
|
+
} catch (err) {
|
|
3614
|
+
deps.log.debug?.("failed to send welcome card", { error: formatUnknownError(err) });
|
|
3615
|
+
}
|
|
3616
|
+
} else if (!isPersonal && msteamsCfg?.groupWelcomeCard === true) {
|
|
3617
|
+
const botName = ctx.activity?.recipient?.name ?? void 0;
|
|
3618
|
+
try {
|
|
3619
|
+
await ctx.sendActivity(buildGroupWelcomeText(botName));
|
|
3620
|
+
deps.log.info("sent group welcome message");
|
|
3621
|
+
} catch (err) {
|
|
3622
|
+
deps.log.debug?.("failed to send group welcome", { error: formatUnknownError(err) });
|
|
3623
|
+
}
|
|
3624
|
+
} else deps.log.debug?.("skipping welcome (disabled by config or conversation type)");
|
|
3625
|
+
} else deps.log.debug?.("member added", { member: member.id });
|
|
3626
|
+
await next();
|
|
3627
|
+
});
|
|
3628
|
+
handler.onReactionsAdded(async (context, next) => {
|
|
3629
|
+
try {
|
|
3630
|
+
await handleReaction(context, "added");
|
|
3631
|
+
} catch (err) {
|
|
3632
|
+
deps.runtime.error?.(`msteams reaction handler failed: ${String(err)}`);
|
|
3633
|
+
}
|
|
3634
|
+
await next();
|
|
3635
|
+
});
|
|
3636
|
+
handler.onReactionsRemoved(async (context, next) => {
|
|
3637
|
+
try {
|
|
3638
|
+
await handleReaction(context, "removed");
|
|
3639
|
+
} catch (err) {
|
|
3640
|
+
deps.runtime.error?.(`msteams reaction handler failed: ${String(err)}`);
|
|
3641
|
+
}
|
|
3642
|
+
await next();
|
|
3643
|
+
});
|
|
3644
|
+
return handler;
|
|
3645
|
+
}
|
|
3646
|
+
//#endregion
|
|
3647
|
+
//#region extensions/msteams/src/sso-token-store.ts
|
|
3648
|
+
/**
|
|
3649
|
+
* File-backed store for Bot Framework OAuth SSO tokens.
|
|
3650
|
+
*
|
|
3651
|
+
* Tokens are keyed by (connectionName, userId). `userId` should be the
|
|
3652
|
+
* stable AAD object ID (`activity.from.aadObjectId`) when available,
|
|
3653
|
+
* falling back to the Bot Framework `activity.from.id`.
|
|
3654
|
+
*
|
|
3655
|
+
* The store is intentionally minimal: it persists the exchanged user
|
|
3656
|
+
* token plus its expiration so consumers (for example tool handlers
|
|
3657
|
+
* that call Microsoft Graph with delegated permissions) can fetch a
|
|
3658
|
+
* valid token without reaching back into Bot Framework every turn.
|
|
3659
|
+
*/
|
|
3660
|
+
const STORE_FILENAME = "msteams-sso-tokens.json";
|
|
3661
|
+
const STORE_KEY_VERSION_PREFIX = "v2:";
|
|
3662
|
+
function makeKey(connectionName, userId) {
|
|
3663
|
+
return `${STORE_KEY_VERSION_PREFIX}${Buffer.from(JSON.stringify([connectionName, userId]), "utf8").toString("base64url")}`;
|
|
3664
|
+
}
|
|
3665
|
+
function normalizeStoredToken(value) {
|
|
3666
|
+
if (!value || typeof value !== "object") return null;
|
|
3667
|
+
const token = value;
|
|
3668
|
+
if (typeof token.connectionName !== "string" || !token.connectionName || typeof token.userId !== "string" || !token.userId || typeof token.token !== "string" || !token.token || typeof token.updatedAt !== "string" || !token.updatedAt) return null;
|
|
3669
|
+
return {
|
|
3670
|
+
connectionName: token.connectionName,
|
|
3671
|
+
userId: token.userId,
|
|
3672
|
+
token: token.token,
|
|
3673
|
+
...typeof token.expiresAt === "string" ? { expiresAt: token.expiresAt } : {},
|
|
3674
|
+
updatedAt: token.updatedAt
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
function isSsoStoreData(value) {
|
|
3678
|
+
if (!value || typeof value !== "object") return false;
|
|
3679
|
+
const obj = value;
|
|
3680
|
+
return obj.version === 1 && typeof obj.tokens === "object" && obj.tokens !== null;
|
|
3681
|
+
}
|
|
3682
|
+
function createMSTeamsSsoTokenStoreFs(params) {
|
|
3683
|
+
const filePath = resolveMSTeamsStorePath({
|
|
3684
|
+
filename: STORE_FILENAME,
|
|
3685
|
+
env: params?.env,
|
|
3686
|
+
homedir: params?.homedir,
|
|
3687
|
+
stateDir: params?.stateDir,
|
|
3688
|
+
storePath: params?.storePath
|
|
3689
|
+
});
|
|
3690
|
+
const empty = {
|
|
3691
|
+
version: 1,
|
|
3692
|
+
tokens: {}
|
|
3693
|
+
};
|
|
3694
|
+
const readStore = async () => {
|
|
3695
|
+
const { value } = await readJsonFile(filePath, empty);
|
|
3696
|
+
if (!isSsoStoreData(value)) return {
|
|
3697
|
+
version: 1,
|
|
3698
|
+
tokens: {}
|
|
3699
|
+
};
|
|
3700
|
+
const tokens = {};
|
|
3701
|
+
for (const stored of Object.values(value.tokens)) {
|
|
3702
|
+
const normalized = normalizeStoredToken(stored);
|
|
3703
|
+
if (!normalized) continue;
|
|
3704
|
+
tokens[makeKey(normalized.connectionName, normalized.userId)] = normalized;
|
|
3705
|
+
}
|
|
3706
|
+
return {
|
|
3707
|
+
version: 1,
|
|
3708
|
+
tokens
|
|
3709
|
+
};
|
|
3710
|
+
};
|
|
3711
|
+
return {
|
|
3712
|
+
async get({ connectionName, userId }) {
|
|
3713
|
+
return (await readStore()).tokens[makeKey(connectionName, userId)] ?? null;
|
|
3714
|
+
},
|
|
3715
|
+
async save(token) {
|
|
3716
|
+
await withFileLock(filePath, empty, async () => {
|
|
3717
|
+
const store = await readStore();
|
|
3718
|
+
const key = makeKey(token.connectionName, token.userId);
|
|
3719
|
+
store.tokens[key] = { ...token };
|
|
3720
|
+
await writeJsonFile(filePath, store);
|
|
3721
|
+
});
|
|
3722
|
+
},
|
|
3723
|
+
async remove({ connectionName, userId }) {
|
|
3724
|
+
let removed = false;
|
|
3725
|
+
await withFileLock(filePath, empty, async () => {
|
|
3726
|
+
const store = await readStore();
|
|
3727
|
+
const key = makeKey(connectionName, userId);
|
|
3728
|
+
if (store.tokens[key]) {
|
|
3729
|
+
delete store.tokens[key];
|
|
3730
|
+
removed = true;
|
|
3731
|
+
await writeJsonFile(filePath, store);
|
|
3732
|
+
}
|
|
3733
|
+
});
|
|
3734
|
+
return removed;
|
|
3735
|
+
}
|
|
3736
|
+
};
|
|
3737
|
+
}
|
|
3738
|
+
//#endregion
|
|
3739
|
+
//#region extensions/msteams/src/webhook-timeouts.ts
|
|
3740
|
+
const MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS = 3e4;
|
|
3741
|
+
const MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS = 3e4;
|
|
3742
|
+
const MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS = 15e3;
|
|
3743
|
+
function applyMSTeamsWebhookTimeouts(httpServer, opts) {
|
|
3744
|
+
const inactivityTimeoutMs = opts?.inactivityTimeoutMs ?? MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS;
|
|
3745
|
+
const requestTimeoutMs = opts?.requestTimeoutMs ?? MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS;
|
|
3746
|
+
const headersTimeoutMs = Math.min(opts?.headersTimeoutMs ?? MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS, requestTimeoutMs);
|
|
3747
|
+
httpServer.setTimeout(inactivityTimeoutMs);
|
|
3748
|
+
httpServer.requestTimeout = requestTimeoutMs;
|
|
3749
|
+
httpServer.headersTimeout = headersTimeoutMs;
|
|
3750
|
+
}
|
|
3751
|
+
//#endregion
|
|
3752
|
+
//#region extensions/msteams/src/monitor.ts
|
|
3753
|
+
const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
|
3754
|
+
async function monitorMSTeamsProvider(opts) {
|
|
3755
|
+
const core = getMSTeamsRuntime();
|
|
3756
|
+
const log = core.logging.getChildLogger({ name: "msteams" });
|
|
3757
|
+
let cfg = opts.cfg;
|
|
3758
|
+
let msteamsCfg = cfg.channels?.msteams;
|
|
3759
|
+
if (!msteamsCfg?.enabled) {
|
|
3760
|
+
log.debug?.("msteams provider disabled");
|
|
3761
|
+
return {
|
|
3762
|
+
app: null,
|
|
3763
|
+
shutdown: async () => {}
|
|
3764
|
+
};
|
|
3765
|
+
}
|
|
3766
|
+
const creds = resolveMSTeamsCredentials(msteamsCfg);
|
|
3767
|
+
if (!creds) {
|
|
3768
|
+
log.error("msteams credentials not configured");
|
|
3769
|
+
return {
|
|
3770
|
+
app: null,
|
|
3771
|
+
shutdown: async () => {}
|
|
3772
|
+
};
|
|
3773
|
+
}
|
|
3774
|
+
const appId = creds.appId;
|
|
3775
|
+
const runtime = opts.runtime ?? {
|
|
3776
|
+
log: console.log,
|
|
3777
|
+
error: console.error,
|
|
3778
|
+
exit: (code) => {
|
|
3779
|
+
throw new Error(`exit ${code}`);
|
|
3780
|
+
}
|
|
3781
|
+
};
|
|
3782
|
+
let allowFrom = msteamsCfg.allowFrom;
|
|
3783
|
+
let groupAllowFrom = msteamsCfg.groupAllowFrom;
|
|
3784
|
+
let teamsConfig = msteamsCfg.teams;
|
|
3785
|
+
const cleanAllowEntry = (entry) => entry.replace(/^(msteams|teams):/i, "").replace(/^user:/i, "").trim();
|
|
3786
|
+
const resolveAllowlistUsers = async (label, entries) => {
|
|
3787
|
+
if (entries.length === 0) return {
|
|
3788
|
+
additions: [],
|
|
3789
|
+
unresolved: []
|
|
3790
|
+
};
|
|
3791
|
+
const resolved = await resolveMSTeamsUserAllowlist({
|
|
3792
|
+
cfg,
|
|
3793
|
+
entries
|
|
3794
|
+
});
|
|
3795
|
+
const additions = [];
|
|
3796
|
+
const unresolved = [];
|
|
3797
|
+
for (const entry of resolved) if (entry.resolved && entry.id) additions.push(entry.id);
|
|
3798
|
+
else unresolved.push(entry.input);
|
|
3799
|
+
summarizeMapping(label, resolved.filter((entry) => entry.resolved && entry.id).map((entry) => `${entry.input}→${entry.id}`), unresolved, runtime);
|
|
3800
|
+
return {
|
|
3801
|
+
additions,
|
|
3802
|
+
unresolved
|
|
3803
|
+
};
|
|
3804
|
+
};
|
|
3805
|
+
try {
|
|
3806
|
+
const allowEntries = allowFrom?.map((entry) => cleanAllowEntry(entry)).filter((entry) => entry && entry !== "*") ?? [];
|
|
3807
|
+
if (allowEntries.length > 0) {
|
|
3808
|
+
const { additions } = await resolveAllowlistUsers("msteams users", allowEntries);
|
|
3809
|
+
allowFrom = mergeAllowlist({
|
|
3810
|
+
existing: allowFrom,
|
|
3811
|
+
additions
|
|
3812
|
+
});
|
|
3813
|
+
}
|
|
3814
|
+
if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) {
|
|
3815
|
+
const groupEntries = groupAllowFrom.map((entry) => cleanAllowEntry(entry)).filter((entry) => entry && entry !== "*");
|
|
3816
|
+
if (groupEntries.length > 0) {
|
|
3817
|
+
const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries);
|
|
3818
|
+
groupAllowFrom = mergeAllowlist({
|
|
3819
|
+
existing: groupAllowFrom,
|
|
3820
|
+
additions
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
if (teamsConfig && Object.keys(teamsConfig).length > 0) {
|
|
3825
|
+
const entries = [];
|
|
3826
|
+
for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) {
|
|
3827
|
+
if (teamKey === "*") continue;
|
|
3828
|
+
const channels = teamCfg?.channels ?? {};
|
|
3829
|
+
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
|
|
3830
|
+
if (channelKeys.length === 0) {
|
|
3831
|
+
entries.push({
|
|
3832
|
+
input: teamKey,
|
|
3833
|
+
teamKey
|
|
3834
|
+
});
|
|
3835
|
+
continue;
|
|
3836
|
+
}
|
|
3837
|
+
for (const channelKey of channelKeys) entries.push({
|
|
3838
|
+
input: `${teamKey}/${channelKey}`,
|
|
3839
|
+
teamKey,
|
|
3840
|
+
channelKey
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
if (entries.length > 0) {
|
|
3844
|
+
const resolved = await resolveMSTeamsChannelAllowlist({
|
|
3845
|
+
cfg,
|
|
3846
|
+
entries: entries.map((entry) => entry.input)
|
|
3847
|
+
});
|
|
3848
|
+
const mapping = [];
|
|
3849
|
+
const unresolved = [];
|
|
3850
|
+
const nextTeams = { ...teamsConfig };
|
|
3851
|
+
resolved.forEach((entry, idx) => {
|
|
3852
|
+
const source = entries[idx];
|
|
3853
|
+
if (!source) return;
|
|
3854
|
+
const sourceTeam = teamsConfig?.[source.teamKey] ?? {};
|
|
3855
|
+
if (!entry.resolved || !entry.teamId) {
|
|
3856
|
+
unresolved.push(entry.input);
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
mapping.push(entry.channelId ? `${entry.input}→${entry.teamId}/${entry.channelId}` : `${entry.input}→${entry.teamId}`);
|
|
3860
|
+
const existing = nextTeams[entry.teamId] ?? {};
|
|
3861
|
+
const mergedChannels = {
|
|
3862
|
+
...sourceTeam.channels,
|
|
3863
|
+
...existing.channels
|
|
3864
|
+
};
|
|
3865
|
+
const mergedTeam = {
|
|
3866
|
+
...sourceTeam,
|
|
3867
|
+
...existing,
|
|
3868
|
+
channels: mergedChannels
|
|
3869
|
+
};
|
|
3870
|
+
nextTeams[entry.teamId] = mergedTeam;
|
|
3871
|
+
if (source.channelKey && entry.channelId) {
|
|
3872
|
+
const sourceChannel = sourceTeam.channels?.[source.channelKey];
|
|
3873
|
+
if (sourceChannel) nextTeams[entry.teamId] = {
|
|
3874
|
+
...mergedTeam,
|
|
3875
|
+
channels: {
|
|
3876
|
+
...mergedChannels,
|
|
3877
|
+
[entry.channelId]: {
|
|
3878
|
+
...sourceChannel,
|
|
3879
|
+
...mergedChannels?.[entry.channelId]
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
});
|
|
3885
|
+
teamsConfig = nextTeams;
|
|
3886
|
+
summarizeMapping("msteams channels", mapping, unresolved, runtime);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
} catch (err) {
|
|
3890
|
+
runtime.log?.(`msteams resolve failed; using config entries. ${formatUnknownError(err)}`);
|
|
3891
|
+
}
|
|
3892
|
+
msteamsCfg = {
|
|
3893
|
+
...msteamsCfg,
|
|
3894
|
+
allowFrom,
|
|
3895
|
+
groupAllowFrom,
|
|
3896
|
+
teams: teamsConfig
|
|
3897
|
+
};
|
|
3898
|
+
cfg = {
|
|
3899
|
+
...cfg,
|
|
3900
|
+
channels: {
|
|
3901
|
+
...cfg.channels,
|
|
3902
|
+
msteams: msteamsCfg
|
|
3903
|
+
}
|
|
3904
|
+
};
|
|
3905
|
+
const port = msteamsCfg.webhook?.port ?? 3978;
|
|
3906
|
+
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
|
|
3907
|
+
const MB = 1024 * 1024;
|
|
3908
|
+
const agentDefaults = cfg.agents?.defaults;
|
|
3909
|
+
const mediaMaxBytes = typeof agentDefaults?.mediaMaxMb === "number" && agentDefaults.mediaMaxMb > 0 ? Math.floor(agentDefaults.mediaMaxMb * MB) : 8 * MB;
|
|
3910
|
+
const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs();
|
|
3911
|
+
const pollStore = opts.pollStore ?? createMSTeamsPollStoreFs();
|
|
3912
|
+
log.info(`starting provider (port ${port})`);
|
|
3913
|
+
const express = await import("express");
|
|
3914
|
+
const { sdk, app } = await loadMSTeamsSdkWithAuth(creds);
|
|
3915
|
+
const tokenProvider = createMSTeamsTokenProvider(app);
|
|
3916
|
+
const adapter = createMSTeamsAdapter(app, sdk);
|
|
3917
|
+
let ssoDeps;
|
|
3918
|
+
if (msteamsCfg.sso?.enabled && msteamsCfg.sso.connectionName) {
|
|
3919
|
+
ssoDeps = {
|
|
3920
|
+
tokenProvider,
|
|
3921
|
+
tokenStore: createMSTeamsSsoTokenStoreFs(),
|
|
3922
|
+
connectionName: msteamsCfg.sso.connectionName
|
|
3923
|
+
};
|
|
3924
|
+
log.debug?.("msteams sso enabled", { connectionName: msteamsCfg.sso.connectionName });
|
|
3925
|
+
}
|
|
3926
|
+
const handler = buildActivityHandler();
|
|
3927
|
+
registerMSTeamsHandlers(handler, {
|
|
3928
|
+
cfg,
|
|
3929
|
+
runtime,
|
|
3930
|
+
appId,
|
|
3931
|
+
adapter,
|
|
3932
|
+
tokenProvider,
|
|
3933
|
+
textLimit,
|
|
3934
|
+
mediaMaxBytes,
|
|
3935
|
+
conversationStore,
|
|
3936
|
+
pollStore,
|
|
3937
|
+
log,
|
|
3938
|
+
sso: ssoDeps
|
|
3939
|
+
});
|
|
3940
|
+
const expressApp = express.default();
|
|
3941
|
+
expressApp.use((req, res, next) => {
|
|
3942
|
+
const auth = req.headers.authorization;
|
|
3943
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
3944
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
next();
|
|
3948
|
+
});
|
|
3949
|
+
const jwtValidator = await createBotFrameworkJwtValidator(creds);
|
|
3950
|
+
expressApp.use((req, res, next) => {
|
|
3951
|
+
const authHeader = req.headers.authorization;
|
|
3952
|
+
jwtValidator.validate(authHeader).then((valid) => {
|
|
3953
|
+
if (!valid) {
|
|
3954
|
+
log.debug?.("JWT validation failed");
|
|
3955
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
next();
|
|
3959
|
+
}).catch((err) => {
|
|
3960
|
+
log.debug?.(`JWT validation error: ${formatUnknownError(err)}`);
|
|
3961
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
3962
|
+
});
|
|
3963
|
+
});
|
|
3964
|
+
expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES }));
|
|
3965
|
+
expressApp.use((err, _req, res, next) => {
|
|
3966
|
+
if (err && typeof err === "object" && "status" in err && err.status === 413) {
|
|
3967
|
+
res.status(413).json({ error: "Payload too large" });
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
next(err);
|
|
3971
|
+
});
|
|
3972
|
+
const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";
|
|
3973
|
+
const messageHandler = (req, res) => {
|
|
3974
|
+
adapter.process(req, res, (context) => handler.run(context)).catch((err) => {
|
|
3975
|
+
log.error("msteams webhook failed", { error: formatUnknownError(err) });
|
|
3976
|
+
});
|
|
3977
|
+
};
|
|
3978
|
+
expressApp.post(configuredPath, messageHandler);
|
|
3979
|
+
if (configuredPath !== "/api/messages") expressApp.post("/api/messages", messageHandler);
|
|
3980
|
+
log.debug?.("listening on paths", {
|
|
3981
|
+
primary: configuredPath,
|
|
3982
|
+
fallback: "/api/messages"
|
|
3983
|
+
});
|
|
3984
|
+
const httpServer = expressApp.listen(port);
|
|
3985
|
+
await new Promise((resolve, reject) => {
|
|
3986
|
+
const onListening = () => {
|
|
3987
|
+
httpServer.off("error", onError);
|
|
3988
|
+
log.info(`msteams provider started on port ${port}`);
|
|
3989
|
+
resolve();
|
|
3990
|
+
};
|
|
3991
|
+
const onError = (err) => {
|
|
3992
|
+
httpServer.off("listening", onListening);
|
|
3993
|
+
log.error("msteams server error", { error: formatUnknownError(err) });
|
|
3994
|
+
reject(err);
|
|
3995
|
+
};
|
|
3996
|
+
httpServer.once("listening", onListening);
|
|
3997
|
+
httpServer.once("error", onError);
|
|
3998
|
+
});
|
|
3999
|
+
applyMSTeamsWebhookTimeouts(httpServer);
|
|
4000
|
+
httpServer.on("error", (err) => {
|
|
4001
|
+
log.error("msteams server error", { error: formatUnknownError(err) });
|
|
4002
|
+
});
|
|
4003
|
+
const shutdown = async () => {
|
|
4004
|
+
log.info("shutting down msteams provider");
|
|
4005
|
+
return new Promise((resolve) => {
|
|
4006
|
+
httpServer.close((err) => {
|
|
4007
|
+
if (err) log.debug?.("msteams server close error", { error: formatUnknownError(err) });
|
|
4008
|
+
resolve();
|
|
4009
|
+
});
|
|
4010
|
+
});
|
|
4011
|
+
};
|
|
4012
|
+
await keepHttpServerTaskAlive({
|
|
4013
|
+
server: httpServer,
|
|
4014
|
+
abortSignal: opts.abortSignal,
|
|
4015
|
+
onAbort: shutdown
|
|
4016
|
+
});
|
|
4017
|
+
return {
|
|
4018
|
+
app: expressApp,
|
|
4019
|
+
shutdown
|
|
4020
|
+
};
|
|
4021
|
+
}
|
|
4022
|
+
/**
|
|
4023
|
+
* Build a minimal ActivityHandler-compatible object that supports
|
|
4024
|
+
* onMessage / onMembersAdded registration and a run() method.
|
|
4025
|
+
*/
|
|
4026
|
+
function buildActivityHandler() {
|
|
4027
|
+
const messageHandlers = [];
|
|
4028
|
+
const membersAddedHandlers = [];
|
|
4029
|
+
const reactionsAddedHandlers = [];
|
|
4030
|
+
const reactionsRemovedHandlers = [];
|
|
4031
|
+
const handler = {
|
|
4032
|
+
onMessage(cb) {
|
|
4033
|
+
messageHandlers.push(cb);
|
|
4034
|
+
return handler;
|
|
4035
|
+
},
|
|
4036
|
+
onMembersAdded(cb) {
|
|
4037
|
+
membersAddedHandlers.push(cb);
|
|
4038
|
+
return handler;
|
|
4039
|
+
},
|
|
4040
|
+
onReactionsAdded(cb) {
|
|
4041
|
+
reactionsAddedHandlers.push(cb);
|
|
4042
|
+
return handler;
|
|
4043
|
+
},
|
|
4044
|
+
onReactionsRemoved(cb) {
|
|
4045
|
+
reactionsRemovedHandlers.push(cb);
|
|
4046
|
+
return handler;
|
|
4047
|
+
},
|
|
4048
|
+
async run(context) {
|
|
4049
|
+
const ctx = context;
|
|
4050
|
+
const activityType = ctx?.activity?.type;
|
|
4051
|
+
const noop = async () => {};
|
|
4052
|
+
if (activityType === "message") for (const h of messageHandlers) await h(context, noop);
|
|
4053
|
+
else if (activityType === "conversationUpdate") for (const h of membersAddedHandlers) await h(context, noop);
|
|
4054
|
+
else if (activityType === "messageReaction") {
|
|
4055
|
+
const activity = ctx?.activity;
|
|
4056
|
+
if (activity?.reactionsAdded?.length) for (const h of reactionsAddedHandlers) await h(context, noop);
|
|
4057
|
+
if (activity?.reactionsRemoved?.length) for (const h of reactionsRemovedHandlers) await h(context, noop);
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
};
|
|
4061
|
+
return handler;
|
|
4062
|
+
}
|
|
4063
|
+
//#endregion
|
|
4064
|
+
export { monitorMSTeamsProvider };
|