@copilotkit/channels-teams 0.1.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/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/adapter.d.ts +133 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +539 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +202 -0
- package/dist/conversation-store.d.ts +36 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +78 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +72 -0
- package/dist/download-files.d.ts +62 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +190 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +96 -0
- package/dist/event-renderer.d.ts +23 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +135 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +95 -0
- package/dist/graph-files.d.ts +50 -0
- package/dist/graph-files.d.ts.map +1 -0
- package/dist/graph-files.js +126 -0
- package/dist/graph-files.test.d.ts +2 -0
- package/dist/graph-files.test.d.ts.map +1 -0
- package/dist/graph-files.test.js +114 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interaction.d.ts +36 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +35 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +43 -0
- package/dist/listener.d.ts +26 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +55 -0
- package/dist/listener.test.d.ts +2 -0
- package/dist/listener.test.d.ts.map +1 -0
- package/dist/listener.test.js +43 -0
- package/dist/message-stream.d.ts +52 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +78 -0
- package/dist/message-stream.test.d.ts +2 -0
- package/dist/message-stream.test.d.ts.map +1 -0
- package/dist/message-stream.test.js +44 -0
- package/dist/render/adaptive-card.d.ts +43 -0
- package/dist/render/adaptive-card.d.ts.map +1 -0
- package/dist/render/adaptive-card.js +401 -0
- package/dist/render/adaptive-card.test.d.ts +2 -0
- package/dist/render/adaptive-card.test.d.ts.map +1 -0
- package/dist/render/adaptive-card.test.js +240 -0
- package/dist/render/auto-close.d.ts +27 -0
- package/dist/render/auto-close.d.ts.map +1 -0
- package/dist/render/auto-close.js +153 -0
- package/dist/render/auto-close.test.d.ts +2 -0
- package/dist/render/auto-close.test.d.ts.map +1 -0
- package/dist/render/auto-close.test.js +46 -0
- package/dist/render/budget.d.ts +38 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +44 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +25 -0
- package/dist/render/markdown.d.ts +13 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +121 -0
- package/dist/render/markdown.test.d.ts +2 -0
- package/dist/render/markdown.test.d.ts.map +1 -0
- package/dist/render/markdown.test.js +58 -0
- package/dist/sanitizing-http-agent.d.ts +35 -0
- package/dist/sanitizing-http-agent.d.ts.map +1 -0
- package/dist/sanitizing-http-agent.js +58 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { CloudAdapter, CardFactory, MessageFactory, } from "@microsoft/agents-hosting";
|
|
2
|
+
import { ActivityTypes, Activity } from "@microsoft/agents-activity";
|
|
3
|
+
import { TeamsConversationStore } from "./conversation-store.js";
|
|
4
|
+
import { buildFileContentParts } from "./download-files.js";
|
|
5
|
+
import { buildChannelFileContentParts } from "./graph-files.js";
|
|
6
|
+
import { createTeamsServer } from "./listener.js";
|
|
7
|
+
import { conversationKeyOf, parseCardAction } from "./interaction.js";
|
|
8
|
+
import { createRunRenderer } from "./event-renderer.js";
|
|
9
|
+
import { renderTeamsMarkdown } from "./render/markdown.js";
|
|
10
|
+
import { renderAdaptiveCard, isPlainText } from "./render/adaptive-card.js";
|
|
11
|
+
import { TeamsMessageStream } from "./message-stream.js";
|
|
12
|
+
/**
|
|
13
|
+
* Microsoft Teams `PlatformAdapter`.
|
|
14
|
+
*
|
|
15
|
+
* Ingress: a `CloudAdapter` receives Teams activities at `POST /api/messages`
|
|
16
|
+
* (M365 Agents SDK). The inbound HTTP turn is **acked immediately**; the agent
|
|
17
|
+
* run is handed off to a detached `continueConversation` so it can outlive the
|
|
18
|
+
* turn. This is required for HITL, where `awaitChoice` suspends the run until a user
|
|
19
|
+
* clicks an Adaptive Card button (possibly minutes later). That detached
|
|
20
|
+
* `continueConversation` provides a stable `TurnContext` the whole run streams
|
|
21
|
+
* on, exactly as the Slack adapter runs in the background off a web client.
|
|
22
|
+
*
|
|
23
|
+
* Adaptive Card `Action.Submit` clicks arrive as Message activities carrying
|
|
24
|
+
* the action `data` in `activity.value`; those are decoded and routed to
|
|
25
|
+
* `sink.onInteraction` to resolve the waiter. This path needs no Microsoft
|
|
26
|
+
* credentials in the local M365 Agents Playground.
|
|
27
|
+
*/
|
|
28
|
+
export class TeamsAdapter {
|
|
29
|
+
opts;
|
|
30
|
+
platform = "teams";
|
|
31
|
+
capabilities;
|
|
32
|
+
// Teams keeps the inbound HTTP turn open while the bot works; ~15s is the
|
|
33
|
+
// practical channel window. Declarative today (the engine doesn't enforce it).
|
|
34
|
+
ackDeadlineMs = 15000;
|
|
35
|
+
store = new TeamsConversationStore();
|
|
36
|
+
cloud;
|
|
37
|
+
server;
|
|
38
|
+
sink;
|
|
39
|
+
constructor(opts = {}) {
|
|
40
|
+
this.opts = opts;
|
|
41
|
+
this.capabilities = {
|
|
42
|
+
supportsModals: false,
|
|
43
|
+
supportsTyping: true,
|
|
44
|
+
supportsReactions: false,
|
|
45
|
+
// Streamed by message edit (post-then-updateActivity), not native
|
|
46
|
+
// token-by-token streaming, but the engine's streaming path is honored.
|
|
47
|
+
supportsStreaming: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async start(sink) {
|
|
51
|
+
this.sink = sink;
|
|
52
|
+
const authConfig = {
|
|
53
|
+
clientId: this.opts.clientId ?? process.env.clientId,
|
|
54
|
+
clientSecret: this.opts.clientSecret ?? process.env.clientSecret,
|
|
55
|
+
tenantId: this.opts.tenantId ?? process.env.tenantId,
|
|
56
|
+
};
|
|
57
|
+
this.cloud = new CloudAdapter(authConfig);
|
|
58
|
+
// Contain turn-handler failures at the SDK boundary. Without this the M365
|
|
59
|
+
// adapter rethrows (e.g. a Bot Connector 401 surfaces as "Unknown error
|
|
60
|
+
// type"), which becomes an unhandled rejection and crashes the process,
|
|
61
|
+
// turning one bad turn into a service-wide outage + restart loop.
|
|
62
|
+
this.cloud.onTurnError = async (_context, error) => {
|
|
63
|
+
console.error("[bot-teams] turn error:", error);
|
|
64
|
+
};
|
|
65
|
+
this.server = createTeamsServer({
|
|
66
|
+
adapter: this.cloud,
|
|
67
|
+
port: this.opts.port ?? 3978,
|
|
68
|
+
onTurnContext: (context) => this.handleActivity(context, sink),
|
|
69
|
+
});
|
|
70
|
+
await this.server.start();
|
|
71
|
+
}
|
|
72
|
+
async stop() {
|
|
73
|
+
await this.server?.stop();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Normalize an inbound activity, ack the HTTP turn immediately, and drive the
|
|
77
|
+
* work into the engine on a **detached** `continueConversation` so it can
|
|
78
|
+
* outlive this turn (HITL suspends the run until a later click).
|
|
79
|
+
*/
|
|
80
|
+
async handleActivity(context, sink) {
|
|
81
|
+
const activity = context.activity;
|
|
82
|
+
if (activity.type !== ActivityTypes.Message)
|
|
83
|
+
return;
|
|
84
|
+
const conversationKey = conversationKeyOf(activity);
|
|
85
|
+
const reference = activity.getConversationReference();
|
|
86
|
+
const from = activity.from;
|
|
87
|
+
const user = from?.id
|
|
88
|
+
? { id: from.id, name: from.name }
|
|
89
|
+
: undefined;
|
|
90
|
+
// An Adaptive Card `Action.Submit` arrives as a Message activity carrying
|
|
91
|
+
// our action `data` in `value` (and no user text). Route it as an
|
|
92
|
+
// interaction so the engine resolves the matching `awaitChoice` waiter and
|
|
93
|
+
// runs the button's `onClick` (which edits the picker card in place).
|
|
94
|
+
const action = parseCardAction(activity);
|
|
95
|
+
if (action) {
|
|
96
|
+
const onInteraction = (replyContext) => Promise.resolve(sink.onInteraction({
|
|
97
|
+
id: action.id,
|
|
98
|
+
conversationKey,
|
|
99
|
+
value: action.value,
|
|
100
|
+
user,
|
|
101
|
+
replyTarget: {
|
|
102
|
+
conversationKey,
|
|
103
|
+
reference,
|
|
104
|
+
context: replyContext,
|
|
105
|
+
},
|
|
106
|
+
messageRef: {
|
|
107
|
+
id: activity.replyToId ?? "",
|
|
108
|
+
conversationKey,
|
|
109
|
+
reference,
|
|
110
|
+
context: replyContext,
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
if (this.canGoProactive()) {
|
|
114
|
+
// Credentialed (real Teams): the inbound card-click turn's connector
|
|
115
|
+
// client is created with an anonymous identity, so editing the card in
|
|
116
|
+
// place (`updateActivity`, a PUT to the Connector) is rejected 401.
|
|
117
|
+
// Run the interaction on a detached, app-id-authenticated proactive
|
|
118
|
+
// context (exactly like an ordinary turn) and ack the click now.
|
|
119
|
+
this.runDetached(reference, onInteraction);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Anonymous local Playground: the inbound turn context is the only one
|
|
123
|
+
// available (no app id for `continueConversation`) and works there.
|
|
124
|
+
try {
|
|
125
|
+
await onInteraction(context);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
console.error("[bot-teams] interaction failed:", err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Ordinary chat message. Strip any `<at>bot</at>` mention (channel scope).
|
|
134
|
+
let text = "";
|
|
135
|
+
try {
|
|
136
|
+
text = (activity.removeRecipientMention() ?? activity.text ?? "").trim();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
text = (activity.text ?? "").trim();
|
|
140
|
+
}
|
|
141
|
+
// Uploaded files (e.g. a CSV the user wants charted) ride along as
|
|
142
|
+
// attachments. Download them and hand the model multimodal content parts —
|
|
143
|
+
// the "upload a CSV → get a chart" payoff. Done inside `drive` (not before
|
|
144
|
+
// the ack) so a slow download never blocks the inbound HTTP turn, and so
|
|
145
|
+
// the transcript records the file's CONTENT (not just the text), letting a
|
|
146
|
+
// follow-up turn ("now make it a bar chart") still act on the data.
|
|
147
|
+
const drive = async (target) => {
|
|
148
|
+
// Keep "…is typing" visible for the whole turn. Teams' indicator expires
|
|
149
|
+
// after a few seconds, and slow work (downloading a file, rendering a
|
|
150
|
+
// chart) posts nothing in the meantime, so a one-shot ping leaves dead
|
|
151
|
+
// air. Heartbeat until the run resolves.
|
|
152
|
+
const stopTyping = this.startTypingHeartbeat(target);
|
|
153
|
+
try {
|
|
154
|
+
const { parts, notes } = await this.collectInboundFileParts(activity);
|
|
155
|
+
// Build contentParts only when we actually got file content (plus any
|
|
156
|
+
// partial-failure notes). A pure failure with no parts is logged but
|
|
157
|
+
// not surfaced, so routine no-file channel messages aren't polluted
|
|
158
|
+
// with "couldn't read file" noise.
|
|
159
|
+
let contentParts;
|
|
160
|
+
if (parts.length > 0) {
|
|
161
|
+
contentParts = [
|
|
162
|
+
...(text ? [{ type: "text", text }] : []),
|
|
163
|
+
...parts,
|
|
164
|
+
...(notes.length
|
|
165
|
+
? [
|
|
166
|
+
{
|
|
167
|
+
type: "text",
|
|
168
|
+
text: `[attachment notes: ${notes.join("; ")}]`,
|
|
169
|
+
},
|
|
170
|
+
]
|
|
171
|
+
: []),
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
// Record the incoming message into the transcript so it (a) reaches the
|
|
175
|
+
// model this turn via the seeded history and (b) persists for later
|
|
176
|
+
// turns. Store the multimodal content when present (so an uploaded CSV
|
|
177
|
+
// is remembered), else the plain text.
|
|
178
|
+
this.store.recordUser(conversationKey, contentParts ?? text);
|
|
179
|
+
await sink.onTurn({
|
|
180
|
+
conversationKey,
|
|
181
|
+
replyTarget: target,
|
|
182
|
+
userText: text,
|
|
183
|
+
user,
|
|
184
|
+
platform: this.platform,
|
|
185
|
+
contentParts,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
stopTyping();
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
if (this.canGoProactive()) {
|
|
193
|
+
// Credentialed (real Teams): ack the turn now and run on a detached
|
|
194
|
+
// proactive context so HITL's `awaitChoice` can suspend the run for
|
|
195
|
+
// minutes without holding the HTTP turn open.
|
|
196
|
+
this.runDetached(reference, (proactive) => drive({ conversationKey, reference, context: proactive }));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
// Anonymous/local (M365 Playground): `continueConversation` needs an app
|
|
200
|
+
// id we don't have, so run on the inbound turn context. The localhost
|
|
201
|
+
// connection stays open across an `awaitChoice` suspend until the click.
|
|
202
|
+
try {
|
|
203
|
+
await drive({ conversationKey, reference, context });
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error("[bot-teams] in-turn run failed:", err);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Resolve a message's uploaded files into AG-UI content parts. Personal chats
|
|
212
|
+
* deliver the file inline (the Teams bot file API), which `buildFileContentParts`
|
|
213
|
+
* handles; a channel doesn't include the file at all, so we fetch it through
|
|
214
|
+
* Microsoft Graph when credentials are configured (group chats aren't wired
|
|
215
|
+
* up yet). Failures are logged, never thrown.
|
|
216
|
+
*/
|
|
217
|
+
async collectInboundFileParts(activity) {
|
|
218
|
+
const attachments = (activity.attachments ?? []);
|
|
219
|
+
const convType = activity.conversation?.conversationType;
|
|
220
|
+
// Inline path (personal chat, or any direct file/media attachment).
|
|
221
|
+
if (attachments.length > 0) {
|
|
222
|
+
const result = await buildFileContentParts(attachments, this.opts.files);
|
|
223
|
+
this.logFileParts("attachment", result);
|
|
224
|
+
if (result.parts.length > 0)
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
// Channel path: the file lives in SharePoint, reachable only via Graph.
|
|
228
|
+
if (convType === "channel") {
|
|
229
|
+
const creds = this.graphCredentials();
|
|
230
|
+
const ref = this.channelMessageRef(activity);
|
|
231
|
+
if (creds && ref) {
|
|
232
|
+
const result = await buildChannelFileContentParts(ref, creds, this.opts.files);
|
|
233
|
+
this.logFileParts("graph", result);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { parts: [], notes: [] };
|
|
238
|
+
}
|
|
239
|
+
/** App-only Graph credentials, when all three are configured. */
|
|
240
|
+
graphCredentials() {
|
|
241
|
+
const clientId = this.opts.clientId ?? process.env.clientId;
|
|
242
|
+
const clientSecret = this.opts.clientSecret ?? process.env.clientSecret;
|
|
243
|
+
const tenantId = this.opts.tenantId ?? process.env.tenantId;
|
|
244
|
+
if (clientId && clientSecret && tenantId) {
|
|
245
|
+
return { clientId, clientSecret, tenantId };
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/** Pull the team/channel/message ids Graph needs out of a channel activity. */
|
|
250
|
+
channelMessageRef(activity) {
|
|
251
|
+
const cd = activity.channelData;
|
|
252
|
+
const teamId = cd?.team?.aadGroupId;
|
|
253
|
+
const channelId = cd?.teamsChannelId;
|
|
254
|
+
const messageId = activity.id;
|
|
255
|
+
if (!teamId || !channelId || !messageId)
|
|
256
|
+
return undefined;
|
|
257
|
+
// conversation.id is "<channel>;messageid=<rootId>"; the root differs from
|
|
258
|
+
// messageId only when the inbound message is a reply.
|
|
259
|
+
const convId = activity.conversation?.id ?? "";
|
|
260
|
+
const rootId = convId.match(/messageid=(\d+)/)?.[1] ?? messageId;
|
|
261
|
+
return { teamId, channelId, messageId, rootId };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Concise operational log for the inbound-file pipeline: how many parts we
|
|
265
|
+
* built and any skip reasons. Deliberately content-free — it never logs file
|
|
266
|
+
* bytes, decoded text, or SharePoint URLs.
|
|
267
|
+
*/
|
|
268
|
+
logFileParts(source, result) {
|
|
269
|
+
if (result.parts.length === 0 && result.notes.length === 0)
|
|
270
|
+
return;
|
|
271
|
+
console.log(`[bot-teams] ${source}: ${result.parts.length} file part(s)` +
|
|
272
|
+
(result.notes.length ? `; notes: ${result.notes.join(" | ")}` : ""));
|
|
273
|
+
}
|
|
274
|
+
/** Whether we can send proactively (out-of-turn). Requires a Microsoft app id. */
|
|
275
|
+
canGoProactive() {
|
|
276
|
+
return Boolean(this.opts.clientId ?? process.env.clientId);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Run `fn` against a proactive `TurnContext` opened by `continueConversation`,
|
|
280
|
+
* detached from any inbound HTTP turn. Fire-and-forget: the caller acks the
|
|
281
|
+
* inbound turn immediately and this runs (and may suspend at `awaitChoice`)
|
|
282
|
+
* in the background. Errors are logged, never surfaced to the inbound turn.
|
|
283
|
+
*/
|
|
284
|
+
runDetached(reference, fn) {
|
|
285
|
+
void this.withProactive(reference, fn).catch((err) => {
|
|
286
|
+
console.error("[bot-teams] detached turn failed:", err);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Open a proactive `TurnContext` for the conversation and await `fn`. */
|
|
290
|
+
async withProactive(reference, fn) {
|
|
291
|
+
if (!this.cloud)
|
|
292
|
+
return;
|
|
293
|
+
const appId = this.opts.clientId ?? process.env.clientId ?? "";
|
|
294
|
+
await this.cloud.continueConversation(appId, reference, (context) => fn(context));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Render IR to a native payload: plain text when it collapses to text,
|
|
298
|
+
* otherwise an Adaptive Card. (A bare `Echo: hi` is a text bubble; structured
|
|
299
|
+
* or interactive UI becomes a card.)
|
|
300
|
+
*/
|
|
301
|
+
render(ir) {
|
|
302
|
+
return isPlainText(ir)
|
|
303
|
+
? { text: renderTeamsMarkdown(ir) }
|
|
304
|
+
: { card: renderAdaptiveCard(ir) };
|
|
305
|
+
}
|
|
306
|
+
async post(target, ir) {
|
|
307
|
+
const t = target;
|
|
308
|
+
const payload = this.render(ir);
|
|
309
|
+
const id = "text" in payload
|
|
310
|
+
? await this.sendText(t, payload.text)
|
|
311
|
+
: await this.sendCard(t, payload.card);
|
|
312
|
+
return { id, conversationKey: t.conversationKey, context: t.context };
|
|
313
|
+
}
|
|
314
|
+
async update(ref, ir) {
|
|
315
|
+
const r = ref;
|
|
316
|
+
if (!r.id)
|
|
317
|
+
return;
|
|
318
|
+
const payload = this.render(ir);
|
|
319
|
+
// `updateActivity` re-derives addressing from the turn, so we build a fresh
|
|
320
|
+
// activity carrying only the id + new content (cloning the inbound activity
|
|
321
|
+
// drags fields that fail the SDK's re-validation).
|
|
322
|
+
const edit = async (context) => {
|
|
323
|
+
const activity = "text" in payload
|
|
324
|
+
? MessageFactory.text(payload.text)
|
|
325
|
+
: MessageFactory.attachment(CardFactory.adaptiveCard(payload.card));
|
|
326
|
+
activity.id = r.id;
|
|
327
|
+
await context.updateActivity(activity);
|
|
328
|
+
};
|
|
329
|
+
// Prefer a live context; otherwise re-enter the conversation out-of-turn so
|
|
330
|
+
// a picker card can be edited in place after its run has detached.
|
|
331
|
+
if (r.context) {
|
|
332
|
+
await edit(r.context);
|
|
333
|
+
}
|
|
334
|
+
else if (r.reference) {
|
|
335
|
+
await this.withProactive(r.reference, edit);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Stream a text reply by message edit: post the first content, then
|
|
340
|
+
* `updateActivity` the same message as the buffer grows (throttled). This is
|
|
341
|
+
* Teams' baseline streaming model: no native token streaming.
|
|
342
|
+
*/
|
|
343
|
+
async stream(target, chunks) {
|
|
344
|
+
const t = target;
|
|
345
|
+
const stream = new TeamsMessageStream({
|
|
346
|
+
post: (text) => this.sendText(t, text),
|
|
347
|
+
update: (id, text) => this.updateText(t, id, text),
|
|
348
|
+
typing: () => this.sendTyping(t),
|
|
349
|
+
});
|
|
350
|
+
let acc = "";
|
|
351
|
+
for await (const chunk of chunks) {
|
|
352
|
+
acc += chunk;
|
|
353
|
+
stream.append(acc);
|
|
354
|
+
}
|
|
355
|
+
const id = (await stream.finish()) ?? "";
|
|
356
|
+
return { id, conversationKey: t.conversationKey, context: t.context };
|
|
357
|
+
}
|
|
358
|
+
async delete(ref) {
|
|
359
|
+
const r = ref;
|
|
360
|
+
if (!r.context || !r.id)
|
|
361
|
+
return;
|
|
362
|
+
await r.context.deleteActivity(r.id);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Post a file to the conversation. Teams renders an image inline when it's
|
|
366
|
+
* sent as an attachment whose `contentUrl` is a `data:` URI, so we base64 the
|
|
367
|
+
* bytes into one — exactly what `render_chart`/`render_diagram` need to drop a
|
|
368
|
+
* PNG into the thread (the bot-slack `postFile` parallel). Non-image bytes are
|
|
369
|
+
* still attached with their inferred MIME; whether Teams previews them is up
|
|
370
|
+
* to the client. Sends on the live turn context, or proactively by reference.
|
|
371
|
+
*/
|
|
372
|
+
async postFile(target, { bytes, filename, altText, }) {
|
|
373
|
+
const t = target;
|
|
374
|
+
const mime = mimeFromFilename(filename);
|
|
375
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
376
|
+
const activity = MessageFactory.attachment({
|
|
377
|
+
contentType: mime,
|
|
378
|
+
contentUrl: `data:${mime};base64,${base64}`,
|
|
379
|
+
name: altText ?? filename,
|
|
380
|
+
});
|
|
381
|
+
try {
|
|
382
|
+
if (t.context) {
|
|
383
|
+
const res = await t.context.sendActivity(activity);
|
|
384
|
+
return { ok: true, fileId: res?.id };
|
|
385
|
+
}
|
|
386
|
+
if (this.cloud && t.reference) {
|
|
387
|
+
let fileId;
|
|
388
|
+
const appId = this.opts.clientId ?? process.env.clientId ?? "";
|
|
389
|
+
await this.cloud.continueConversation(appId, t.reference, async (context) => {
|
|
390
|
+
const res = await context.sendActivity(activity);
|
|
391
|
+
fileId = res?.id;
|
|
392
|
+
});
|
|
393
|
+
return { ok: true, fileId };
|
|
394
|
+
}
|
|
395
|
+
return { ok: false, error: "no live or proactive context to post on" };
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
return { ok: false, error: e.message };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
createRunRenderer(target) {
|
|
402
|
+
const t = target;
|
|
403
|
+
return createRunRenderer({
|
|
404
|
+
interruptEventNames: this.opts.interruptEventNames,
|
|
405
|
+
post: (text) => this.sendText(t, text),
|
|
406
|
+
update: (id, text) => this.updateText(t, id, text),
|
|
407
|
+
typing: () => this.sendTyping(t),
|
|
408
|
+
recordAssistant: (text) => this.store.recordAssistant(t.conversationKey, text),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
decodeInteraction(raw) {
|
|
412
|
+
const activity = raw;
|
|
413
|
+
const action = parseCardAction(activity);
|
|
414
|
+
if (!action)
|
|
415
|
+
return undefined;
|
|
416
|
+
const conversationKey = conversationKeyOf(activity);
|
|
417
|
+
const reference = activity.getConversationReference?.();
|
|
418
|
+
const from = activity.from;
|
|
419
|
+
return {
|
|
420
|
+
id: action.id,
|
|
421
|
+
conversationKey,
|
|
422
|
+
value: action.value,
|
|
423
|
+
user: from?.id ? { id: from.id, name: from.name } : undefined,
|
|
424
|
+
replyTarget: { conversationKey, reference },
|
|
425
|
+
messageRef: {
|
|
426
|
+
id: activity.replyToId ?? "",
|
|
427
|
+
conversationKey,
|
|
428
|
+
reference,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
async lookupUser(_q) {
|
|
433
|
+
// Directory lookups require Microsoft Graph; not wired in milestone-1.
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
get conversationStore() {
|
|
437
|
+
return this.store;
|
|
438
|
+
}
|
|
439
|
+
/** Return the conversation transcript the adapter has accumulated. */
|
|
440
|
+
async getMessages(target) {
|
|
441
|
+
const t = target;
|
|
442
|
+
return this.store.getTranscript(t.conversationKey);
|
|
443
|
+
}
|
|
444
|
+
/** Send plain Markdown text, preferring the live turn context. */
|
|
445
|
+
async sendText(t, text) {
|
|
446
|
+
const trimmed = text.trim();
|
|
447
|
+
if (!trimmed)
|
|
448
|
+
return "";
|
|
449
|
+
if (t.context) {
|
|
450
|
+
const res = await t.context.sendActivity(trimmed);
|
|
451
|
+
return res?.id ?? "";
|
|
452
|
+
}
|
|
453
|
+
// Out-of-turn (proactive) send: re-enter the conversation by reference.
|
|
454
|
+
if (this.cloud && t.reference) {
|
|
455
|
+
let id = "";
|
|
456
|
+
const appId = this.opts.clientId ?? process.env.clientId ?? "";
|
|
457
|
+
await this.cloud.continueConversation(appId, t.reference, async (context) => {
|
|
458
|
+
const res = await context.sendActivity(trimmed);
|
|
459
|
+
id = res?.id ?? "";
|
|
460
|
+
});
|
|
461
|
+
return id;
|
|
462
|
+
}
|
|
463
|
+
return "";
|
|
464
|
+
}
|
|
465
|
+
/** Send an Adaptive Card as a message attachment on the live turn. */
|
|
466
|
+
async sendCard(t, card) {
|
|
467
|
+
const activity = MessageFactory.attachment(CardFactory.adaptiveCard(card));
|
|
468
|
+
if (t.context) {
|
|
469
|
+
const res = await t.context.sendActivity(activity);
|
|
470
|
+
return res?.id ?? "";
|
|
471
|
+
}
|
|
472
|
+
if (this.cloud && t.reference) {
|
|
473
|
+
let id = "";
|
|
474
|
+
const appId = this.opts.clientId ?? process.env.clientId ?? "";
|
|
475
|
+
await this.cloud.continueConversation(appId, t.reference, async (context) => {
|
|
476
|
+
const res = await context.sendActivity(activity);
|
|
477
|
+
id = res?.id ?? "";
|
|
478
|
+
});
|
|
479
|
+
return id;
|
|
480
|
+
}
|
|
481
|
+
return "";
|
|
482
|
+
}
|
|
483
|
+
/** Edit a previously-posted text activity in place (streamed-by-edit). */
|
|
484
|
+
async updateText(t, id, text) {
|
|
485
|
+
if (!t.context || !id)
|
|
486
|
+
return;
|
|
487
|
+
const activity = MessageFactory.text(text);
|
|
488
|
+
activity.id = id;
|
|
489
|
+
await t.context.updateActivity(activity);
|
|
490
|
+
}
|
|
491
|
+
/** Fire a typing indicator (shown while the agent works). */
|
|
492
|
+
async sendTyping(t) {
|
|
493
|
+
if (!t.context)
|
|
494
|
+
return;
|
|
495
|
+
try {
|
|
496
|
+
await t.context.sendActivity(new Activity(ActivityTypes.Typing));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// Typing is best-effort; never let it sink a reply.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Send a typing indicator now and keep re-sending it every few seconds until
|
|
504
|
+
* the returned stop fn is called. Teams' typing indicator lapses after a few
|
|
505
|
+
* seconds, so a single ping can't cover slow work (a Graph download, a chart
|
|
506
|
+
* render) where nothing posts in between. Returns a stop fn; call it in a
|
|
507
|
+
* `finally` so the timer is always cleared.
|
|
508
|
+
*/
|
|
509
|
+
startTypingHeartbeat(t) {
|
|
510
|
+
void this.sendTyping(t); // immediate, so the indicator shows right away
|
|
511
|
+
const timer = setInterval(() => void this.sendTyping(t), 3500);
|
|
512
|
+
return () => clearInterval(timer);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** Construct a Microsoft Teams `PlatformAdapter`. */
|
|
516
|
+
export function teams(opts = {}) {
|
|
517
|
+
return new TeamsAdapter(opts);
|
|
518
|
+
}
|
|
519
|
+
/** Best-effort MIME from a filename extension; defaults to PNG (the common case). */
|
|
520
|
+
function mimeFromFilename(filename) {
|
|
521
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
522
|
+
switch (ext) {
|
|
523
|
+
case "png":
|
|
524
|
+
return "image/png";
|
|
525
|
+
case "jpg":
|
|
526
|
+
case "jpeg":
|
|
527
|
+
return "image/jpeg";
|
|
528
|
+
case "gif":
|
|
529
|
+
return "image/gif";
|
|
530
|
+
case "webp":
|
|
531
|
+
return "image/webp";
|
|
532
|
+
case "svg":
|
|
533
|
+
return "image/svg+xml";
|
|
534
|
+
case "pdf":
|
|
535
|
+
return "application/pdf";
|
|
536
|
+
default:
|
|
537
|
+
return "image/png";
|
|
538
|
+
}
|
|
539
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}
|