@hahnfeld/teams-adapter 1.2.0 → 1.3.0
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/README.md +81 -28
- package/dist/adapter.d.ts +16 -25
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +154 -233
- package/dist/adapter.js.map +1 -1
- package/dist/formatting.d.ts +0 -57
- package/dist/formatting.d.ts.map +1 -1
- package/dist/formatting.js +0 -183
- package/dist/formatting.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/message-composer.d.ts +94 -0
- package/dist/message-composer.d.ts.map +1 -0
- package/dist/message-composer.js +331 -0
- package/dist/message-composer.js.map +1 -0
- package/dist/rate-limiter.d.ts +17 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +201 -0
- package/dist/rate-limiter.js.map +1 -0
- package/package.json +2 -2
- package/dist/draft-manager.d.ts +0 -69
- package/dist/draft-manager.d.ts.map +0 -1
- package/dist/draft-manager.js +0 -333
- package/dist/draft-manager.js.map +0 -1
- package/dist/renderer.d.ts +0 -49
- package/dist/renderer.d.ts.map +0 -1
- package/dist/renderer.js +0 -55
- package/dist/renderer.js.map +0 -1
- package/dist/task-modules.d.ts +0 -34
- package/dist/task-modules.d.ts.map +0 -1
- package/dist/task-modules.js +0 -136
- package/dist/task-modules.js.map +0 -1
package/dist/adapter.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
import { App } from "@microsoft/teams.apps";
|
|
2
2
|
import { BotBuilderPlugin } from "@microsoft/teams.botbuilder";
|
|
3
|
-
import {
|
|
3
|
+
import { MemoryStorage } from "@microsoft/agents-hosting";
|
|
4
4
|
import { CloudAdapter, ConfigurationBotFrameworkAuthentication, } from "botbuilder";
|
|
5
5
|
import { PasswordServiceClientCredentialFactory } from "botframework-connector";
|
|
6
|
-
import { log, MessagingAdapter,
|
|
7
|
-
import { TeamsRenderer } from "./renderer.js";
|
|
6
|
+
import { log, MessagingAdapter, BaseRenderer } from "@openacp/plugin-sdk";
|
|
8
7
|
import { DEFAULT_BOT_PORT } from "./types.js";
|
|
9
|
-
import {
|
|
8
|
+
import { SessionMessageManager } from "./message-composer.js";
|
|
9
|
+
import { ConversationRateLimiter } from "./rate-limiter.js";
|
|
10
10
|
import { PermissionHandler } from "./permissions.js";
|
|
11
11
|
import { handleCommand, setupCardActionCallbacks } from "./commands/index.js";
|
|
12
12
|
import { spawnAssistant } from "./assistant.js";
|
|
13
|
-
import { downloadTeamsFile, isAttachmentTooLarge,
|
|
13
|
+
import { downloadTeamsFile, isAttachmentTooLarge, uploadFileViaGraph } from "./media.js";
|
|
14
14
|
import { GraphFileClient } from "./graph.js";
|
|
15
15
|
import { ConversationStore } from "./conversation-store.js";
|
|
16
16
|
import { sendText, sendCard, sendActivity } from "./send-utils.js";
|
|
17
|
-
import {
|
|
17
|
+
import { formatTokens, formatToolSummary, formatPlan } from "./formatting.js";
|
|
18
18
|
/** Max retry attempts for transient Teams API failures */
|
|
19
19
|
const MAX_RETRIES = 3;
|
|
20
20
|
/** Base delay (ms) for exponential backoff */
|
|
21
21
|
const BASE_RETRY_DELAY = 1000;
|
|
22
22
|
export class TeamsAdapter extends MessagingAdapter {
|
|
23
23
|
name = "teams";
|
|
24
|
-
renderer = new
|
|
24
|
+
renderer = new BaseRenderer();
|
|
25
25
|
capabilities = {
|
|
26
26
|
streaming: true,
|
|
27
27
|
richFormatting: true,
|
|
@@ -33,8 +33,8 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
33
33
|
core;
|
|
34
34
|
app;
|
|
35
35
|
teamsConfig;
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
rateLimiter;
|
|
37
|
+
composer;
|
|
38
38
|
permissionHandler;
|
|
39
39
|
notificationChannelId;
|
|
40
40
|
assistantSession = null;
|
|
@@ -48,16 +48,6 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
48
48
|
*/
|
|
49
49
|
_sessionContexts = new Map();
|
|
50
50
|
_sessionOutputModes = new Map();
|
|
51
|
-
/**
|
|
52
|
-
* Per-session serial dispatch queues — matches Telegram's _dispatchQueues pattern.
|
|
53
|
-
* SessionBridge fires sendMessage() as fire-and-forget, so multiple events can arrive
|
|
54
|
-
* concurrently. Without serialization, fast handlers overtake slow ones, causing
|
|
55
|
-
* out-of-order delivery. This queue ensures events are processed in arrival order.
|
|
56
|
-
*
|
|
57
|
-
* Entries are replaced with Promise.resolve() once their chain settles, preventing
|
|
58
|
-
* unbounded closure growth for long-lived sessions.
|
|
59
|
-
*/
|
|
60
|
-
_dispatchQueues = new Map();
|
|
61
51
|
/** Track processed activity IDs to handle Teams 15-second retry deduplication */
|
|
62
52
|
_processedActivities = new Map();
|
|
63
53
|
_processedCleanupTimer;
|
|
@@ -74,8 +64,8 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
74
64
|
{ ...config, maxMessageLength: 25000, enabled: config.enabled ?? true });
|
|
75
65
|
this.core = core;
|
|
76
66
|
this.teamsConfig = config;
|
|
77
|
-
this.
|
|
78
|
-
this.
|
|
67
|
+
this.rateLimiter = new ConversationRateLimiter();
|
|
68
|
+
this.composer = new SessionMessageManager(this.rateLimiter, () => this.acquireBotToken());
|
|
79
69
|
this.fileService = core.fileService;
|
|
80
70
|
// Persistent conversation reference store for proactive messaging
|
|
81
71
|
const storageDir = core.configManager.instanceRoot ?? process.cwd();
|
|
@@ -169,9 +159,8 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
169
159
|
}
|
|
170
160
|
this._sessionContexts.clear();
|
|
171
161
|
this._sessionOutputModes.clear();
|
|
172
|
-
this._dispatchQueues.clear();
|
|
173
162
|
this._processedActivities.clear();
|
|
174
|
-
this.
|
|
163
|
+
this.rateLimiter.destroy();
|
|
175
164
|
this.conversationStore.destroy();
|
|
176
165
|
this.permissionHandler.dispose();
|
|
177
166
|
await this.app.stop();
|
|
@@ -348,16 +337,7 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
348
337
|
return;
|
|
349
338
|
}
|
|
350
339
|
if (sessionId !== "unknown") {
|
|
351
|
-
|
|
352
|
-
// previous prompt hung (e.g., output token exhaustion).
|
|
353
|
-
const pendingDispatch = this._dispatchQueues.get(sessionId);
|
|
354
|
-
if (pendingDispatch) {
|
|
355
|
-
await Promise.race([
|
|
356
|
-
pendingDispatch,
|
|
357
|
-
new Promise(resolve => setTimeout(resolve, 5000)),
|
|
358
|
-
]);
|
|
359
|
-
}
|
|
360
|
-
this.draftManager.cleanup(sessionId);
|
|
340
|
+
this.composer.cleanup(sessionId);
|
|
361
341
|
}
|
|
362
342
|
// Show typing indicator while the agent processes the message
|
|
363
343
|
this.sendTyping(context);
|
|
@@ -967,13 +947,8 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
967
947
|
/**
|
|
968
948
|
* Primary outbound dispatch — routes agent messages to Teams.
|
|
969
949
|
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
* preventing fast handlers from overtaking slower ones (matches Telegram pattern).
|
|
973
|
-
*
|
|
974
|
-
* Context is NOT deleted after dispatch — it persists from the inbound message handler
|
|
975
|
-
* and is available for the entire session lifetime, avoiding the race condition where
|
|
976
|
-
* async handlers lose their context mid-execution.
|
|
950
|
+
* Routes agent messages to Teams. The rate limiter handles per-conversation
|
|
951
|
+
* serialization and throttling.
|
|
977
952
|
*/
|
|
978
953
|
async sendMessage(sessionId, content) {
|
|
979
954
|
// Buffer messages during assistant initialization instead of dropping them
|
|
@@ -1030,184 +1005,134 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1030
1005
|
}
|
|
1031
1006
|
return;
|
|
1032
1007
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
try {
|
|
1039
|
-
await super.sendMessage(sessionId, content);
|
|
1040
|
-
}
|
|
1041
|
-
catch (err) {
|
|
1042
|
-
log.warn({ err, sessionId, type: content.type }, "[TeamsAdapter] Dispatch error");
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1045
|
-
// Set immediately — before any await — so the next concurrent caller sees this entry
|
|
1046
|
-
this._dispatchQueues.set(sessionId, next);
|
|
1047
|
-
await next;
|
|
1048
|
-
// Replace settled chain with a fresh resolved promise to prevent unbounded
|
|
1049
|
-
// closure growth for long-lived sessions. Only replace if no new work was
|
|
1050
|
-
// chained while we were awaiting (i.e., the entry still points to `next`).
|
|
1051
|
-
if (this._dispatchQueues.get(sessionId) === next) {
|
|
1052
|
-
this._dispatchQueues.set(sessionId, Promise.resolve());
|
|
1008
|
+
try {
|
|
1009
|
+
await super.sendMessage(sessionId, content);
|
|
1010
|
+
}
|
|
1011
|
+
catch (err) {
|
|
1012
|
+
log.warn({ err, sessionId, type: content.type }, "[TeamsAdapter] Dispatch error");
|
|
1053
1013
|
}
|
|
1054
1014
|
}
|
|
1055
1015
|
// ─── Handler overrides ───────────────────────────────────────────────────
|
|
1056
|
-
async handleThought(
|
|
1057
|
-
|
|
1016
|
+
async handleThought(sessionId, content, _verbosity) {
|
|
1017
|
+
const ctx = this._sessionContexts.get(sessionId);
|
|
1018
|
+
if (!ctx)
|
|
1019
|
+
return;
|
|
1020
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1021
|
+
this.ensureSessionTitle(sessionId, msg);
|
|
1022
|
+
const summary = content.text?.split("\n")[0]?.slice(0, 100) || "Thinking...";
|
|
1023
|
+
msg.setHeader(`💭 ${summary}`);
|
|
1058
1024
|
}
|
|
1059
1025
|
async handleText(sessionId, content) {
|
|
1060
1026
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1061
1027
|
if (!ctx)
|
|
1062
1028
|
return;
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
}
|
|
1068
|
-
const draft = this.draftManager.getOrCreate(sessionId, context);
|
|
1029
|
+
if (!this.composer.has(sessionId))
|
|
1030
|
+
this.sendTyping(ctx.context);
|
|
1031
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1032
|
+
this.ensureSessionTitle(sessionId, msg);
|
|
1069
1033
|
if (content.text)
|
|
1070
|
-
|
|
1034
|
+
msg.appendBody(content.text);
|
|
1071
1035
|
}
|
|
1072
|
-
async handleToolCall(sessionId, content,
|
|
1036
|
+
async handleToolCall(sessionId, content, _verbosity) {
|
|
1073
1037
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1074
1038
|
if (!ctx)
|
|
1075
1039
|
return;
|
|
1076
|
-
const
|
|
1077
|
-
this.
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
id: meta.id ?? "",
|
|
1083
|
-
name: meta.name ?? content.text ?? "Tool",
|
|
1084
|
-
kind: meta.kind,
|
|
1085
|
-
status: meta.status,
|
|
1086
|
-
rawInput: meta.rawInput,
|
|
1087
|
-
content: meta.content,
|
|
1088
|
-
displaySummary: meta.displaySummary,
|
|
1089
|
-
displayTitle: meta.displayTitle,
|
|
1090
|
-
displayKind: meta.displayKind,
|
|
1091
|
-
viewerLinks: meta.viewerLinks,
|
|
1092
|
-
viewerFilePath: meta.viewerFilePath,
|
|
1093
|
-
}, verbosity);
|
|
1094
|
-
const card = { type: "AdaptiveCard", version: "1.2", ...cardData };
|
|
1095
|
-
// Build citation entities for file references (hover popup with source info)
|
|
1096
|
-
const citationSources = [];
|
|
1097
|
-
const filePath = meta.viewerFilePath;
|
|
1098
|
-
const links = meta.viewerLinks;
|
|
1099
|
-
if (filePath && links?.file) {
|
|
1100
|
-
citationSources.push({ name: filePath.split("/").pop() || filePath, url: links.file, abstract: `Source: ${filePath}` });
|
|
1101
|
-
}
|
|
1102
|
-
if (filePath && links?.diff) {
|
|
1103
|
-
citationSources.push({ name: `${filePath.split("/").pop() || filePath} (diff)`, url: links.diff, abstract: `Changes to ${filePath}` });
|
|
1104
|
-
}
|
|
1105
|
-
const citationEntities = buildCitationEntities(citationSources);
|
|
1106
|
-
// Citations require [N] markers in the text field — Teams ignores citation entities
|
|
1107
|
-
// on card-only activities with no text anchor. Add a text field with markers.
|
|
1108
|
-
const citationText = citationSources.length > 0
|
|
1109
|
-
? citationSources.map((s, i) => `[${i + 1}]`).join(" ")
|
|
1110
|
-
: undefined;
|
|
1111
|
-
await this.sendActivityWithRetry(context, {
|
|
1112
|
-
...(citationText ? { text: citationText } : {}),
|
|
1113
|
-
attachments: [CardFactory.adaptiveCard(card)],
|
|
1114
|
-
...(citationEntities.length > 0 ? { entities: citationEntities } : {}),
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
catch (err) {
|
|
1118
|
-
log.error({ err, sessionId }, "[TeamsAdapter] handleToolCall: sendActivity failed");
|
|
1119
|
-
}
|
|
1040
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1041
|
+
this.ensureSessionTitle(sessionId, msg);
|
|
1042
|
+
const meta = (content.metadata ?? {});
|
|
1043
|
+
const toolName = meta.name || content.text || "Tool";
|
|
1044
|
+
const summary = formatToolSummary(toolName, meta.rawInput, meta.displaySummary);
|
|
1045
|
+
msg.setHeader(`🔧 ${summary}`);
|
|
1120
1046
|
}
|
|
1121
|
-
async handleToolUpdate(sessionId, content,
|
|
1122
|
-
// Only render tool updates in high verbosity mode (matches Telegram's tracker behavior)
|
|
1123
|
-
if (verbosity !== "high")
|
|
1124
|
-
return;
|
|
1047
|
+
async handleToolUpdate(sessionId, content, _verbosity) {
|
|
1125
1048
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1126
1049
|
if (!ctx)
|
|
1127
1050
|
return;
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
}
|
|
1051
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1052
|
+
const meta = (content.metadata ?? {});
|
|
1053
|
+
const toolName = meta.name || content.text || "";
|
|
1054
|
+
// Only update header if we have meaningful content — don't overwrite with empty
|
|
1055
|
+
if (!toolName && !meta.displaySummary)
|
|
1056
|
+
return;
|
|
1057
|
+
const summary = formatToolSummary(toolName || "Tool", meta.rawInput, meta.displaySummary);
|
|
1058
|
+
msg.setHeader(`🔧 ${summary}`);
|
|
1059
|
+
}
|
|
1060
|
+
/** Set the session title on the composer if not already set. */
|
|
1061
|
+
ensureSessionTitle(sessionId, msg) {
|
|
1062
|
+
const session = this.core.sessionManager.getSession(sessionId);
|
|
1063
|
+
const name = session?.name;
|
|
1064
|
+
if (name)
|
|
1065
|
+
msg.setTitle(name);
|
|
1136
1066
|
}
|
|
1067
|
+
/** Per-session plan send mutex to prevent TOCTOU race on first plan message. */
|
|
1068
|
+
_planSending = new Set();
|
|
1137
1069
|
async handlePlan(sessionId, content, _verbosity) {
|
|
1138
1070
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1071
|
+
const planEntries = content.metadata?.entries ?? [];
|
|
1139
1072
|
if (!ctx)
|
|
1140
1073
|
return;
|
|
1141
1074
|
const { context } = ctx;
|
|
1142
|
-
const
|
|
1143
|
-
const
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1075
|
+
const conversationId = context.activity.conversation?.id;
|
|
1076
|
+
const entries = planEntries;
|
|
1077
|
+
const text = formatPlan(entries, "high");
|
|
1078
|
+
const planRef = this.composer.getPlanRef(sessionId);
|
|
1079
|
+
if (planRef) {
|
|
1080
|
+
// Update existing plan message via rate limiter
|
|
1081
|
+
await this.rateLimiter.enqueue(conversationId, async () => {
|
|
1082
|
+
const token = await this.acquireBotToken();
|
|
1083
|
+
if (!token)
|
|
1084
|
+
return;
|
|
1085
|
+
const url = `${planRef.serviceUrl}/v3/conversations/${encodeURIComponent(planRef.conversationId)}/activities/${encodeURIComponent(planRef.activityId)}`;
|
|
1086
|
+
await fetch(url, {
|
|
1087
|
+
method: "PUT",
|
|
1088
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
1089
|
+
body: JSON.stringify({ type: "message", text: text.replace(/(?<!\n)\n(?!\n)/g, "\n\n"), textFormat: "markdown" }),
|
|
1090
|
+
});
|
|
1091
|
+
}, `plan:${sessionId}`);
|
|
1148
1092
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1093
|
+
else {
|
|
1094
|
+
// Prevent TOCTOU: if another plan event is already sending the first message, skip
|
|
1095
|
+
if (this._planSending.has(sessionId))
|
|
1096
|
+
return;
|
|
1097
|
+
this._planSending.add(sessionId);
|
|
1098
|
+
try {
|
|
1099
|
+
const result = await this.rateLimiter.enqueue(conversationId, async () => {
|
|
1100
|
+
return sendText(context, text);
|
|
1101
|
+
}, `plan:${sessionId}`);
|
|
1102
|
+
if (result?.id) {
|
|
1103
|
+
this.composer.setPlanRef(sessionId, {
|
|
1104
|
+
activityId: result.id,
|
|
1105
|
+
conversationId,
|
|
1106
|
+
serviceUrl: context.activity.serviceUrl,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
catch (err) {
|
|
1111
|
+
log.warn({ err, sessionId }, "[TeamsAdapter] handlePlan: send failed");
|
|
1112
|
+
}
|
|
1113
|
+
finally {
|
|
1114
|
+
this._planSending.delete(sessionId);
|
|
1115
|
+
}
|
|
1151
1116
|
}
|
|
1152
1117
|
}
|
|
1153
1118
|
async handleUsage(sessionId, content, _verbosity) {
|
|
1154
1119
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1155
1120
|
if (!ctx)
|
|
1156
1121
|
return;
|
|
1157
|
-
const { context, isAssistant } = ctx;
|
|
1158
|
-
// Append usage as a subtle footer on the draft before finalizing
|
|
1159
1122
|
const meta = content.metadata;
|
|
1160
1123
|
if (meta?.tokensUsed != null) {
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
const draft = this.draftManager.getDraft(sessionId);
|
|
1174
|
-
await this.draftManager.finalize(sessionId, context, isAssistant);
|
|
1175
|
-
const finalRef = draft?.getFinalRef();
|
|
1176
|
-
// In DMs, edit the last message to append "Task completed" to the usage footer.
|
|
1177
|
-
// In channels, send a separate notification to the notification channel.
|
|
1178
|
-
const convType = context.activity?.conversation?.conversationType;
|
|
1179
|
-
if (convType === "personal" && finalRef) {
|
|
1180
|
-
// Edit the last message's usage footer in-place
|
|
1181
|
-
void (async () => {
|
|
1182
|
-
try {
|
|
1183
|
-
const botToken = await this.acquireBotToken();
|
|
1184
|
-
if (!botToken)
|
|
1185
|
-
return;
|
|
1186
|
-
const updatedText = finalRef.text.replace(/(\n---\n\*.+)\*\s*$/, `$1 · Task completed*`);
|
|
1187
|
-
if (updatedText === finalRef.text)
|
|
1188
|
-
return;
|
|
1189
|
-
const url = `${finalRef.serviceUrl}/v3/conversations/${encodeURIComponent(finalRef.conversationId)}/activities/${encodeURIComponent(finalRef.activityId)}`;
|
|
1190
|
-
await fetch(url, {
|
|
1191
|
-
method: "PUT",
|
|
1192
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${botToken}` },
|
|
1193
|
-
body: JSON.stringify({ type: "message", text: updatedText.replace(/(?<!\n)\n(?!\n)/g, "\n\n"), textFormat: "markdown" }),
|
|
1194
|
-
});
|
|
1195
|
-
// Best effort — result not checked
|
|
1196
|
-
}
|
|
1197
|
-
catch {
|
|
1198
|
-
// Best effort — user already sees the usage footer
|
|
1199
|
-
}
|
|
1200
|
-
})();
|
|
1201
|
-
}
|
|
1202
|
-
else if (this.notificationChannelId && sessionId !== this.assistantSession?.id) {
|
|
1203
|
-
const sess = this.core.sessionManager.getSession(sessionId);
|
|
1204
|
-
const name = sess?.name || "Session";
|
|
1205
|
-
void this.sendNotification({
|
|
1206
|
-
sessionId,
|
|
1207
|
-
sessionName: name,
|
|
1208
|
-
type: "completed",
|
|
1209
|
-
summary: "Task completed",
|
|
1210
|
-
});
|
|
1124
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1125
|
+
const parts = [];
|
|
1126
|
+
parts.push(`${formatTokens(meta.tokensUsed)} tokens`);
|
|
1127
|
+
if (meta.duration != null)
|
|
1128
|
+
parts.push(`${(meta.duration / 1000).toFixed(1)}s`);
|
|
1129
|
+
if (meta.cost != null)
|
|
1130
|
+
parts.push(`$${meta.cost.toFixed(4)}`);
|
|
1131
|
+
parts.push("Task completed");
|
|
1132
|
+
const footerText = parts.join(" · ");
|
|
1133
|
+
msg.setFooter(footerText);
|
|
1134
|
+
// Usage signals end of a turn — clear ephemeral header
|
|
1135
|
+
msg.clearHeader();
|
|
1211
1136
|
}
|
|
1212
1137
|
}
|
|
1213
1138
|
/** Suggested quick-reply actions (Teams restricts these to 1:1 personal chat only) */
|
|
@@ -1230,20 +1155,14 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1230
1155
|
return {};
|
|
1231
1156
|
}
|
|
1232
1157
|
/**
|
|
1233
|
-
* Clean up all per-session state (contexts,
|
|
1234
|
-
* Removes both sessionId and threadId entries from _sessionContexts to prevent leaks.
|
|
1158
|
+
* Clean up all per-session state (contexts, composer, output modes).
|
|
1235
1159
|
*/
|
|
1236
1160
|
cleanupSessionState(sessionId) {
|
|
1237
|
-
// Find and remove the threadId entry that may also reference this session's context.
|
|
1238
|
-
// First try the stored threadId on the context entry itself (reliable even if the
|
|
1239
|
-
// session has already been removed from the session manager).
|
|
1240
1161
|
const entry = this._sessionContexts.get(sessionId);
|
|
1241
1162
|
const storedThreadId = entry?.threadId;
|
|
1242
1163
|
if (storedThreadId && storedThreadId !== sessionId) {
|
|
1243
1164
|
this._sessionContexts.delete(storedThreadId);
|
|
1244
1165
|
}
|
|
1245
|
-
// Fallback: also check session manager and session record in case the context entry
|
|
1246
|
-
// was already removed or the threadId wasn't stored.
|
|
1247
1166
|
const session = this.core.sessionManager.getSession(sessionId);
|
|
1248
1167
|
const threadId = session?.threadId;
|
|
1249
1168
|
if (threadId && threadId !== sessionId && threadId !== storedThreadId) {
|
|
@@ -1256,28 +1175,30 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1256
1175
|
}
|
|
1257
1176
|
this._sessionContexts.delete(sessionId);
|
|
1258
1177
|
this._sessionOutputModes.delete(sessionId);
|
|
1259
|
-
this.
|
|
1260
|
-
this.
|
|
1178
|
+
this._planSending.delete(sessionId);
|
|
1179
|
+
this.composer.cleanup(sessionId);
|
|
1261
1180
|
}
|
|
1262
1181
|
async handleSessionEnd(sessionId, _content) {
|
|
1263
1182
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1264
1183
|
if (!ctx)
|
|
1265
1184
|
return;
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1185
|
+
const msg = this.composer.get(sessionId);
|
|
1186
|
+
if (msg) {
|
|
1187
|
+
msg.appendFooter("Task completed");
|
|
1188
|
+
}
|
|
1189
|
+
const ref = await this.composer.finalize(sessionId);
|
|
1268
1190
|
this.cleanupSessionState(sessionId);
|
|
1269
1191
|
}
|
|
1270
1192
|
async handleError(sessionId, content) {
|
|
1271
1193
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1272
1194
|
if (!ctx)
|
|
1273
1195
|
return;
|
|
1274
|
-
|
|
1275
|
-
await this.draftManager.finalize(sessionId, context, isAssistant);
|
|
1196
|
+
await this.composer.finalize(sessionId);
|
|
1276
1197
|
this.cleanupSessionState(sessionId);
|
|
1277
1198
|
try {
|
|
1278
|
-
await this.sendActivityWithRetry(context, {
|
|
1199
|
+
await this.sendActivityWithRetry(ctx.context, {
|
|
1279
1200
|
text: `❌ **Error:** ${content.text}`,
|
|
1280
|
-
...this.getQuickActions(context),
|
|
1201
|
+
...this.getQuickActions(ctx.context),
|
|
1281
1202
|
});
|
|
1282
1203
|
}
|
|
1283
1204
|
catch { /* best effort */ }
|
|
@@ -1289,33 +1210,29 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1289
1210
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1290
1211
|
if (!ctx)
|
|
1291
1212
|
return;
|
|
1292
|
-
|
|
1293
|
-
// Strip TTS markers from the draft BEFORE finalizing — finalize() deletes
|
|
1294
|
-
// the draft from the map, so getDraft() would return undefined after it.
|
|
1213
|
+
// Strip TTS markers from body before sending attachment
|
|
1295
1214
|
if (attachment.type === "audio") {
|
|
1296
|
-
const
|
|
1297
|
-
if (
|
|
1298
|
-
await
|
|
1299
|
-
log.warn({ err, sessionId }, "[TeamsAdapter] handleAttachment: stripPattern failed");
|
|
1300
|
-
});
|
|
1215
|
+
const msg = this.composer.get(sessionId);
|
|
1216
|
+
if (msg) {
|
|
1217
|
+
await msg.stripPattern(/\[TTS\][\s\S]*?\[\/TTS\]/g).catch(() => { });
|
|
1301
1218
|
}
|
|
1302
1219
|
}
|
|
1303
|
-
await this.draftManager.finalize(sessionId, context, isAssistant);
|
|
1304
1220
|
if (isAttachmentTooLarge(attachment.size)) {
|
|
1305
1221
|
log.warn({ sessionId, fileName: attachment.fileName, size: attachment.size }, "[TeamsAdapter] File too large");
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
text: `⚠️ File too large to send (${Math.round(attachment.size / 1024 / 1024)}MB): ${attachment.fileName}`,
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
catch { /* best effort */ }
|
|
1222
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1223
|
+
msg.appendBody(`\n\n⚠️ File too large to send (${Math.round(attachment.size / 1024 / 1024)}MB): ${attachment.fileName}`);
|
|
1312
1224
|
return;
|
|
1313
1225
|
}
|
|
1314
1226
|
try {
|
|
1315
|
-
// Upload to OneDrive via Graph API if available, get a sharing URL
|
|
1316
1227
|
const shareUrl = await uploadFileViaGraph(this.graphClient, sessionId, attachment.filePath, attachment.fileName, attachment.mimeType);
|
|
1317
|
-
|
|
1318
|
-
|
|
1228
|
+
// Append file info inline in the body
|
|
1229
|
+
const msg = this.composer.getOrCreate(sessionId, ctx.context);
|
|
1230
|
+
if (shareUrl) {
|
|
1231
|
+
msg.appendBody(`\n\n📎 [${attachment.fileName}](${shareUrl})`);
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
msg.appendBody(`\n\n📎 ${attachment.fileName} (${Math.round(attachment.size / 1024)}KB)`);
|
|
1235
|
+
}
|
|
1319
1236
|
}
|
|
1320
1237
|
catch (err) {
|
|
1321
1238
|
log.error({ err, sessionId, fileName: attachment.fileName }, "[TeamsAdapter] Failed to send attachment");
|
|
@@ -1327,20 +1244,22 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1327
1244
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1328
1245
|
if (!ctx)
|
|
1329
1246
|
return;
|
|
1330
|
-
const { context } = ctx;
|
|
1331
1247
|
try {
|
|
1332
|
-
await this.sendActivityWithRetry(context, { text: content.text });
|
|
1248
|
+
await this.sendActivityWithRetry(ctx.context, { text: `⚙️ ${content.text}` });
|
|
1333
1249
|
}
|
|
1334
1250
|
catch { /* best effort */ }
|
|
1335
1251
|
}
|
|
1252
|
+
/** Sanitize metadata strings for safe markdown interpolation. */
|
|
1253
|
+
static sanitizeMd(text) {
|
|
1254
|
+
return text.replace(/[*_~`[\]()\\]/g, "").slice(0, 200);
|
|
1255
|
+
}
|
|
1336
1256
|
async handleModeChange(sessionId, content) {
|
|
1337
1257
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1338
1258
|
if (!ctx)
|
|
1339
1259
|
return;
|
|
1340
|
-
const
|
|
1341
|
-
const rendered = renderer.renderModeChange(content);
|
|
1260
|
+
const modeId = TeamsAdapter.sanitizeMd(String(content.metadata?.modeId ?? ""));
|
|
1342
1261
|
try {
|
|
1343
|
-
await this.sendActivityWithRetry(ctx.context, { text:
|
|
1262
|
+
await this.sendActivityWithRetry(ctx.context, { text: `⚙️ **Mode:** ${modeId}` });
|
|
1344
1263
|
}
|
|
1345
1264
|
catch { /* best effort */ }
|
|
1346
1265
|
}
|
|
@@ -1348,10 +1267,10 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1348
1267
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1349
1268
|
if (!ctx)
|
|
1350
1269
|
return;
|
|
1351
|
-
const
|
|
1352
|
-
const
|
|
1270
|
+
const key = content.metadata?.key;
|
|
1271
|
+
const detail = key ? ` \`${TeamsAdapter.sanitizeMd(String(key))}\`` : "";
|
|
1353
1272
|
try {
|
|
1354
|
-
await this.sendActivityWithRetry(ctx.context, { text:
|
|
1273
|
+
await this.sendActivityWithRetry(ctx.context, { text: `⚙️ **Config updated**${detail}` });
|
|
1355
1274
|
}
|
|
1356
1275
|
catch { /* best effort */ }
|
|
1357
1276
|
}
|
|
@@ -1359,10 +1278,9 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1359
1278
|
const ctx = this._sessionContexts.get(sessionId);
|
|
1360
1279
|
if (!ctx)
|
|
1361
1280
|
return;
|
|
1362
|
-
const
|
|
1363
|
-
const rendered = renderer.renderModelUpdate(content);
|
|
1281
|
+
const modelId = TeamsAdapter.sanitizeMd(String(content.metadata?.modelId ?? ""));
|
|
1364
1282
|
try {
|
|
1365
|
-
await this.sendActivityWithRetry(ctx.context, { text:
|
|
1283
|
+
await this.sendActivityWithRetry(ctx.context, { text: `⚙️ **Model:** ${modelId}` });
|
|
1366
1284
|
}
|
|
1367
1285
|
catch { /* best effort */ }
|
|
1368
1286
|
}
|
|
@@ -1394,9 +1312,7 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1394
1312
|
return;
|
|
1395
1313
|
const rawUrl = content.metadata?.url;
|
|
1396
1314
|
const rawName = content.metadata?.name;
|
|
1397
|
-
// Only allow http/https URLs to prevent javascript: or data: scheme injection
|
|
1398
1315
|
const url = rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined;
|
|
1399
|
-
// Sanitize name to prevent markdown injection — strip characters that break link syntax
|
|
1400
1316
|
const name = rawName?.replace(/[\[\]\(\)]/g, "") || undefined;
|
|
1401
1317
|
const text = url ? `📎 [${name || url}](${url})` : content.text;
|
|
1402
1318
|
try {
|
|
@@ -1569,7 +1485,12 @@ export class TeamsAdapter extends MessagingAdapter {
|
|
|
1569
1485
|
});
|
|
1570
1486
|
}
|
|
1571
1487
|
catch { /* best effort */ }
|
|
1572
|
-
|
|
1488
|
+
// Update the main message title if the session has an active composer
|
|
1489
|
+
const msg = this.composer.get(sessionId);
|
|
1490
|
+
if (msg && newName) {
|
|
1491
|
+
msg.setTitle(newName);
|
|
1492
|
+
}
|
|
1493
|
+
log.debug({ sessionId, newName }, "[TeamsAdapter] renameSessionThread — title updated");
|
|
1573
1494
|
}
|
|
1574
1495
|
async deleteSessionThread(sessionId) {
|
|
1575
1496
|
const record = this.core.sessionManager.getSessionRecord(sessionId);
|