@hahnfeld/teams-adapter 1.0.9

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +286 -0
  3. package/dist/activity.d.ts +23 -0
  4. package/dist/activity.d.ts.map +1 -0
  5. package/dist/activity.js +3 -0
  6. package/dist/activity.js.map +1 -0
  7. package/dist/adapter.d.ts +174 -0
  8. package/dist/adapter.d.ts.map +1 -0
  9. package/dist/adapter.js +1583 -0
  10. package/dist/adapter.js.map +1 -0
  11. package/dist/app-package.d.ts +7 -0
  12. package/dist/app-package.d.ts.map +1 -0
  13. package/dist/app-package.js +158 -0
  14. package/dist/app-package.js.map +1 -0
  15. package/dist/assistant.d.ts +7 -0
  16. package/dist/assistant.d.ts.map +1 -0
  17. package/dist/assistant.js +32 -0
  18. package/dist/assistant.js.map +1 -0
  19. package/dist/commands/admin.d.ts +27 -0
  20. package/dist/commands/admin.d.ts.map +1 -0
  21. package/dist/commands/admin.js +146 -0
  22. package/dist/commands/admin.js.map +1 -0
  23. package/dist/commands/agents.d.ts +13 -0
  24. package/dist/commands/agents.d.ts.map +1 -0
  25. package/dist/commands/agents.js +98 -0
  26. package/dist/commands/agents.js.map +1 -0
  27. package/dist/commands/doctor.d.ts +8 -0
  28. package/dist/commands/doctor.d.ts.map +1 -0
  29. package/dist/commands/doctor.js +49 -0
  30. package/dist/commands/doctor.js.map +1 -0
  31. package/dist/commands/index.d.ts +16 -0
  32. package/dist/commands/index.d.ts.map +1 -0
  33. package/dist/commands/index.js +253 -0
  34. package/dist/commands/index.js.map +1 -0
  35. package/dist/commands/integrate.d.ts +8 -0
  36. package/dist/commands/integrate.d.ts.map +1 -0
  37. package/dist/commands/integrate.js +45 -0
  38. package/dist/commands/integrate.js.map +1 -0
  39. package/dist/commands/menu.d.ts +16 -0
  40. package/dist/commands/menu.d.ts.map +1 -0
  41. package/dist/commands/menu.js +92 -0
  42. package/dist/commands/menu.js.map +1 -0
  43. package/dist/commands/new-session.d.ts +13 -0
  44. package/dist/commands/new-session.d.ts.map +1 -0
  45. package/dist/commands/new-session.js +105 -0
  46. package/dist/commands/new-session.js.map +1 -0
  47. package/dist/commands/session.d.ts +22 -0
  48. package/dist/commands/session.d.ts.map +1 -0
  49. package/dist/commands/session.js +110 -0
  50. package/dist/commands/session.js.map +1 -0
  51. package/dist/commands/settings.d.ts +8 -0
  52. package/dist/commands/settings.d.ts.map +1 -0
  53. package/dist/commands/settings.js +54 -0
  54. package/dist/commands/settings.js.map +1 -0
  55. package/dist/conversation-store.d.ts +38 -0
  56. package/dist/conversation-store.d.ts.map +1 -0
  57. package/dist/conversation-store.js +101 -0
  58. package/dist/conversation-store.js.map +1 -0
  59. package/dist/draft-manager.d.ts +47 -0
  60. package/dist/draft-manager.d.ts.map +1 -0
  61. package/dist/draft-manager.js +136 -0
  62. package/dist/draft-manager.js.map +1 -0
  63. package/dist/formatting.d.ts +121 -0
  64. package/dist/formatting.d.ts.map +1 -0
  65. package/dist/formatting.js +392 -0
  66. package/dist/formatting.js.map +1 -0
  67. package/dist/graph.d.ts +59 -0
  68. package/dist/graph.d.ts.map +1 -0
  69. package/dist/graph.js +261 -0
  70. package/dist/graph.js.map +1 -0
  71. package/dist/index.d.ts +16 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +10 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/media.d.ts +29 -0
  76. package/dist/media.d.ts.map +1 -0
  77. package/dist/media.js +120 -0
  78. package/dist/media.js.map +1 -0
  79. package/dist/permissions.d.ts +15 -0
  80. package/dist/permissions.d.ts.map +1 -0
  81. package/dist/permissions.js +221 -0
  82. package/dist/permissions.js.map +1 -0
  83. package/dist/plugin.d.ts +13 -0
  84. package/dist/plugin.d.ts.map +1 -0
  85. package/dist/plugin.js +689 -0
  86. package/dist/plugin.js.map +1 -0
  87. package/dist/renderer.d.ts +49 -0
  88. package/dist/renderer.d.ts.map +1 -0
  89. package/dist/renderer.js +55 -0
  90. package/dist/renderer.js.map +1 -0
  91. package/dist/send-utils.d.ts +15 -0
  92. package/dist/send-utils.d.ts.map +1 -0
  93. package/dist/send-utils.js +64 -0
  94. package/dist/send-utils.js.map +1 -0
  95. package/dist/task-modules.d.ts +34 -0
  96. package/dist/task-modules.d.ts.map +1 -0
  97. package/dist/task-modules.js +136 -0
  98. package/dist/task-modules.js.map +1 -0
  99. package/dist/types.d.ts +26 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +3 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/validators.d.ts +54 -0
  104. package/dist/validators.d.ts.map +1 -0
  105. package/dist/validators.js +142 -0
  106. package/dist/validators.js.map +1 -0
  107. package/package.json +56 -0
@@ -0,0 +1,1583 @@
1
+ import { App } from "@microsoft/teams.apps";
2
+ import { BotBuilderPlugin } from "@microsoft/teams.botbuilder";
3
+ import { CardFactory, MemoryStorage } from "@microsoft/agents-hosting";
4
+ import { CloudAdapter, ConfigurationBotFrameworkAuthentication, } from "botbuilder";
5
+ import { PasswordServiceClientCredentialFactory } from "botframework-connector";
6
+ import { log, MessagingAdapter, SendQueue } from "@openacp/plugin-sdk";
7
+ import { TeamsRenderer } from "./renderer.js";
8
+ import { DEFAULT_BOT_PORT } from "./types.js";
9
+ import { TeamsDraftManager } from "./draft-manager.js";
10
+ import { PermissionHandler } from "./permissions.js";
11
+ import { handleCommand, setupCardActionCallbacks } from "./commands/index.js";
12
+ import { spawnAssistant } from "./assistant.js";
13
+ import { downloadTeamsFile, isAttachmentTooLarge, buildFileAttachmentCard, uploadFileViaGraph } from "./media.js";
14
+ import { GraphFileClient } from "./graph.js";
15
+ import { ConversationStore } from "./conversation-store.js";
16
+ import { sendText, sendCard, sendActivity } from "./send-utils.js";
17
+ import { renderUsageCard, renderToolCallCard, renderPlanCard, buildCitationEntities } from "./formatting.js";
18
+ /** Max retry attempts for transient Teams API failures */
19
+ const MAX_RETRIES = 3;
20
+ /** Base delay (ms) for exponential backoff */
21
+ const BASE_RETRY_DELAY = 1000;
22
+ export class TeamsAdapter extends MessagingAdapter {
23
+ name = "teams";
24
+ renderer = new TeamsRenderer();
25
+ capabilities = {
26
+ streaming: true,
27
+ richFormatting: true,
28
+ threads: true,
29
+ reactions: false,
30
+ fileUpload: true,
31
+ voice: false,
32
+ };
33
+ core;
34
+ app;
35
+ teamsConfig;
36
+ sendQueue;
37
+ draftManager;
38
+ permissionHandler;
39
+ notificationChannelId;
40
+ assistantSession = null;
41
+ assistantInitializing = false;
42
+ fileService;
43
+ graphClient;
44
+ conversationStore;
45
+ /**
46
+ * Per-session TurnContext references, set during inbound message handling.
47
+ * Handler overrides read from this map during sendMessage dispatch.
48
+ */
49
+ _sessionContexts = new Map();
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
+ /** Track processed activity IDs to handle Teams 15-second retry deduplication */
62
+ _processedActivities = new Map();
63
+ _processedCleanupTimer;
64
+ /** Messages buffered during assistant initialization — replayed once ready. Capped to prevent unbounded growth. */
65
+ static MAX_INIT_BUFFER = 50;
66
+ _assistantInitBuffer = [];
67
+ /** Bot token cache for proactive messaging via connector REST API */
68
+ _botTokenCache;
69
+ constructor(core, config) {
70
+ super({ configManager: core.configManager },
71
+ // Teams measures message size in bytes (100KB limit, 80KB safe threshold).
72
+ // Use 12000 chars as the split limit — safe for multi-byte content (CJK, emoji)
73
+ // where each char can be 3-4 bytes, plus activity envelope overhead.
74
+ { ...config, maxMessageLength: 12000, enabled: config.enabled ?? true });
75
+ this.core = core;
76
+ this.teamsConfig = config;
77
+ this.sendQueue = new SendQueue({ minInterval: 1000 });
78
+ this.draftManager = new TeamsDraftManager(this.sendQueue);
79
+ this.fileService = core.fileService;
80
+ // Persistent conversation reference store for proactive messaging
81
+ const storageDir = core.configManager.instanceRoot ?? process.cwd();
82
+ this.conversationStore = new ConversationStore(storageDir);
83
+ // Initialize Graph API client for file operations (optional)
84
+ if (config.graphClientSecret && config.tenantId && config.botAppId) {
85
+ this.graphClient = new GraphFileClient(config.tenantId, config.botAppId, config.graphClientSecret);
86
+ log.info("[TeamsAdapter] Graph API file client initialized");
87
+ }
88
+ const isSingleTenant = config.tenantId && config.tenantId !== "botframework.com";
89
+ // Custom credential factory that accepts both the bot's app ID and the
90
+ // Bot Framework audience URI. Azure Bot Service sends single-tenant tokens
91
+ // with aud=https://api.botframework.com, but the SDK's default factory only
92
+ // accepts the bot's own app ID — causing "Invalid AppId" errors.
93
+ const credFactory = new PasswordServiceClientCredentialFactory(config.botAppId, config.botAppPassword, isSingleTenant ? config.tenantId : "");
94
+ const origIsValidAppId = credFactory.isValidAppId.bind(credFactory);
95
+ credFactory.isValidAppId = async (appId) => {
96
+ if (appId === "https://api.botframework.com")
97
+ return true;
98
+ return origIsValidAppId(appId);
99
+ };
100
+ const botAuth = new ConfigurationBotFrameworkAuthentication({}, credFactory);
101
+ const cloudAdapter = new CloudAdapter(botAuth);
102
+ const botBuilderPlugin = new BotBuilderPlugin({ adapter: cloudAdapter });
103
+ this.app = new App({
104
+ clientId: config.botAppId,
105
+ clientSecret: config.botAppPassword,
106
+ tenantId: isSingleTenant ? config.tenantId : undefined,
107
+ port: config.botPort ?? DEFAULT_BOT_PORT,
108
+ skipAuth: true, // App-level JWT validation skipped; CloudAdapter handles auth
109
+ storage: new MemoryStorage(),
110
+ plugins: [botBuilderPlugin],
111
+ });
112
+ }
113
+ // ─── start ────────────────────────────────────────────────────────────────
114
+ _started = false;
115
+ async start() {
116
+ if (this._started) {
117
+ log.warn("[TeamsAdapter] Already started, skipping duplicate start()");
118
+ return;
119
+ }
120
+ this._started = true;
121
+ log.info("[TeamsAdapter] Starting...");
122
+ try {
123
+ this.notificationChannelId = this.teamsConfig.notificationChannelId ?? undefined;
124
+ this.permissionHandler = new PermissionHandler((sessionId) => this.core.sessionManager.getSession(sessionId), (notification) => this.sendNotification(notification));
125
+ this.setupMessageHandler();
126
+ this.setupCardActionHandler();
127
+ this.setupReactionHandler();
128
+ // Periodic cleanup of processed activity IDs (deduplication cache)
129
+ this._processedCleanupTimer = setInterval(() => {
130
+ const cutoff = Date.now() - 60_000; // 60s retention
131
+ for (const [id, ts] of this._processedActivities) {
132
+ if (ts < cutoff)
133
+ this._processedActivities.delete(id);
134
+ }
135
+ }, 30_000);
136
+ if (this._processedCleanupTimer.unref)
137
+ this._processedCleanupTimer.unref();
138
+ await this.app.start();
139
+ const botPort = this.teamsConfig.botPort ?? DEFAULT_BOT_PORT;
140
+ log.info(`[TeamsAdapter] Bot Framework server listening on port ${botPort}`);
141
+ // Spawn assistant session if configured (non-blocking — matches Telegram's pattern)
142
+ if (this.teamsConfig.assistantThreadId) {
143
+ this.setupAssistant().catch((err) => {
144
+ log.error({ err }, "[TeamsAdapter] Assistant setup failed (non-blocking)");
145
+ });
146
+ }
147
+ log.info("[TeamsAdapter] Initialization complete");
148
+ }
149
+ catch (err) {
150
+ log.error({ err }, "[TeamsAdapter] Initialization failed");
151
+ throw err;
152
+ }
153
+ }
154
+ // ─── stop ─────────────────────────────────────────────────────────────────
155
+ async stop() {
156
+ // Cancel deduplication cleanup timer
157
+ if (this._processedCleanupTimer) {
158
+ clearInterval(this._processedCleanupTimer);
159
+ this._processedCleanupTimer = undefined;
160
+ }
161
+ if (this.assistantSession) {
162
+ try {
163
+ await this.assistantSession.destroy();
164
+ }
165
+ catch (err) {
166
+ log.warn({ err }, "[TeamsAdapter] Failed to destroy assistant session");
167
+ }
168
+ this.assistantSession = null;
169
+ }
170
+ this._sessionContexts.clear();
171
+ this._sessionOutputModes.clear();
172
+ this._dispatchQueues.clear();
173
+ this._processedActivities.clear();
174
+ this.sendQueue.clear();
175
+ this.conversationStore.destroy();
176
+ this.permissionHandler.dispose();
177
+ await this.app.stop();
178
+ this._started = false;
179
+ log.info("[TeamsAdapter] Stopped");
180
+ }
181
+ // ─── Message handler ──────────────────────────────────────────────────────
182
+ setupMessageHandler() {
183
+ // File consent: accept
184
+ this.app.on("file.consent.accept", async (context) => {
185
+ try {
186
+ const uploadInfo = context.activity.value?.uploadInfo;
187
+ const fileName = uploadInfo?.name ?? "file";
188
+ log.info({ fileName }, "[TeamsAdapter] File consent accepted");
189
+ await sendText(context, `✅ File consent accepted: ${fileName}`);
190
+ return { status: 200 };
191
+ }
192
+ catch (err) {
193
+ log.error({ err }, "[TeamsAdapter] file.consent.accept error");
194
+ return { status: 500 };
195
+ }
196
+ });
197
+ // File consent: decline
198
+ this.app.on("file.consent.decline", async (context) => {
199
+ try {
200
+ const uploadInfo = context.activity.value?.uploadInfo;
201
+ const fileName = uploadInfo?.name ?? "file";
202
+ log.info({ fileName }, "[TeamsAdapter] File consent declined");
203
+ await sendText(context, `❌ File consent declined: ${fileName}`);
204
+ return { status: 200 };
205
+ }
206
+ catch (err) {
207
+ log.error({ err }, "[TeamsAdapter] file.consent.decline error");
208
+ return { status: 500 };
209
+ }
210
+ });
211
+ this.app.on("message", async (context) => {
212
+ // Action.Submit cards send activity.value with the flat data object (no text).
213
+ // Intercept these before normal text processing.
214
+ const submitValue = context.activity.value;
215
+ if (submitValue && !context.activity.text) {
216
+ await this.handleSubmitAction(context, submitValue);
217
+ return;
218
+ }
219
+ const rawActivityText = context.activity.text ?? "";
220
+ // Teams may prepend the bot @mention (e.g., "<at>BotName</at> /new") — strip it
221
+ const text = rawActivityText.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
222
+ log.info({ rawText: rawActivityText.slice(0, 100), cleanText: text.slice(0, 100), activityType: context.activity.type }, "[TeamsAdapter] Incoming activity");
223
+ const userId = context.activity.from?.id ?? "unknown";
224
+ // Use conversation.id as the thread discriminator — NOT activity.channelId
225
+ // which is always "msteams" for Teams. Conversation ID uniquely identifies
226
+ // the 1:1, group chat, or channel thread the message came from.
227
+ const conversationId = String(context.activity.conversation?.id ?? "unknown");
228
+ const threadId = conversationId;
229
+ const activityId = context.activity.id;
230
+ try {
231
+ // Idempotency: Teams retries if bot takes >15s — skip duplicate activity IDs
232
+ if (activityId && this._processedActivities.has(activityId)) {
233
+ log.debug({ activityId }, "[TeamsAdapter] Duplicate activity, skipping");
234
+ return;
235
+ }
236
+ if (activityId)
237
+ this._processedActivities.set(activityId, Date.now());
238
+ log.debug({ threadId, userId, text: text.slice(0, 50) }, "[TeamsAdapter] message received");
239
+ if (!text && !context.activity.attachments?.length)
240
+ return;
241
+ // Persist conversation reference for proactive messaging (survives restarts).
242
+ // Validate serviceUrl against known Bot Framework endpoints to prevent
243
+ // SSRF via spoofed serviceUrl redirecting bot tokens to an attacker.
244
+ if (context.activity.conversation?.id && context.activity.serviceUrl) {
245
+ const serviceUrl = context.activity.serviceUrl;
246
+ if (TeamsAdapter.isValidServiceUrl(serviceUrl)) {
247
+ this.conversationStore.upsert({
248
+ conversationId: context.activity.conversation.id,
249
+ serviceUrl,
250
+ tenantId: context.activity.conversation.tenantId ?? this.teamsConfig.tenantId,
251
+ channelId: context.activity.channelId,
252
+ botId: context.activity.recipient?.id ?? this.teamsConfig.botAppId,
253
+ botName: context.activity.recipient?.name ?? "OpenACP",
254
+ updatedAt: Date.now(),
255
+ });
256
+ }
257
+ else {
258
+ log.warn({ serviceUrl: serviceUrl.slice(0, 80) }, "[TeamsAdapter] Rejected untrusted serviceUrl");
259
+ }
260
+ }
261
+ const existingSession = this.core.sessionManager.getSessionByThread("teams", threadId);
262
+ const sessionId = existingSession?.id ?? "unknown";
263
+ // Always store context under threadId — this is the stable key.
264
+ // For new sessions, core.handleMessage creates the session and assigns threadId,
265
+ // then sendMessage is called with the new sessionId. We also store under sessionId
266
+ // when known, and under threadId as a universal fallback.
267
+ const isAssistant = this.assistantSession != null && sessionId === this.assistantSession?.id;
268
+ this._sessionContexts.set(threadId, { context, isAssistant, threadId });
269
+ if (sessionId !== "unknown") {
270
+ this._sessionContexts.set(sessionId, { context, isAssistant, threadId });
271
+ }
272
+ // Route slash commands — local handler first (handles /new, /help, etc.
273
+ // with Teams-specific UX), then fall back to core command registry.
274
+ if (text.startsWith("/")) {
275
+ // Always try local command handler first — it has Teams-specific implementations
276
+ const handled = await handleCommand(context, this, userId, sessionId !== "unknown" ? sessionId : null);
277
+ if (handled)
278
+ return;
279
+ // Fall back to core command registry for commands not handled locally
280
+ const registry = this.getCommandRegistry();
281
+ if (registry) {
282
+ const rawCommand = text.split(" ")[0].slice(1).toLowerCase();
283
+ const def = registry.get(rawCommand);
284
+ if (def) {
285
+ try {
286
+ const response = await registry.execute(text, {
287
+ raw: "",
288
+ sessionId: sessionId !== "unknown" ? sessionId : null,
289
+ channelId: "teams",
290
+ userId,
291
+ reply: async (content) => {
292
+ if (typeof content === "string") {
293
+ await sendText(context, content);
294
+ }
295
+ },
296
+ });
297
+ if (response.type !== "silent") {
298
+ await this.renderCommandResponse(response, context);
299
+ }
300
+ }
301
+ catch (err) {
302
+ log.error({ err, command: rawCommand }, "[TeamsAdapter] Command failed");
303
+ await sendText(context, `❌ Command failed: ${err instanceof Error ? err.message : String(err)}`);
304
+ }
305
+ return;
306
+ }
307
+ }
308
+ return;
309
+ }
310
+ // Process attachments only when a session already exists — saving files
311
+ // requires a valid sessionId for the file service to scope storage correctly.
312
+ // First messages (sessionId === "unknown") skip attachment processing; the
313
+ // auto-session-creation path below handles the text, and the user can resend
314
+ // the attachment once the session is established.
315
+ const attachments = [];
316
+ if (sessionId !== "unknown" && context.activity.attachments?.length) {
317
+ for (const att of context.activity.attachments) {
318
+ try {
319
+ const buffer = await downloadTeamsFile(att.contentUrl ?? "", att.name ?? "attachment", this.graphClient);
320
+ if (buffer) {
321
+ const saved = await this.fileService.saveFile(sessionId, att.name ?? "attachment", buffer, att.contentType ?? "application/octet-stream");
322
+ if (saved)
323
+ attachments.push(saved);
324
+ }
325
+ }
326
+ catch (err) {
327
+ log.warn({ err, name: att.name }, "[TeamsAdapter] Attachment download failed");
328
+ }
329
+ }
330
+ }
331
+ let messageText = text;
332
+ if (!messageText && attachments.length > 0) {
333
+ messageText = attachments.map((a) => `[Attachment: ${a.fileName}]`).join("\n");
334
+ }
335
+ if (!messageText && attachments.length === 0)
336
+ return;
337
+ // Route to assistant thread
338
+ if (this.teamsConfig.assistantThreadId &&
339
+ threadId === this.teamsConfig.assistantThreadId) {
340
+ if (this.assistantSession && messageText) {
341
+ try {
342
+ await this.assistantSession.enqueuePrompt(messageText, attachments.length > 0 ? attachments : undefined);
343
+ }
344
+ catch (err) {
345
+ log.error({ err, sessionId: this.assistantSession.id }, "[TeamsAdapter] assistant enqueuePrompt failed");
346
+ }
347
+ }
348
+ return;
349
+ }
350
+ if (sessionId !== "unknown") {
351
+ // Drain pending dispatches and reset draft state for new prompt
352
+ const pendingDispatch = this._dispatchQueues.get(sessionId);
353
+ if (pendingDispatch)
354
+ await pendingDispatch;
355
+ this.draftManager.cleanup(sessionId);
356
+ }
357
+ // Show typing indicator while the agent processes the message
358
+ this.sendTyping(context);
359
+ const existingSessionBeforeSend = this.core.sessionManager.getSessionByThread("teams", threadId);
360
+ if (!existingSessionBeforeSend) {
361
+ const defaultAgent = this.core.configManager.get()?.defaultAgent ?? "claude";
362
+ log.info({ threadId, text: messageText.slice(0, 50), defaultAgent }, "[TeamsAdapter] No session — auto-creating via /new");
363
+ // Auto-create a session for first-time messages
364
+ const registry = this.getCommandRegistry();
365
+ if (registry) {
366
+ try {
367
+ const response = await registry.execute(`/new ${defaultAgent}`, {
368
+ raw: messageText,
369
+ sessionId: null,
370
+ channelId: "teams",
371
+ userId,
372
+ reply: async (content) => {
373
+ if (typeof content === "string") {
374
+ await sendText(context, content);
375
+ }
376
+ },
377
+ });
378
+ if (response.type !== "silent") {
379
+ await this.renderCommandResponse(response, context);
380
+ }
381
+ // After session creation, route the original message to the new session
382
+ const newSession = this.core.sessionManager.getSessionByThread("teams", threadId);
383
+ if (newSession && messageText) {
384
+ this._sessionContexts.set(newSession.id, { context, isAssistant: false });
385
+ await this.core.handleMessage({
386
+ channelId: "teams",
387
+ threadId,
388
+ userId,
389
+ text: messageText,
390
+ ...(attachments.length > 0 ? { attachments } : {}),
391
+ });
392
+ }
393
+ }
394
+ catch (err) {
395
+ log.error({ err }, "[TeamsAdapter] Auto-create session failed");
396
+ await sendText(context, "👋 Send /new to start a session.");
397
+ }
398
+ return;
399
+ }
400
+ await sendText(context, "👋 Send /new to start a session.");
401
+ return;
402
+ }
403
+ await this.core.handleMessage({
404
+ channelId: "teams",
405
+ threadId,
406
+ userId,
407
+ text: messageText,
408
+ ...(attachments.length > 0 ? { attachments } : {}),
409
+ });
410
+ }
411
+ catch (err) {
412
+ log.error({ err, threadId, userId }, "[TeamsAdapter] message handler error");
413
+ try {
414
+ await sendText(context, "❌ Failed to process message. Please try again.");
415
+ }
416
+ catch { /* best effort */ }
417
+ }
418
+ });
419
+ }
420
+ // ─── Card action handler ────────────────────────────────────────────────
421
+ /**
422
+ * Handle Action.Submit payloads from Adaptive Cards.
423
+ *
424
+ * Action.Submit (v1.2 compatible) sends the card's data directly as
425
+ * activity.value with no text. This handles permission responses, command
426
+ * buttons, and output mode changes — all of which embed a `verb` field.
427
+ */
428
+ async handleSubmitAction(context, data) {
429
+ try {
430
+ // Inline dialog form submissions (dialogAction from inline wizard cards)
431
+ const dialogAction = data.dialogAction;
432
+ if (dialogAction) {
433
+ await this.handleDialogAction(context, dialogAction, data);
434
+ return;
435
+ }
436
+ const verb = data.verb;
437
+ if (!verb)
438
+ return;
439
+ await this.dispatchCardVerb(context, verb, data);
440
+ }
441
+ catch (err) {
442
+ log.error({ err }, "[TeamsAdapter] handleSubmitAction error");
443
+ }
444
+ }
445
+ /**
446
+ * Shared card action dispatch — handles verbs from both Action.Submit and
447
+ * Action.Execute (invoke) paths. Eliminates duplication between
448
+ * handleSubmitAction and cardActionHandler.
449
+ */
450
+ async dispatchCardVerb(context, verb, data) {
451
+ // Permission response: data has verb + sessionId + callbackKey + requestId
452
+ if (data.sessionId && data.callbackKey && data.requestId) {
453
+ const handled = await this.permissionHandler.handleCardAction(context, verb, data.sessionId, data.callbackKey, data.requestId);
454
+ if (handled)
455
+ return;
456
+ }
457
+ // Output mode change: verb = "om:<sessionId>:<mode>"
458
+ if (verb.startsWith("om:")) {
459
+ const parts = verb.split(":");
460
+ if (parts.length === 3) {
461
+ const [, sessionId, mode] = parts;
462
+ if (mode === "low" || mode === "medium" || mode === "high") {
463
+ const session = this.core.sessionManager.getSession(sessionId);
464
+ if (!session) {
465
+ log.warn({ sessionId }, "[TeamsAdapter] output mode change: session not found");
466
+ return;
467
+ }
468
+ // Verify the action came from the session's conversation
469
+ const conversationId = context.activity.conversation?.id;
470
+ const sessionThread = session.threadIds.get("teams");
471
+ if (sessionThread && conversationId && sessionThread !== conversationId) {
472
+ log.warn({ sessionId, conversationId, sessionThread }, "[TeamsAdapter] om: conversation mismatch");
473
+ return;
474
+ }
475
+ this._sessionOutputModes.set(sessionId, mode);
476
+ await sendText(context, `🔄 Output mode: **${mode}**`);
477
+ return;
478
+ }
479
+ }
480
+ }
481
+ // Session cancel: verb = "cancel:<sessionId>"
482
+ if (verb.startsWith("cancel:")) {
483
+ const sessionId = verb.split(":")[1];
484
+ if (sessionId) {
485
+ const session = this.core.sessionManager.getSession(sessionId);
486
+ if (session) {
487
+ // Verify the action came from the session's conversation
488
+ const conversationId = context.activity.conversation?.id;
489
+ const sessionThread = session.threadIds.get("teams");
490
+ if (sessionThread && conversationId && sessionThread !== conversationId) {
491
+ log.warn({ sessionId, conversationId, sessionThread }, "[TeamsAdapter] cancel: conversation mismatch");
492
+ return;
493
+ }
494
+ try {
495
+ await session.destroy();
496
+ }
497
+ catch (err) {
498
+ log.error({ err, sessionId }, "[TeamsAdapter] cancel: destroy failed");
499
+ try {
500
+ await sendText(context, "❌ Failed to cancel session");
501
+ }
502
+ catch { /* best effort */ }
503
+ return;
504
+ }
505
+ try {
506
+ await sendText(context, "❌ Session cancelled");
507
+ }
508
+ catch { /* best effort */ }
509
+ }
510
+ }
511
+ return;
512
+ }
513
+ // Inline dialog form submission: verb = "dialog:<action>"
514
+ if (verb.startsWith("dialog:")) {
515
+ const action = verb.slice(7);
516
+ await this.handleDialogAction(context, action, data);
517
+ return;
518
+ }
519
+ // Command button: verb = "cmd:<command>"
520
+ if (verb.startsWith("cmd:")) {
521
+ await setupCardActionCallbacks(context, this);
522
+ return;
523
+ }
524
+ }
525
+ // ─── Reaction handler ────────────────────────────────────────────────────
526
+ /**
527
+ * Handle message reactions (like, heart, laugh, surprised, sad, angry).
528
+ *
529
+ * Teams sends messageReaction activities when users react to bot messages.
530
+ * We log them as engagement signals and emit an event so plugins can act
531
+ * on them (e.g., aggregate feedback, adjust behavior).
532
+ */
533
+ setupReactionHandler() {
534
+ this.app.on("messageReaction", async (context) => {
535
+ try {
536
+ const added = context.activity.reactionsAdded;
537
+ const removed = context.activity.reactionsRemoved;
538
+ const replyToId = context.activity.replyToId;
539
+ const userId = context.activity.from?.id ?? "unknown";
540
+ const userName = context.activity.from?.name;
541
+ const conversationId = context.activity.conversation?.id;
542
+ if (added?.length) {
543
+ for (const reaction of added) {
544
+ log.info({ reaction: reaction.type, replyToId, userId, userName, conversationId }, "[TeamsAdapter] Reaction added");
545
+ // Map Teams reactions to sentiment signals
546
+ const positive = ["like", "heart", "laugh"].includes(reaction.type);
547
+ const negative = ["sad", "angry"].includes(reaction.type);
548
+ // Emit reaction event so plugins (usage tracking, analytics) can consume it
549
+ if (this.core.eventBus) {
550
+ this.core.eventBus.emit("teams:reaction", {
551
+ type: reaction.type,
552
+ sentiment: positive ? "positive" : negative ? "negative" : "neutral",
553
+ replyToId,
554
+ userId,
555
+ userName,
556
+ conversationId,
557
+ });
558
+ }
559
+ }
560
+ }
561
+ if (removed?.length) {
562
+ for (const reaction of removed) {
563
+ log.debug({ reaction: reaction.type, replyToId, userId }, "[TeamsAdapter] Reaction removed");
564
+ }
565
+ }
566
+ }
567
+ catch (err) {
568
+ log.warn({ err }, "[TeamsAdapter] Reaction handler error");
569
+ }
570
+ });
571
+ }
572
+ /**
573
+ * Create a session in the background — sends status updates to the conversation.
574
+ * Runs async without blocking the invoke response.
575
+ */
576
+ createSessionInBackground(context, agentName, workspace) {
577
+ const conversationId = context.activity.conversation?.id;
578
+ (async () => {
579
+ try {
580
+ // Use core.createSession (not sessionManager.createSession) so the
581
+ // SessionBridge is connected — without it, agent responses never
582
+ // reach the adapter's sendMessage.
583
+ const session = await this.core.createSession({
584
+ channelId: "teams",
585
+ agentName,
586
+ workingDirectory: workspace,
587
+ threadId: conversationId,
588
+ createThread: !conversationId,
589
+ });
590
+ // Ensure the thread is linked for DM conversations
591
+ if (conversationId) {
592
+ session.threadIds.set("teams", conversationId);
593
+ }
594
+ // Store context so the adapter can send responses to this conversation
595
+ this._sessionContexts.set(session.id, { context, isAssistant: false, threadId: conversationId });
596
+ await sendText(context, `✅ Session created\n\n` +
597
+ `**Agent:** ${agentName}\n\n` +
598
+ `**Workspace:** \`${workspace}\`\n\n` +
599
+ `**Session:** ${session.id.slice(0, 8)}`);
600
+ }
601
+ catch (err) {
602
+ log.error({ err, agentName, workspace }, "[TeamsAdapter] createSessionInBackground error");
603
+ try {
604
+ await sendText(context, `❌ Failed to create session: ${err.message}`);
605
+ }
606
+ catch { /* best effort */ }
607
+ }
608
+ })();
609
+ }
610
+ /**
611
+ * Handle form submissions from inline wizard cards (dialogAction payloads).
612
+ * These come from Action.Execute buttons on cards rendered directly in chat.
613
+ */
614
+ async handleDialogAction(context, action, data) {
615
+ if (action === "new-session") {
616
+ const agentName = data.agent;
617
+ const workspace = data.workspace;
618
+ if (!agentName || !workspace) {
619
+ await sendText(context, "❌ Agent and workspace are required.");
620
+ return;
621
+ }
622
+ const availableAgents = this.core.agentManager.getAvailableAgents();
623
+ if (!availableAgents.some((a) => a.name === agentName)) {
624
+ await sendText(context, `❌ Unknown agent: ${agentName}`);
625
+ return;
626
+ }
627
+ // Send acknowledgment immediately, then create session in the background.
628
+ // Session creation spawns an agent process (~30s) which would timeout the invoke.
629
+ await sendText(context, `🔄 Creating session with **${agentName}**...`);
630
+ this.createSessionInBackground(context, agentName, workspace);
631
+ return;
632
+ }
633
+ if (action === "save-settings") {
634
+ const outputMode = data.outputMode;
635
+ const sessionId = data.sessionId;
636
+ const bypass = data.bypass;
637
+ if (outputMode && (outputMode === "low" || outputMode === "medium" || outputMode === "high")) {
638
+ if (sessionId) {
639
+ this._sessionOutputModes.set(sessionId, outputMode);
640
+ }
641
+ }
642
+ if (bypass !== undefined && sessionId) {
643
+ const session = this.core.sessionManager.getSession(sessionId);
644
+ if (session?.clientOverrides) {
645
+ session.clientOverrides.bypassPermissions = bypass === "true";
646
+ }
647
+ }
648
+ await sendText(context, "✅ Settings saved");
649
+ return;
650
+ }
651
+ await sendText(context, `Unknown action: ${action}`);
652
+ }
653
+ // ─── Card action handler (for Action.Execute / invoke activities) ─────
654
+ setupCardActionHandler() {
655
+ this.app.on("card.action", (async (context) => {
656
+ await this.cardActionHandler(context);
657
+ return { status: 200 };
658
+ }));
659
+ }
660
+ async cardActionHandler(context) {
661
+ try {
662
+ const action = context.activity.value?.action;
663
+ if (!action)
664
+ return;
665
+ const verb = action.verb;
666
+ const data = action.data ?? {};
667
+ if (!verb)
668
+ return;
669
+ await this.dispatchCardVerb(context, verb, data);
670
+ }
671
+ catch (err) {
672
+ log.error({ err }, "[TeamsAdapter] card.action handler error");
673
+ }
674
+ }
675
+ // ─── CommandRegistry dispatch ────────────────────────────────────────────
676
+ getCommandRegistry() {
677
+ return this.core.lifecycleManager?.serviceRegistry?.get("command-registry");
678
+ }
679
+ async handleCommand(text, context, sessionId, userId) {
680
+ const registry = this.getCommandRegistry();
681
+ if (!registry)
682
+ return;
683
+ const response = await registry.execute(text, {
684
+ raw: "",
685
+ sessionId,
686
+ channelId: "teams",
687
+ userId,
688
+ reply: async (content) => {
689
+ if (typeof content === "string") {
690
+ try {
691
+ await sendText(context, content);
692
+ }
693
+ catch (err) {
694
+ log.warn({ err }, "[TeamsAdapter] handleCommand: reply failed");
695
+ }
696
+ }
697
+ },
698
+ });
699
+ if (response.type !== "silent") {
700
+ await this.renderCommandResponse(response, context);
701
+ }
702
+ }
703
+ async renderCommandResponse(response, context) {
704
+ const reply = async (text) => {
705
+ await sendText(context, text);
706
+ };
707
+ switch (response.type) {
708
+ case "text":
709
+ await reply(response.text);
710
+ break;
711
+ case "error":
712
+ await reply(`⚠️ ${response.message}`);
713
+ break;
714
+ case "menu": {
715
+ const card = {
716
+ type: "AdaptiveCard",
717
+ version: "1.2",
718
+ body: [
719
+ { type: "TextBlock", text: response.title, weight: "Bolder", size: "Medium" },
720
+ { type: "TextBlock", text: response.options.map((o) => `• ${o.label}`).join("\n"), wrap: true },
721
+ ],
722
+ actions: response.options.slice(0, 5).map((opt) => ({
723
+ type: "Action.Submit",
724
+ title: opt.label.slice(0, 20),
725
+ data: { verb: `cmd:${opt.command}` },
726
+ })),
727
+ };
728
+ await sendCard(context, card);
729
+ break;
730
+ }
731
+ case "list": {
732
+ const text = response.items.map((i) => `• **${i.label}**${i.detail ? ` — ${i.detail}` : ""}`).join("\n");
733
+ await reply(`${response.title}\n${text}`);
734
+ break;
735
+ }
736
+ case "confirm": {
737
+ const card = {
738
+ type: "AdaptiveCard",
739
+ version: "1.2",
740
+ body: [{ type: "TextBlock", text: response.question, wrap: true }],
741
+ actions: [
742
+ { type: "Action.Submit", title: "Yes", data: { verb: `cmd:${response.onYes}` } },
743
+ { type: "Action.Submit", title: "No", data: { verb: "cmd:noop" } },
744
+ ],
745
+ };
746
+ await sendCard(context, card);
747
+ break;
748
+ }
749
+ case "silent":
750
+ break;
751
+ default:
752
+ await reply(`⚠️ Unexpected response type: ${response.type ?? "unknown"}`);
753
+ }
754
+ }
755
+ // ─── Assistant ────────────────────────────────────────────────────────────
756
+ async setupAssistant() {
757
+ let threadId = this.teamsConfig.assistantThreadId ?? undefined;
758
+ if (!threadId) {
759
+ threadId = await this.createSessionThread("assistant", "Assistant");
760
+ this.teamsConfig.assistantThreadId = threadId;
761
+ await this.core.configManager.save({
762
+ channels: { teams: { assistantThreadId: threadId } },
763
+ });
764
+ log.info({ threadId }, "[TeamsAdapter] Created assistant thread");
765
+ }
766
+ this.assistantInitializing = true;
767
+ try {
768
+ const { session, ready } = await spawnAssistant(this.core, threadId);
769
+ this.assistantSession = session;
770
+ // Guard ensures only one of timeout/ready acts on the buffer (prevents race)
771
+ let settled = false;
772
+ const settle = (replay) => {
773
+ if (settled)
774
+ return;
775
+ settled = true;
776
+ clearTimeout(timeout);
777
+ this.assistantInitializing = false;
778
+ const buffered = this._assistantInitBuffer.splice(0);
779
+ if (replay) {
780
+ for (const { sessionId: sid, content: msg } of buffered) {
781
+ this.sendMessage(sid, msg).catch((err) => {
782
+ log.warn({ err, sessionId: sid }, "[TeamsAdapter] Failed to replay buffered assistant message");
783
+ });
784
+ }
785
+ }
786
+ };
787
+ const timeout = setTimeout(() => {
788
+ log.warn("[TeamsAdapter] Assistant ready timeout — clearing initializing flag");
789
+ settle(false); // discard stale messages on timeout
790
+ }, 60_000);
791
+ if (timeout.unref)
792
+ timeout.unref();
793
+ ready.then(() => settle(true), // replay buffered messages on success
794
+ (err) => {
795
+ log.error({ err }, "[TeamsAdapter] Assistant ready promise rejected");
796
+ settle(false); // discard buffer on failure
797
+ });
798
+ }
799
+ catch (err) {
800
+ this.assistantInitializing = false;
801
+ this._assistantInitBuffer.splice(0);
802
+ log.error({ err }, "[TeamsAdapter] Failed to spawn assistant");
803
+ }
804
+ }
805
+ async respawnAssistant() {
806
+ // Reset init state to prevent stale closures from a previous setupAssistant()
807
+ // call (e.g., its 60s timeout) from interfering with the new spawn.
808
+ this.assistantInitializing = false;
809
+ this._assistantInitBuffer.splice(0);
810
+ if (this.assistantSession) {
811
+ try {
812
+ await this.assistantSession.destroy();
813
+ }
814
+ catch { /* ignore */ }
815
+ this.assistantSession = null;
816
+ }
817
+ await this.setupAssistant();
818
+ }
819
+ async restartAssistant() {
820
+ await this.respawnAssistant();
821
+ }
822
+ // ─── Typing indicator ────────────────────────────────────────────────────
823
+ /** Send a typing indicator to the user. Non-critical — failures are silently ignored. */
824
+ sendTyping(context) {
825
+ sendActivity(context, { type: "typing" }).catch(() => { });
826
+ }
827
+ // ─── Bot token for proactive messaging ────────────────────────────────────
828
+ /**
829
+ * Acquire a bot framework token for proactive messaging via the MSA/AAD endpoint.
830
+ * Required when posting to the Bot Connector REST API outside of a turn context.
831
+ */
832
+ async acquireBotToken() {
833
+ if (this._botTokenCache && Date.now() < this._botTokenCache.expiresAt - 60_000) {
834
+ return this._botTokenCache.token;
835
+ }
836
+ const appId = this.teamsConfig.botAppId;
837
+ const appPassword = this.teamsConfig.botAppPassword;
838
+ if (!appId || !appPassword)
839
+ return null;
840
+ try {
841
+ // Use configured tenantId for single-tenant bots; fall back to botframework.com for multi-tenant
842
+ const tenantForToken = this.teamsConfig.tenantId || "botframework.com";
843
+ const response = await fetch(`https://login.microsoftonline.com/${tenantForToken}/oauth2/v2.0/token`, {
844
+ method: "POST",
845
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
846
+ body: new URLSearchParams({
847
+ grant_type: "client_credentials",
848
+ client_id: appId,
849
+ client_secret: appPassword,
850
+ scope: "https://api.botframework.com/.default",
851
+ }).toString(),
852
+ });
853
+ if (!response.ok) {
854
+ log.warn({ status: response.status }, "[TeamsAdapter] Bot token acquisition failed");
855
+ return null;
856
+ }
857
+ const data = (await response.json());
858
+ this._botTokenCache = {
859
+ token: data.access_token,
860
+ expiresAt: Date.now() + data.expires_in * 1000,
861
+ };
862
+ return data.access_token;
863
+ }
864
+ catch (err) {
865
+ log.warn({ err }, "[TeamsAdapter] Bot token acquisition error");
866
+ return null;
867
+ }
868
+ }
869
+ // ─── Retry helper — matches Telegram's retryWithBackoff and 429 handling ──
870
+ /**
871
+ * Validate that a serviceUrl is a trusted Bot Framework endpoint.
872
+ * Prevents SSRF where a spoofed serviceUrl could redirect bot tokens.
873
+ *
874
+ * @see https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference
875
+ */
876
+ static TRUSTED_SERVICE_URL_PATTERNS = [
877
+ /^https:\/\/[\w.-]+\.botframework\.com\b/i,
878
+ /^https:\/\/[\w.-]+\.teams\.microsoft\.com\b/i,
879
+ /^https:\/\/smba\.trafficmanager\.net\b/i,
880
+ /^https:\/\/[\w.-]+\.botframework\.azure\.us\b/i,
881
+ // Allow localhost for development
882
+ /^https?:\/\/localhost(:\d+)?\b/,
883
+ /^https?:\/\/127\.0\.0\.1(:\d+)?\b/,
884
+ ];
885
+ static isValidServiceUrl(url) {
886
+ return TeamsAdapter.TRUSTED_SERVICE_URL_PATTERNS.some((pattern) => pattern.test(url));
887
+ }
888
+ /** AI-generated content entity — attached to all outbound messages for the Teams "AI generated" badge */
889
+ static AI_ENTITY = {
890
+ type: "https://schema.org/Message",
891
+ "@type": "Message",
892
+ "@context": "https://schema.org",
893
+ additionalType: ["AIGeneratedContent"],
894
+ };
895
+ /**
896
+ * Send a Teams activity with exponential backoff retry on transient failures.
897
+ * Handles HTTP 429 (rate limited), 502, 504 per Microsoft best practices.
898
+ */
899
+ async sendActivityWithRetry(context, activity) {
900
+ // Attach AI-generated content label to all message activities.
901
+ // Clone the activity to avoid mutating the caller's object (and duplicating on retries).
902
+ if (!activity.type || activity.type === "message") {
903
+ const existing = activity.entities ?? [];
904
+ // Skip if an AIGeneratedContent entity is already present (e.g., citation entities)
905
+ const hasAiLabel = existing.some((e) => Array.isArray(e?.additionalType) && e.additionalType.includes("AIGeneratedContent"));
906
+ if (!hasAiLabel) {
907
+ activity = { ...activity, entities: [...existing, TeamsAdapter.AI_ENTITY] };
908
+ }
909
+ }
910
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
911
+ try {
912
+ return await sendActivity(context, activity);
913
+ }
914
+ catch (err) {
915
+ const statusCode = err?.statusCode;
916
+ // Teams docs require retrying 412, 429, 502, and 504
917
+ const isRetryable = statusCode === 412 || statusCode === 429 || statusCode === 502 || statusCode === 504;
918
+ if (!isRetryable || attempt === MAX_RETRIES)
919
+ throw err;
920
+ // Parse Retry-After header if available, otherwise use exponential backoff + jitter
921
+ const retryAfterRaw = err?.headers?.["retry-after"];
922
+ const retryAfterSec = retryAfterRaw ? parseInt(retryAfterRaw, 10) : NaN;
923
+ const delayMs = !isNaN(retryAfterSec) && retryAfterSec > 0
924
+ ? retryAfterSec * 1000
925
+ : BASE_RETRY_DELAY * Math.pow(2, attempt) + Math.random() * 500;
926
+ log.warn({ statusCode, attempt: attempt + 1, delayMs }, "[TeamsAdapter] Rate limited or transient error, retrying");
927
+ await new Promise((r) => setTimeout(r, delayMs));
928
+ }
929
+ }
930
+ throw new Error("unreachable");
931
+ }
932
+ // ─── Helper: resolve context ─────────────────────────────────────────────
933
+ resolveMode(sessionId) {
934
+ // Check session-level override
935
+ const sessionMode = this._sessionOutputModes.get(sessionId);
936
+ if (sessionMode)
937
+ return sessionMode;
938
+ // Session record
939
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
940
+ if (record?.outputMode) {
941
+ const m = record.outputMode;
942
+ if (m === "low" || m === "medium" || m === "high")
943
+ return m;
944
+ }
945
+ // Adapter config — check teams-specific setting
946
+ const config = this.core.configManager.get();
947
+ const channels = config.channels;
948
+ const adapterMode = channels?.teams?.outputMode;
949
+ if (adapterMode === "low" || adapterMode === "medium" || adapterMode === "high") {
950
+ return adapterMode;
951
+ }
952
+ // Global
953
+ if (config.outputMode === "low" || config.outputMode === "medium" || config.outputMode === "high") {
954
+ return config.outputMode;
955
+ }
956
+ return "medium";
957
+ }
958
+ // ─── sendMessage ─────────────────────────────────────────────────────────
959
+ /**
960
+ * Primary outbound dispatch — routes agent messages to Teams.
961
+ *
962
+ * Wraps the base class `sendMessage` in a per-session promise chain (_dispatchQueues)
963
+ * so concurrent events fired from SessionBridge are serialized and delivered in order,
964
+ * preventing fast handlers from overtaking slower ones (matches Telegram pattern).
965
+ *
966
+ * Context is NOT deleted after dispatch — it persists from the inbound message handler
967
+ * and is available for the entire session lifetime, avoiding the race condition where
968
+ * async handlers lose their context mid-execution.
969
+ */
970
+ async sendMessage(sessionId, content) {
971
+ // Buffer messages during assistant initialization instead of dropping them
972
+ if (this.assistantInitializing &&
973
+ this.assistantSession &&
974
+ sessionId === this.assistantSession.id) {
975
+ if (this._assistantInitBuffer.length < TeamsAdapter.MAX_INIT_BUFFER) {
976
+ this._assistantInitBuffer.push({ sessionId, content });
977
+ }
978
+ else {
979
+ log.warn({ sessionId }, "[TeamsAdapter] Assistant init buffer full, dropping message");
980
+ }
981
+ return;
982
+ }
983
+ // Look up context by sessionId first, then by threadId (for newly-created sessions
984
+ // where context was stored under threadId before the session existed).
985
+ // Multiple fallback paths ensure the first response to a new session is not dropped.
986
+ let ctx = this._sessionContexts.get(sessionId);
987
+ if (!ctx) {
988
+ // Try in-memory session's threadId
989
+ const session = this.core.sessionManager.getSession(sessionId);
990
+ const threadId = session?.threadId;
991
+ if (threadId) {
992
+ ctx = this._sessionContexts.get(threadId);
993
+ }
994
+ // Try stored session record's threadId (covers async session creation)
995
+ if (!ctx) {
996
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
997
+ const recordThreadId = record?.platform?.threadId;
998
+ if (recordThreadId) {
999
+ ctx = this._sessionContexts.get(recordThreadId);
1000
+ }
1001
+ }
1002
+ // Promote to sessionId key for future lookups
1003
+ if (ctx)
1004
+ this._sessionContexts.set(sessionId, ctx);
1005
+ }
1006
+ if (!ctx) {
1007
+ // No live TurnContext — for terminal events (error, session_end), attempt
1008
+ // proactive delivery so the user isn't left without feedback
1009
+ if (content.type === "error" || content.type === "session_end") {
1010
+ log.warn({ sessionId, type: content.type }, "[TeamsAdapter] sendMessage: no context, attempting proactive delivery");
1011
+ const text = content.type === "error"
1012
+ ? `❌ **Error:** ${content.text}`
1013
+ : "✅ **Done**";
1014
+ await this.sendNotification({
1015
+ sessionId,
1016
+ type: content.type === "error" ? "error" : "completed",
1017
+ summary: text,
1018
+ });
1019
+ }
1020
+ else {
1021
+ log.warn({ sessionId, type: content.type }, "[TeamsAdapter] sendMessage: no context for session, skipping");
1022
+ }
1023
+ return;
1024
+ }
1025
+ // Serialize dispatch per session to preserve event ordering.
1026
+ // Read + write the queue entry atomically (synchronous) so concurrent callers
1027
+ // always chain on the latest promise, preventing parallel execution.
1028
+ const prev = this._dispatchQueues.get(sessionId) ?? Promise.resolve();
1029
+ const next = prev.then(async () => {
1030
+ try {
1031
+ await super.sendMessage(sessionId, content);
1032
+ }
1033
+ catch (err) {
1034
+ log.warn({ err, sessionId, type: content.type }, "[TeamsAdapter] Dispatch error");
1035
+ }
1036
+ });
1037
+ // Set immediately — before any await — so the next concurrent caller sees this entry
1038
+ this._dispatchQueues.set(sessionId, next);
1039
+ await next;
1040
+ // Replace settled chain with a fresh resolved promise to prevent unbounded
1041
+ // closure growth for long-lived sessions. Only replace if no new work was
1042
+ // chained while we were awaiting (i.e., the entry still points to `next`).
1043
+ if (this._dispatchQueues.get(sessionId) === next) {
1044
+ this._dispatchQueues.set(sessionId, Promise.resolve());
1045
+ }
1046
+ }
1047
+ // ─── Handler overrides ───────────────────────────────────────────────────
1048
+ async handleThought(sessionId, content, _verbosity) {
1049
+ // Thoughts are not sent as messages in Teams — buffered and displayed via plan
1050
+ void sessionId;
1051
+ void content;
1052
+ }
1053
+ async handleText(sessionId, content) {
1054
+ const ctx = this._sessionContexts.get(sessionId);
1055
+ if (!ctx)
1056
+ return;
1057
+ const { context } = ctx;
1058
+ // Send typing indicator on first text chunk (before draft exists)
1059
+ if (!this.draftManager.hasDraft(sessionId)) {
1060
+ this.sendTyping(context);
1061
+ }
1062
+ const draft = this.draftManager.getOrCreate(sessionId, context);
1063
+ if (content.text)
1064
+ draft.append(content.text);
1065
+ }
1066
+ async handleToolCall(sessionId, content, verbosity) {
1067
+ const ctx = this._sessionContexts.get(sessionId);
1068
+ if (!ctx)
1069
+ return;
1070
+ const { context, isAssistant } = ctx;
1071
+ this.sendTyping(context);
1072
+ await this.draftManager.finalize(sessionId, context, isAssistant);
1073
+ try {
1074
+ const meta = (content.metadata ?? {});
1075
+ const cardData = renderToolCallCard({
1076
+ id: meta.id ?? "",
1077
+ name: meta.name ?? content.text ?? "Tool",
1078
+ kind: meta.kind,
1079
+ status: meta.status,
1080
+ rawInput: meta.rawInput,
1081
+ content: meta.content,
1082
+ displaySummary: meta.displaySummary,
1083
+ displayTitle: meta.displayTitle,
1084
+ displayKind: meta.displayKind,
1085
+ viewerLinks: meta.viewerLinks,
1086
+ viewerFilePath: meta.viewerFilePath,
1087
+ }, verbosity);
1088
+ const card = { type: "AdaptiveCard", version: "1.2", ...cardData };
1089
+ // Build citation entities for file references (hover popup with source info)
1090
+ const citationSources = [];
1091
+ const filePath = meta.viewerFilePath;
1092
+ const links = meta.viewerLinks;
1093
+ if (filePath && links?.file) {
1094
+ citationSources.push({ name: filePath.split("/").pop() || filePath, url: links.file, abstract: `Source: ${filePath}` });
1095
+ }
1096
+ if (filePath && links?.diff) {
1097
+ citationSources.push({ name: `${filePath.split("/").pop() || filePath} (diff)`, url: links.diff, abstract: `Changes to ${filePath}` });
1098
+ }
1099
+ const citationEntities = buildCitationEntities(citationSources);
1100
+ // Citations require [N] markers in the text field — Teams ignores citation entities
1101
+ // on card-only activities with no text anchor. Add a text field with markers.
1102
+ const citationText = citationSources.length > 0
1103
+ ? citationSources.map((s, i) => `[${i + 1}]`).join(" ")
1104
+ : undefined;
1105
+ await this.sendActivityWithRetry(context, {
1106
+ ...(citationText ? { text: citationText } : {}),
1107
+ attachments: [CardFactory.adaptiveCard(card)],
1108
+ ...(citationEntities.length > 0 ? { entities: citationEntities } : {}),
1109
+ });
1110
+ }
1111
+ catch (err) {
1112
+ log.error({ err, sessionId }, "[TeamsAdapter] handleToolCall: sendActivity failed");
1113
+ }
1114
+ }
1115
+ async handleToolUpdate(sessionId, content, verbosity) {
1116
+ // Only render tool updates in high verbosity mode (matches Telegram's tracker behavior)
1117
+ if (verbosity !== "high")
1118
+ return;
1119
+ const ctx = this._sessionContexts.get(sessionId);
1120
+ if (!ctx)
1121
+ return;
1122
+ const { context } = ctx;
1123
+ try {
1124
+ const rendered = this.renderer.renderToolUpdate(content, verbosity);
1125
+ await this.sendActivityWithRetry(context, { text: rendered.body });
1126
+ }
1127
+ catch (err) {
1128
+ log.warn({ err, sessionId }, "[TeamsAdapter] handleToolUpdate: sendActivity failed");
1129
+ }
1130
+ }
1131
+ async handlePlan(sessionId, content, _verbosity) {
1132
+ const ctx = this._sessionContexts.get(sessionId);
1133
+ if (!ctx)
1134
+ return;
1135
+ const { context } = ctx;
1136
+ const entries = content.metadata?.entries ?? [];
1137
+ const mode = this.resolveMode(sessionId);
1138
+ const cardData = renderPlanCard(entries, mode);
1139
+ const card = { type: "AdaptiveCard", version: "1.2", ...cardData };
1140
+ try {
1141
+ await this.sendActivityWithRetry(context, { attachments: [CardFactory.adaptiveCard(card)] });
1142
+ }
1143
+ catch (err) {
1144
+ log.error({ err, sessionId }, "[TeamsAdapter] handlePlan: sendActivity failed");
1145
+ }
1146
+ }
1147
+ async handleUsage(sessionId, content, _verbosity) {
1148
+ const ctx = this._sessionContexts.get(sessionId);
1149
+ if (!ctx)
1150
+ return;
1151
+ const { context, isAssistant } = ctx;
1152
+ await this.draftManager.finalize(sessionId, context, isAssistant);
1153
+ const meta = content.metadata;
1154
+ const mode = this.resolveMode(sessionId);
1155
+ const { body } = renderUsageCard(meta ?? {}, mode);
1156
+ const card = { type: "AdaptiveCard", version: "1.2", body };
1157
+ try {
1158
+ // Feedback buttons on usage/completion messages — Teams shows thumbs up/down
1159
+ await this.sendActivityWithRetry(context, {
1160
+ attachments: [CardFactory.adaptiveCard(card)],
1161
+ channelData: { feedbackLoop: { type: "default" } },
1162
+ });
1163
+ }
1164
+ catch (err) {
1165
+ log.error({ err, sessionId }, "[TeamsAdapter] handleUsage: sendActivity failed");
1166
+ }
1167
+ // Notify completion in notification channel (matches Telegram's notification pattern)
1168
+ if (this.notificationChannelId && sessionId !== this.assistantSession?.id) {
1169
+ const sess = this.core.sessionManager.getSession(sessionId);
1170
+ const name = sess?.name || "Session";
1171
+ void this.sendNotification({
1172
+ sessionId,
1173
+ sessionName: name,
1174
+ type: "completed",
1175
+ summary: "Task completed",
1176
+ });
1177
+ }
1178
+ }
1179
+ /** Suggested quick-reply actions (Teams restricts these to 1:1 personal chat only) */
1180
+ static QUICK_ACTIONS = {
1181
+ suggestedActions: {
1182
+ actions: [
1183
+ { type: "imBack", title: "➕ New Session", value: "/new" },
1184
+ { type: "imBack", title: "📊 Status", value: "/status" },
1185
+ { type: "imBack", title: "📋 Sessions", value: "/sessions" },
1186
+ { type: "imBack", title: "📋 Menu", value: "/menu" },
1187
+ ],
1188
+ },
1189
+ };
1190
+ /** Return QUICK_ACTIONS only if the conversation is 1:1 personal chat (Teams requirement) */
1191
+ getQuickActions(context) {
1192
+ const convType = context.activity.conversation;
1193
+ if (convType?.conversationType === "personal") {
1194
+ return TeamsAdapter.QUICK_ACTIONS;
1195
+ }
1196
+ return {};
1197
+ }
1198
+ /**
1199
+ * Clean up all per-session state (contexts, drafts, dispatch queues, output modes).
1200
+ * Removes both sessionId and threadId entries from _sessionContexts to prevent leaks.
1201
+ */
1202
+ cleanupSessionState(sessionId) {
1203
+ // Find and remove the threadId entry that may also reference this session's context.
1204
+ // First try the stored threadId on the context entry itself (reliable even if the
1205
+ // session has already been removed from the session manager).
1206
+ const entry = this._sessionContexts.get(sessionId);
1207
+ const storedThreadId = entry?.threadId;
1208
+ if (storedThreadId && storedThreadId !== sessionId) {
1209
+ this._sessionContexts.delete(storedThreadId);
1210
+ }
1211
+ // Fallback: also check session manager and session record in case the context entry
1212
+ // was already removed or the threadId wasn't stored.
1213
+ const session = this.core.sessionManager.getSession(sessionId);
1214
+ const threadId = session?.threadId;
1215
+ if (threadId && threadId !== sessionId && threadId !== storedThreadId) {
1216
+ this._sessionContexts.delete(threadId);
1217
+ }
1218
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
1219
+ const recordThreadId = record?.platform?.threadId;
1220
+ if (recordThreadId && recordThreadId !== sessionId && recordThreadId !== threadId && recordThreadId !== storedThreadId) {
1221
+ this._sessionContexts.delete(recordThreadId);
1222
+ }
1223
+ this._sessionContexts.delete(sessionId);
1224
+ this._sessionOutputModes.delete(sessionId);
1225
+ this._dispatchQueues.delete(sessionId);
1226
+ this.draftManager.cleanup(sessionId);
1227
+ }
1228
+ async handleSessionEnd(sessionId, _content) {
1229
+ const ctx = this._sessionContexts.get(sessionId);
1230
+ if (!ctx)
1231
+ return;
1232
+ const { context, isAssistant } = ctx;
1233
+ await this.draftManager.finalize(sessionId, context, isAssistant);
1234
+ this.cleanupSessionState(sessionId);
1235
+ try {
1236
+ await this.sendActivityWithRetry(context, {
1237
+ text: "✅ **Done**",
1238
+ channelData: { feedbackLoop: { type: "default" } },
1239
+ ...this.getQuickActions(context),
1240
+ });
1241
+ }
1242
+ catch { /* best effort */ }
1243
+ }
1244
+ async handleError(sessionId, content) {
1245
+ const ctx = this._sessionContexts.get(sessionId);
1246
+ if (!ctx)
1247
+ return;
1248
+ const { context, isAssistant } = ctx;
1249
+ await this.draftManager.finalize(sessionId, context, isAssistant);
1250
+ this.cleanupSessionState(sessionId);
1251
+ try {
1252
+ await this.sendActivityWithRetry(context, {
1253
+ text: `❌ **Error:** ${content.text}`,
1254
+ ...this.getQuickActions(context),
1255
+ });
1256
+ }
1257
+ catch { /* best effort */ }
1258
+ }
1259
+ async handleAttachment(sessionId, content) {
1260
+ if (!content.attachment)
1261
+ return;
1262
+ const { attachment } = content;
1263
+ const ctx = this._sessionContexts.get(sessionId);
1264
+ if (!ctx)
1265
+ return;
1266
+ const { context, isAssistant } = ctx;
1267
+ // Strip TTS markers from the draft BEFORE finalizing — finalize() deletes
1268
+ // the draft from the map, so getDraft() would return undefined after it.
1269
+ if (attachment.type === "audio") {
1270
+ const draft = this.draftManager.getDraft(sessionId);
1271
+ if (draft) {
1272
+ await draft.stripPattern(/\[TTS\][\s\S]*?\[\/TTS\]/g).catch((err) => {
1273
+ log.warn({ err, sessionId }, "[TeamsAdapter] handleAttachment: stripPattern failed");
1274
+ });
1275
+ }
1276
+ }
1277
+ await this.draftManager.finalize(sessionId, context, isAssistant);
1278
+ if (isAttachmentTooLarge(attachment.size)) {
1279
+ log.warn({ sessionId, fileName: attachment.fileName, size: attachment.size }, "[TeamsAdapter] File too large");
1280
+ try {
1281
+ await this.sendActivityWithRetry(context, {
1282
+ text: `⚠️ File too large to send (${Math.round(attachment.size / 1024 / 1024)}MB): ${attachment.fileName}`,
1283
+ });
1284
+ }
1285
+ catch { /* best effort */ }
1286
+ return;
1287
+ }
1288
+ try {
1289
+ // Upload to OneDrive via Graph API if available, get a sharing URL
1290
+ const shareUrl = await uploadFileViaGraph(this.graphClient, sessionId, attachment.filePath, attachment.fileName, attachment.mimeType);
1291
+ const card = buildFileAttachmentCard(attachment.fileName, attachment.size, attachment.mimeType, shareUrl ?? undefined);
1292
+ await this.sendActivityWithRetry(context, { attachments: [CardFactory.adaptiveCard(card)] });
1293
+ }
1294
+ catch (err) {
1295
+ log.error({ err, sessionId, fileName: attachment.fileName }, "[TeamsAdapter] Failed to send attachment");
1296
+ }
1297
+ }
1298
+ async handleSystem(sessionId, content) {
1299
+ if (!content.text)
1300
+ return;
1301
+ const ctx = this._sessionContexts.get(sessionId);
1302
+ if (!ctx)
1303
+ return;
1304
+ const { context } = ctx;
1305
+ try {
1306
+ await this.sendActivityWithRetry(context, { text: content.text });
1307
+ }
1308
+ catch { /* best effort */ }
1309
+ }
1310
+ async handleModeChange(sessionId, content) {
1311
+ const ctx = this._sessionContexts.get(sessionId);
1312
+ if (!ctx)
1313
+ return;
1314
+ const renderer = this.renderer;
1315
+ const rendered = renderer.renderModeChange(content);
1316
+ try {
1317
+ await this.sendActivityWithRetry(ctx.context, { text: rendered.body });
1318
+ }
1319
+ catch { /* best effort */ }
1320
+ }
1321
+ async handleConfigUpdate(sessionId, content) {
1322
+ const ctx = this._sessionContexts.get(sessionId);
1323
+ if (!ctx)
1324
+ return;
1325
+ const renderer = this.renderer;
1326
+ const rendered = renderer.renderConfigUpdate(content);
1327
+ try {
1328
+ await this.sendActivityWithRetry(ctx.context, { text: rendered.body });
1329
+ }
1330
+ catch { /* best effort */ }
1331
+ }
1332
+ async handleModelUpdate(sessionId, content) {
1333
+ const ctx = this._sessionContexts.get(sessionId);
1334
+ if (!ctx)
1335
+ return;
1336
+ const renderer = this.renderer;
1337
+ const rendered = renderer.renderModelUpdate(content);
1338
+ try {
1339
+ await this.sendActivityWithRetry(ctx.context, { text: rendered.body });
1340
+ }
1341
+ catch { /* best effort */ }
1342
+ }
1343
+ async handleUserReplay(sessionId, content) {
1344
+ if (!content.text)
1345
+ return;
1346
+ const ctx = this._sessionContexts.get(sessionId);
1347
+ if (!ctx)
1348
+ return;
1349
+ try {
1350
+ await this.sendActivityWithRetry(ctx.context, { text: content.text });
1351
+ }
1352
+ catch { /* best effort */ }
1353
+ }
1354
+ async handleResource(sessionId, content) {
1355
+ if (!content.text)
1356
+ return;
1357
+ const ctx = this._sessionContexts.get(sessionId);
1358
+ if (!ctx)
1359
+ return;
1360
+ try {
1361
+ await this.sendActivityWithRetry(ctx.context, { text: content.text });
1362
+ }
1363
+ catch { /* best effort */ }
1364
+ }
1365
+ async handleResourceLink(sessionId, content) {
1366
+ const ctx = this._sessionContexts.get(sessionId);
1367
+ if (!ctx)
1368
+ return;
1369
+ const rawUrl = content.metadata?.url;
1370
+ const rawName = content.metadata?.name;
1371
+ // Only allow http/https URLs to prevent javascript: or data: scheme injection
1372
+ const url = rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined;
1373
+ // Sanitize name to prevent markdown injection — strip characters that break link syntax
1374
+ const name = rawName?.replace(/[\[\]\(\)]/g, "") || undefined;
1375
+ const text = url ? `📎 [${name || url}](${url})` : content.text;
1376
+ try {
1377
+ await this.sendActivityWithRetry(ctx.context, { text });
1378
+ }
1379
+ catch { /* best effort */ }
1380
+ }
1381
+ // ─── sendPermissionRequest ──────────────────────────────────────────────
1382
+ async sendPermissionRequest(sessionId, request) {
1383
+ const session = this.core.sessionManager.getSession(sessionId);
1384
+ if (!session) {
1385
+ log.warn({ sessionId }, "[TeamsAdapter] sendPermissionRequest: session not found");
1386
+ return;
1387
+ }
1388
+ const ctx = this._sessionContexts.get(sessionId);
1389
+ if (!ctx) {
1390
+ log.warn({ sessionId }, "[TeamsAdapter] sendPermissionRequest: no context");
1391
+ return;
1392
+ }
1393
+ await this.permissionHandler.sendPermissionRequest(session, request, ctx.context);
1394
+ }
1395
+ // ─── sendNotification ──────────────────────────────────────────────────
1396
+ async sendNotification(notification) {
1397
+ const typeIcon = {
1398
+ completed: "✅", error: "❌", permission: "🔐", input_required: "💬", budget_warning: "⚠️",
1399
+ };
1400
+ const icon = typeIcon[notification.type] ?? "ℹ️";
1401
+ const name = notification.sessionName ? ` **${notification.sessionName}**` : "";
1402
+ let text = `${icon}${name}: ${notification.summary}`;
1403
+ if (notification.deepLink) {
1404
+ text += `\n${notification.deepLink}`;
1405
+ }
1406
+ // Proactive messaging via stored conversation reference + bot token.
1407
+ // We do NOT use a stored TurnContext here — TurnContexts are scoped to a
1408
+ // single HTTP request/response cycle and go stale after the turn ends.
1409
+ if (this.notificationChannelId) {
1410
+ const ref = this.conversationStore.get(this.notificationChannelId) ?? this.conversationStore.getAny();
1411
+ if (ref && TeamsAdapter.isValidServiceUrl(ref.serviceUrl)) {
1412
+ try {
1413
+ const botToken = await this.acquireBotToken();
1414
+ if (botToken) {
1415
+ const controller = new AbortController();
1416
+ const timeout = setTimeout(() => controller.abort(), 10_000);
1417
+ try {
1418
+ const response = await fetch(`${ref.serviceUrl}/v3/conversations/${ref.conversationId}/activities`, {
1419
+ method: "POST",
1420
+ headers: {
1421
+ "Content-Type": "application/json",
1422
+ "Authorization": `Bearer ${botToken}`,
1423
+ },
1424
+ body: JSON.stringify({
1425
+ type: "message",
1426
+ text,
1427
+ from: { id: ref.botId, name: ref.botName },
1428
+ }),
1429
+ signal: controller.signal,
1430
+ });
1431
+ if (response.ok)
1432
+ return;
1433
+ log.warn({ status: response.status }, "[TeamsAdapter] Proactive notification failed");
1434
+ }
1435
+ finally {
1436
+ clearTimeout(timeout);
1437
+ }
1438
+ }
1439
+ }
1440
+ catch (err) {
1441
+ log.warn({ err }, "[TeamsAdapter] Proactive notification error");
1442
+ }
1443
+ }
1444
+ }
1445
+ // Session-specific context fallback — last resort, may fail if the
1446
+ // TurnContext's HTTP response stream has closed since the turn ended.
1447
+ if (notification.sessionId) {
1448
+ const ctx = this._sessionContexts.get(notification.sessionId);
1449
+ if (ctx) {
1450
+ try {
1451
+ await sendText(ctx.context, text);
1452
+ return;
1453
+ }
1454
+ catch (err) {
1455
+ log.debug({ err, sessionId: notification.sessionId }, "[TeamsAdapter] Session context fallback failed (context may be stale)");
1456
+ }
1457
+ }
1458
+ }
1459
+ log.debug({ type: notification.type, sessionName: notification.sessionName }, "[TeamsAdapter] sendNotification: no delivery path available");
1460
+ }
1461
+ // ─── createSessionThread ─────────────────────────────────────────────────
1462
+ /**
1463
+ * Create a new conversation thread for a session.
1464
+ *
1465
+ * Attempts to create a real Teams channel conversation via the Bot Framework
1466
+ * connector API. If that fails (e.g., missing permissions, no stored conversation
1467
+ * reference), falls back to using the existing conversation ID as thread context.
1468
+ */
1469
+ async createSessionThread(sessionId, name) {
1470
+ // Try to create a real Teams conversation thread
1471
+ const ref = this.conversationStore.getAny();
1472
+ if (ref && TeamsAdapter.isValidServiceUrl(ref.serviceUrl)) {
1473
+ try {
1474
+ const botToken = await this.acquireBotToken();
1475
+ if (!botToken)
1476
+ throw new Error("No bot token available");
1477
+ const createUrl = `${ref.serviceUrl}/v3/conversations`;
1478
+ const response = await fetch(createUrl, {
1479
+ method: "POST",
1480
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${botToken}` },
1481
+ body: JSON.stringify({
1482
+ isGroup: false,
1483
+ bot: { id: ref.botId, name: ref.botName },
1484
+ tenantId: ref.tenantId,
1485
+ activity: {
1486
+ type: "message",
1487
+ text: `**${name.replace(/[*_~`[\]()\\]/g, "")}** — New session started`,
1488
+ },
1489
+ channelData: {
1490
+ channel: { id: this.teamsConfig.channelId },
1491
+ tenant: { id: ref.tenantId },
1492
+ },
1493
+ }),
1494
+ });
1495
+ if (response.ok) {
1496
+ const data = (await response.json());
1497
+ const threadId = data.id;
1498
+ const session = this.core.sessionManager.getSession(sessionId);
1499
+ if (session)
1500
+ session.threadId = threadId;
1501
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
1502
+ if (record) {
1503
+ await this.core.sessionManager.patchRecord(sessionId, {
1504
+ platform: { ...record.platform, threadId },
1505
+ });
1506
+ }
1507
+ log.info({ sessionId, threadId, name }, "[TeamsAdapter] Created real Teams conversation thread");
1508
+ return threadId;
1509
+ }
1510
+ log.warn({ status: response.status }, "[TeamsAdapter] createConversation failed, using fallback");
1511
+ }
1512
+ catch (err) {
1513
+ log.warn({ err }, "[TeamsAdapter] createConversation error, using fallback");
1514
+ }
1515
+ }
1516
+ // Fallback: use the configured channel as the conversation context
1517
+ const threadId = this.teamsConfig.channelId || `teams-${sessionId}-${Date.now()}`;
1518
+ const session = this.core.sessionManager.getSession(sessionId);
1519
+ if (session)
1520
+ session.threadId = threadId;
1521
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
1522
+ if (record) {
1523
+ await this.core.sessionManager.patchRecord(sessionId, {
1524
+ platform: { ...record.platform, threadId },
1525
+ });
1526
+ }
1527
+ return threadId;
1528
+ }
1529
+ /**
1530
+ * Rename a session thread. This is a no-op for Teams — the Teams API does not
1531
+ * support renaming channel conversations. Renaming a group chat requires
1532
+ * Graph API with Chat.ReadWrite.All permission, which most bot registrations
1533
+ * don't have. The new name is stored in the session record for display purposes.
1534
+ */
1535
+ async renameSessionThread(sessionId, newName) {
1536
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
1537
+ if (!record)
1538
+ return;
1539
+ // Persist the name in the session record even though Teams can't be updated
1540
+ try {
1541
+ await this.core.sessionManager.patchRecord(sessionId, {
1542
+ platform: { ...record.platform, displayName: newName },
1543
+ });
1544
+ }
1545
+ catch { /* best effort */ }
1546
+ log.debug({ sessionId, newName }, "[TeamsAdapter] renameSessionThread — name stored locally (Teams API does not support conversation rename)");
1547
+ }
1548
+ async deleteSessionThread(sessionId) {
1549
+ const record = this.core.sessionManager.getSessionRecord(sessionId);
1550
+ if (!record)
1551
+ return;
1552
+ const threadId = record.platform?.threadId;
1553
+ if (!threadId)
1554
+ return;
1555
+ // Clean up local state — actual Teams conversation deletion requires Graph API
1556
+ try {
1557
+ await this.core.sessionManager.patchRecord(sessionId, {
1558
+ platform: { ...record.platform, threadId: undefined },
1559
+ });
1560
+ }
1561
+ catch (err) {
1562
+ log.warn({ err, sessionId }, "[TeamsAdapter] deleteSessionThread: failed to clear threadId");
1563
+ }
1564
+ log.info({ sessionId, threadId }, "[TeamsAdapter] deleteSessionThread — cleanup done, Graph API delete not called");
1565
+ }
1566
+ // ─── Public helpers ─────────────────────────────────────────────────────
1567
+ getChannelId() {
1568
+ return this.teamsConfig.channelId;
1569
+ }
1570
+ getTeamId() {
1571
+ return this.teamsConfig.teamId;
1572
+ }
1573
+ getAssistantSessionId() {
1574
+ return this.assistantSession?.id ?? null;
1575
+ }
1576
+ getAssistantThreadId() {
1577
+ return this.teamsConfig.assistantThreadId ?? null;
1578
+ }
1579
+ setSessionOutputMode(sessionId, mode) {
1580
+ this._sessionOutputModes.set(sessionId, mode);
1581
+ }
1582
+ }
1583
+ //# sourceMappingURL=adapter.js.map