@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { ActivityTypes } from "@microsoft/agents-activity";
|
|
3
|
+
import { TeamsAdapter } from "./adapter.js";
|
|
4
|
+
/**
|
|
5
|
+
* Regression coverage for the card-interaction auth fix.
|
|
6
|
+
*
|
|
7
|
+
* Adaptive Card `Action.Submit` clicks used to be handled on the inbound turn
|
|
8
|
+
* context, whose connector client the M365 SDK builds with an anonymous
|
|
9
|
+
* identity, so editing the card in place (`updateActivity`) was rejected 401
|
|
10
|
+
* on real Teams. Credentialed interactions must instead run on the same
|
|
11
|
+
* app-id-authenticated proactive (`continueConversation`) context as ordinary
|
|
12
|
+
* replies. In the anonymous local Playground (no app id) the inbound context is
|
|
13
|
+
* the only one available and is used directly.
|
|
14
|
+
*/
|
|
15
|
+
function cardClickContext() {
|
|
16
|
+
const activity = {
|
|
17
|
+
type: ActivityTypes.Message,
|
|
18
|
+
value: { ckActionId: "ck:abc123", value: { confirmed: true } },
|
|
19
|
+
conversation: { id: "conv-1" },
|
|
20
|
+
from: { id: "user-1", name: "Tester" },
|
|
21
|
+
replyToId: "card-activity-1",
|
|
22
|
+
getConversationReference: () => ({
|
|
23
|
+
conversation: { id: "conv-1" },
|
|
24
|
+
serviceUrl: "https://smba.example/",
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
return { activity };
|
|
28
|
+
}
|
|
29
|
+
function mockSink() {
|
|
30
|
+
return {
|
|
31
|
+
onTurn: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
onCommand: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
onInteraction: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
37
|
+
describe("TeamsAdapter card interactions", () => {
|
|
38
|
+
let prevClientId;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
prevClientId = process.env.clientId;
|
|
41
|
+
delete process.env.clientId;
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (prevClientId !== undefined)
|
|
45
|
+
process.env.clientId = prevClientId;
|
|
46
|
+
});
|
|
47
|
+
it("routes the interaction through the authenticated proactive context when credentialed", async () => {
|
|
48
|
+
const adapter = new TeamsAdapter({ clientId: "app-123" });
|
|
49
|
+
const proactiveCtx = { id: "proactive" };
|
|
50
|
+
const continueConversation = vi.fn(async (_appId, _ref, cb) => {
|
|
51
|
+
await cb(proactiveCtx);
|
|
52
|
+
});
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
adapter.cloud = { continueConversation };
|
|
55
|
+
const sink = mockSink();
|
|
56
|
+
const inbound = cardClickContext();
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
await adapter.handleActivity(inbound, sink);
|
|
59
|
+
await flush(); // the interaction runs on a detached proactive context
|
|
60
|
+
expect(continueConversation).toHaveBeenCalledWith("app-123", expect.anything(), expect.any(Function));
|
|
61
|
+
expect(sink.onInteraction).toHaveBeenCalledTimes(1);
|
|
62
|
+
const evt = sink.onInteraction.mock.calls[0][0];
|
|
63
|
+
expect(evt.id).toBe("ck:abc123");
|
|
64
|
+
expect(evt.value).toEqual({ confirmed: true });
|
|
65
|
+
// The reply/edit context must be the proactive one, NOT the (anonymous)
|
|
66
|
+
// inbound click context. That was the 401 bug.
|
|
67
|
+
expect(evt.replyTarget.context).toBe(proactiveCtx);
|
|
68
|
+
expect(evt.messageRef.context).toBe(proactiveCtx);
|
|
69
|
+
expect(evt.messageRef.id).toBe("card-activity-1");
|
|
70
|
+
});
|
|
71
|
+
it("uses the inbound context for interactions in anonymous mode (no app id)", async () => {
|
|
72
|
+
const adapter = new TeamsAdapter({});
|
|
73
|
+
const continueConversation = vi.fn();
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
adapter.cloud = { continueConversation };
|
|
76
|
+
const sink = mockSink();
|
|
77
|
+
const inbound = cardClickContext();
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
await adapter.handleActivity(inbound, sink);
|
|
80
|
+
await flush();
|
|
81
|
+
expect(continueConversation).not.toHaveBeenCalled();
|
|
82
|
+
expect(sink.onInteraction).toHaveBeenCalledTimes(1);
|
|
83
|
+
const evt = sink.onInteraction.mock.calls[0][0];
|
|
84
|
+
expect(evt.replyTarget.context).toBe(inbound);
|
|
85
|
+
expect(evt.messageRef.context).toBe(inbound);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
/** A plain inbound message activity carrying optional file attachments. */
|
|
89
|
+
function messageContext(text, attachments) {
|
|
90
|
+
const activity = {
|
|
91
|
+
type: ActivityTypes.Message,
|
|
92
|
+
text,
|
|
93
|
+
attachments,
|
|
94
|
+
conversation: { id: "conv-1" },
|
|
95
|
+
from: { id: "user-1", name: "Sam" },
|
|
96
|
+
removeRecipientMention: () => text,
|
|
97
|
+
getConversationReference: () => ({
|
|
98
|
+
conversation: { id: "conv-1" },
|
|
99
|
+
serviceUrl: "https://smba.example/",
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
return { activity };
|
|
103
|
+
}
|
|
104
|
+
describe("TeamsAdapter inbound files", () => {
|
|
105
|
+
const prevClientId = process.env.clientId;
|
|
106
|
+
beforeEach(() => delete process.env.clientId);
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
if (prevClientId !== undefined)
|
|
109
|
+
process.env.clientId = prevClientId;
|
|
110
|
+
});
|
|
111
|
+
it("delivers an uploaded CSV to the agent as a content part on the turn", async () => {
|
|
112
|
+
const adapter = new TeamsAdapter({});
|
|
113
|
+
const sink = mockSink();
|
|
114
|
+
const csv = Buffer.from("month,sev1\nJan,3\nFeb,5\n").toString("base64");
|
|
115
|
+
const ctx = messageContext("chart this", [
|
|
116
|
+
{
|
|
117
|
+
contentType: "text/csv",
|
|
118
|
+
name: "incidents.csv",
|
|
119
|
+
contentUrl: `data:text/csv;base64,${csv}`,
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
123
|
+
await adapter.handleActivity(ctx, sink);
|
|
124
|
+
await flush();
|
|
125
|
+
expect(sink.onTurn).toHaveBeenCalledTimes(1);
|
|
126
|
+
const turn = sink.onTurn.mock.calls[0][0];
|
|
127
|
+
expect(turn.userText).toBe("chart this");
|
|
128
|
+
expect(turn.contentParts).toBeDefined();
|
|
129
|
+
// Leads with the user's text, then the decoded CSV as a text part.
|
|
130
|
+
expect(turn.contentParts[0]).toEqual({ type: "text", text: "chart this" });
|
|
131
|
+
const csvPart = turn.contentParts[1];
|
|
132
|
+
expect(csvPart.type).toBe("text");
|
|
133
|
+
expect(csvPart.text).toContain("month,sev1");
|
|
134
|
+
});
|
|
135
|
+
it("leaves contentParts undefined when there are no attachments", async () => {
|
|
136
|
+
const adapter = new TeamsAdapter({});
|
|
137
|
+
const sink = mockSink();
|
|
138
|
+
const ctx = messageContext("hi");
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
140
|
+
await adapter.handleActivity(ctx, sink);
|
|
141
|
+
await flush();
|
|
142
|
+
const turn = sink.onTurn.mock.calls[0][0];
|
|
143
|
+
expect(turn.contentParts).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("TeamsAdapter.postFile", () => {
|
|
147
|
+
it("sends a PNG as an inline image attachment via a data: URI", async () => {
|
|
148
|
+
const adapter = new TeamsAdapter({});
|
|
149
|
+
const sendActivity = vi.fn().mockResolvedValue({ id: "msg-9" });
|
|
150
|
+
const target = {
|
|
151
|
+
conversationKey: "conv-1",
|
|
152
|
+
reference: {},
|
|
153
|
+
context: { sendActivity },
|
|
154
|
+
};
|
|
155
|
+
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
156
|
+
const res = await adapter.postFile(target, {
|
|
157
|
+
bytes,
|
|
158
|
+
filename: "chart.png",
|
|
159
|
+
title: "Chart",
|
|
160
|
+
altText: "Sev counts",
|
|
161
|
+
});
|
|
162
|
+
expect(res).toEqual({ ok: true, fileId: "msg-9" });
|
|
163
|
+
const sent = sendActivity.mock.calls[0][0];
|
|
164
|
+
const attachment = sent.attachments[0];
|
|
165
|
+
expect(attachment.contentType).toBe("image/png");
|
|
166
|
+
expect(attachment.contentUrl).toBe(`data:image/png;base64,${Buffer.from(bytes).toString("base64")}`);
|
|
167
|
+
expect(attachment.name).toBe("Sev counts");
|
|
168
|
+
});
|
|
169
|
+
it("returns an error result when there is no context to send on", async () => {
|
|
170
|
+
const adapter = new TeamsAdapter({});
|
|
171
|
+
const res = await adapter.postFile({ conversationKey: "conv-1", reference: {} }, { bytes: new Uint8Array([1]), filename: "x.png" });
|
|
172
|
+
expect(res.ok).toBe(false);
|
|
173
|
+
expect(res.error).toBeDefined();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe("TeamsAdapter typing heartbeat", () => {
|
|
177
|
+
it("sends typing immediately, repeats on a timer, and stops when cleared", () => {
|
|
178
|
+
vi.useFakeTimers();
|
|
179
|
+
try {
|
|
180
|
+
const adapter = new TeamsAdapter({});
|
|
181
|
+
const sendActivity = vi.fn().mockResolvedValue({ id: "t" });
|
|
182
|
+
const target = {
|
|
183
|
+
conversationKey: "c",
|
|
184
|
+
reference: {},
|
|
185
|
+
context: { sendActivity },
|
|
186
|
+
};
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
|
+
const stop = adapter.startTypingHeartbeat(target);
|
|
189
|
+
expect(sendActivity).toHaveBeenCalledTimes(1); // immediate
|
|
190
|
+
vi.advanceTimersByTime(3500 * 2);
|
|
191
|
+
expect(sendActivity).toHaveBeenCalledTimes(3); // two more ticks
|
|
192
|
+
stop();
|
|
193
|
+
vi.advanceTimersByTime(3500 * 3);
|
|
194
|
+
expect(sendActivity).toHaveBeenCalledTimes(3); // none after stop
|
|
195
|
+
// It sends a typing activity, not text.
|
|
196
|
+
expect(sendActivity.mock.calls[0][0].type).toBe("typing");
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
vi.useRealTimers();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AbstractAgent } from "@ag-ui/client";
|
|
2
|
+
import type { ConversationStore, AgentSession, ReplyTarget } from "@copilotkit/channels";
|
|
3
|
+
import type { ThreadMessage, AgentContentPart } from "@copilotkit/channels-ui";
|
|
4
|
+
/**
|
|
5
|
+
* In-memory Teams conversation store.
|
|
6
|
+
*
|
|
7
|
+
* Unlike Slack, Teams does not hand the bot a free, queryable history of a
|
|
8
|
+
* conversation, so the adapter keeps the transcript itself: the listener
|
|
9
|
+
* records each incoming user message, and the run renderer records the agent's
|
|
10
|
+
* reply. Each turn builds a fresh AG-UI thread id (the durable history is held
|
|
11
|
+
* here, not server-side) and seeds the agent with the accumulated transcript.
|
|
12
|
+
*
|
|
13
|
+
* The transcript retains a message's multimodal content (an uploaded file's
|
|
14
|
+
* decoded contents), not just its text, so a follow-up turn can still act on
|
|
15
|
+
* data the user only sent once. This grows context for long file-bearing
|
|
16
|
+
* conversations; a production {@link ConversationStore} can cap or summarize it.
|
|
17
|
+
*
|
|
18
|
+
* This is the batteries-included default. It is deliberately swappable: a
|
|
19
|
+
* production deployment that needs the transcript to survive restarts can
|
|
20
|
+
* implement {@link ConversationStore} against a real datastore and pass it in.
|
|
21
|
+
*/
|
|
22
|
+
export declare class TeamsConversationStore implements ConversationStore {
|
|
23
|
+
private readonly history;
|
|
24
|
+
/**
|
|
25
|
+
* Append a user message to the conversation transcript. Accepts plain text or
|
|
26
|
+
* multimodal content parts (uploaded files); empty content is ignored.
|
|
27
|
+
*/
|
|
28
|
+
recordUser(conversationKey: string, content: string | AgentContentPart[]): void;
|
|
29
|
+
/** Append an assistant message to the conversation transcript. */
|
|
30
|
+
recordAssistant(conversationKey: string, content: string): void;
|
|
31
|
+
/** The accumulated transcript as bot-ui `ThreadMessage`s (backs `thread.getMessages()`). */
|
|
32
|
+
getTranscript(conversationKey: string): ThreadMessage[];
|
|
33
|
+
private append;
|
|
34
|
+
getOrCreate(conversationKey: string, _replyTarget: ReplyTarget, makeAgent: (threadId: string) => AbstractAgent): Promise<AgentSession>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=conversation-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversation-store.d.ts","sourceRoot":"","sources":["../src/conversation-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EACV,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACZ,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AA2B/E;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,sBAAuB,YAAW,iBAAiB;IAC9D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAE9D;;;OAGG;IACH,UAAU,CACR,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,MAAM,GAAG,gBAAgB,EAAE,GACnC,IAAI;IAKP,kEAAkE;IAClE,eAAe,CAAC,eAAe,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAS/D,4FAA4F;IAC5F,aAAa,CAAC,eAAe,EAAE,MAAM,GAAG,aAAa,EAAE;IAQvD,OAAO,CAAC,MAAM;IAMR,WAAW,CACf,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,WAAW,EACzB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,aAAa,GAC7C,OAAO,CAAC,YAAY,CAAC;CAYzB"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/** Collapse stored content (text or multimodal parts) to a display string. */
|
|
3
|
+
function contentToText(content) {
|
|
4
|
+
if (typeof content === "string")
|
|
5
|
+
return content;
|
|
6
|
+
return content
|
|
7
|
+
.map((p) => p.type === "text"
|
|
8
|
+
? p.text
|
|
9
|
+
: `[${p.type} attachment: ${p.source.mimeType}]`)
|
|
10
|
+
.join("\n");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* In-memory Teams conversation store.
|
|
14
|
+
*
|
|
15
|
+
* Unlike Slack, Teams does not hand the bot a free, queryable history of a
|
|
16
|
+
* conversation, so the adapter keeps the transcript itself: the listener
|
|
17
|
+
* records each incoming user message, and the run renderer records the agent's
|
|
18
|
+
* reply. Each turn builds a fresh AG-UI thread id (the durable history is held
|
|
19
|
+
* here, not server-side) and seeds the agent with the accumulated transcript.
|
|
20
|
+
*
|
|
21
|
+
* The transcript retains a message's multimodal content (an uploaded file's
|
|
22
|
+
* decoded contents), not just its text, so a follow-up turn can still act on
|
|
23
|
+
* data the user only sent once. This grows context for long file-bearing
|
|
24
|
+
* conversations; a production {@link ConversationStore} can cap or summarize it.
|
|
25
|
+
*
|
|
26
|
+
* This is the batteries-included default. It is deliberately swappable: a
|
|
27
|
+
* production deployment that needs the transcript to survive restarts can
|
|
28
|
+
* implement {@link ConversationStore} against a real datastore and pass it in.
|
|
29
|
+
*/
|
|
30
|
+
export class TeamsConversationStore {
|
|
31
|
+
history = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Append a user message to the conversation transcript. Accepts plain text or
|
|
34
|
+
* multimodal content parts (uploaded files); empty content is ignored.
|
|
35
|
+
*/
|
|
36
|
+
recordUser(conversationKey, content) {
|
|
37
|
+
if (typeof content === "string" ? !content : content.length === 0)
|
|
38
|
+
return;
|
|
39
|
+
this.append(conversationKey, { id: randomUUID(), role: "user", content });
|
|
40
|
+
}
|
|
41
|
+
/** Append an assistant message to the conversation transcript. */
|
|
42
|
+
recordAssistant(conversationKey, content) {
|
|
43
|
+
if (!content)
|
|
44
|
+
return;
|
|
45
|
+
this.append(conversationKey, {
|
|
46
|
+
id: randomUUID(),
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** The accumulated transcript as bot-ui `ThreadMessage`s (backs `thread.getMessages()`). */
|
|
52
|
+
getTranscript(conversationKey) {
|
|
53
|
+
const transcript = this.history.get(conversationKey) ?? [];
|
|
54
|
+
return transcript.map((m) => ({
|
|
55
|
+
text: contentToText(m.content),
|
|
56
|
+
isBot: m.role === "assistant",
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
append(conversationKey, message) {
|
|
60
|
+
const existing = this.history.get(conversationKey);
|
|
61
|
+
if (existing)
|
|
62
|
+
existing.push(message);
|
|
63
|
+
else
|
|
64
|
+
this.history.set(conversationKey, [message]);
|
|
65
|
+
}
|
|
66
|
+
async getOrCreate(conversationKey, _replyTarget, makeAgent) {
|
|
67
|
+
// Fresh AG-UI thread per turn. Our `history` map is the durable record, so
|
|
68
|
+
// the server-side thread only needs to live for this turn (mirrors the
|
|
69
|
+
// Slack adapter's rationale for not reusing a stable thread id).
|
|
70
|
+
const threadId = `teams-${conversationKey}-${randomUUID()}`;
|
|
71
|
+
const agent = makeAgent(threadId);
|
|
72
|
+
const transcript = this.history.get(conversationKey) ?? [];
|
|
73
|
+
agent.messages = [
|
|
74
|
+
...transcript,
|
|
75
|
+
];
|
|
76
|
+
return { agent };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversation-store.test.d.ts","sourceRoot":"","sources":["../src/conversation-store.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { TeamsConversationStore } from "./conversation-store.js";
|
|
3
|
+
/** Minimal stand-in: only the `messages` field the store writes is exercised. */
|
|
4
|
+
function makeAgent(threadId) {
|
|
5
|
+
return { threadId, messages: [] };
|
|
6
|
+
}
|
|
7
|
+
describe("TeamsConversationStore", () => {
|
|
8
|
+
it("seeds a fresh agent with the accumulated transcript", async () => {
|
|
9
|
+
const store = new TeamsConversationStore();
|
|
10
|
+
store.recordUser("conv-1", "hello");
|
|
11
|
+
store.recordAssistant("conv-1", "hi there");
|
|
12
|
+
store.recordUser("conv-1", "how are you?");
|
|
13
|
+
const session = await store.getOrCreate("conv-1", {}, makeAgent);
|
|
14
|
+
const messages = session.agent
|
|
15
|
+
.messages;
|
|
16
|
+
expect(messages).toEqual([
|
|
17
|
+
expect.objectContaining({ role: "user", content: "hello" }),
|
|
18
|
+
expect.objectContaining({ role: "assistant", content: "hi there" }),
|
|
19
|
+
expect.objectContaining({ role: "user", content: "how are you?" }),
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
it("isolates transcripts by conversation key", async () => {
|
|
23
|
+
const store = new TeamsConversationStore();
|
|
24
|
+
store.recordUser("a", "from a");
|
|
25
|
+
store.recordUser("b", "from b");
|
|
26
|
+
const a = await store.getOrCreate("a", {}, makeAgent);
|
|
27
|
+
const aMessages = a.agent.messages;
|
|
28
|
+
expect(aMessages).toHaveLength(1);
|
|
29
|
+
expect(aMessages[0]).toMatchObject({ content: "from a" });
|
|
30
|
+
});
|
|
31
|
+
it("exposes the transcript as bot-ui ThreadMessages", () => {
|
|
32
|
+
const store = new TeamsConversationStore();
|
|
33
|
+
store.recordUser("c", "ping");
|
|
34
|
+
store.recordAssistant("c", "pong");
|
|
35
|
+
expect(store.getTranscript("c")).toEqual([
|
|
36
|
+
{ text: "ping", isBot: false },
|
|
37
|
+
{ text: "pong", isBot: true },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
it("ignores empty messages", () => {
|
|
41
|
+
const store = new TeamsConversationStore();
|
|
42
|
+
store.recordUser("d", "");
|
|
43
|
+
store.recordUser("d", []); // empty content parts are dropped too
|
|
44
|
+
store.recordAssistant("d", " "); // whitespace-only is still stored as-is by callers; empty string is dropped
|
|
45
|
+
expect(store.getTranscript("d")).toEqual([{ text: " ", isBot: true }]);
|
|
46
|
+
});
|
|
47
|
+
it("persists multimodal content so a later turn still sees uploaded data", async () => {
|
|
48
|
+
const store = new TeamsConversationStore();
|
|
49
|
+
// Turn 1: a CSV upload, recorded as multimodal content parts.
|
|
50
|
+
store.recordUser("conv-1", [
|
|
51
|
+
{ type: "text", text: "make a pie chart" },
|
|
52
|
+
{ type: "text", text: 'Attached file "data.csv" (text/csv):\na,b\n1,2' },
|
|
53
|
+
]);
|
|
54
|
+
store.recordAssistant("conv-1", "Pie chart created.");
|
|
55
|
+
// Turn 2: a plain follow-up with no new data.
|
|
56
|
+
store.recordUser("conv-1", "now make it a bar chart");
|
|
57
|
+
const session = await store.getOrCreate("conv-1", {}, makeAgent);
|
|
58
|
+
const messages = session.agent
|
|
59
|
+
.messages;
|
|
60
|
+
// The CSV content from turn 1 is still seeded into the agent on turn 2.
|
|
61
|
+
expect(messages).toHaveLength(3);
|
|
62
|
+
expect(messages[0]).toMatchObject({
|
|
63
|
+
role: "user",
|
|
64
|
+
content: [
|
|
65
|
+
{ type: "text", text: "make a pie chart" },
|
|
66
|
+
expect.objectContaining({ type: "text" }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
// The transcript view flattens parts to text (for thread.getMessages()).
|
|
70
|
+
expect(store.getTranscript("conv-1")[0].text).toContain("a,b\n1,2");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound file transport for Teams. A Teams message can carry uploaded files;
|
|
3
|
+
* this turns them into AG-UI multimodal content the agent's model can read —
|
|
4
|
+
* images and PDFs as their respective binary parts, and text/CSV/JSON as
|
|
5
|
+
* decoded `text` parts. This is what makes the "upload a CSV → get a chart"
|
|
6
|
+
* flow work: the CSV arrives as text the model can parse before it calls a
|
|
7
|
+
* render tool. Mirrors `@copilotkit/channels-slack`'s `buildFileContentParts`.
|
|
8
|
+
*
|
|
9
|
+
* Teams delivers files two ways, and we handle both:
|
|
10
|
+
* - Channel/chat file uploads arrive as an attachment of contentType
|
|
11
|
+
* `application/vnd.microsoft.teams.file.download.info`, whose `content`
|
|
12
|
+
* holds a pre-authenticated `downloadUrl` (no bearer token needed) plus a
|
|
13
|
+
* `fileType`. We download from `downloadUrl`.
|
|
14
|
+
* - Inline media (and the M365 Agents Playground) arrives as an attachment
|
|
15
|
+
* whose `contentType` is the media MIME and `contentUrl` is either a
|
|
16
|
+
* `data:` URI (decoded in-process) or an https URL (fetched directly).
|
|
17
|
+
*
|
|
18
|
+
* The bridge is transport-only: it delivers the bytes/text to the agent and
|
|
19
|
+
* lets the app decide what to do with them. Anything it can't represent is
|
|
20
|
+
* skipped with a short note so the agent knows a file was dropped and why.
|
|
21
|
+
*/
|
|
22
|
+
import type { AgentContentPart } from "@copilotkit/channels-ui";
|
|
23
|
+
/** The subset of a Teams `Attachment` we read (matches @microsoft/agents-activity). */
|
|
24
|
+
export interface TeamsAttachmentRef {
|
|
25
|
+
contentType: string;
|
|
26
|
+
contentUrl?: string;
|
|
27
|
+
/** `file.download.info` attachments carry the real source here. */
|
|
28
|
+
content?: unknown;
|
|
29
|
+
name?: string;
|
|
30
|
+
}
|
|
31
|
+
/** Tunables for inbound file handling (all optional; sane defaults). */
|
|
32
|
+
export interface FileDeliveryConfig {
|
|
33
|
+
/** Skip a single file larger than this many bytes. Default 8 MiB. */
|
|
34
|
+
maxBytesPerFile?: number;
|
|
35
|
+
/** Process at most this many files per message. Default 5. */
|
|
36
|
+
maxFiles?: number;
|
|
37
|
+
/** Truncate decoded text files to this many bytes. Default 200 KiB. */
|
|
38
|
+
maxTextBytes?: number;
|
|
39
|
+
}
|
|
40
|
+
/** Best-effort MIME from a filename's extension, or undefined if unknown. */
|
|
41
|
+
export declare function mimeFromName(name: string | undefined): string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Turn downloaded bytes into a single AG-UI content part, or a skip `note` when
|
|
44
|
+
* the type is unsupported or the file exceeds the byte cap. Shared by the
|
|
45
|
+
* attachment path (this module) and the Graph channel path (`graph-files.ts`)
|
|
46
|
+
* so both classify and truncate identically.
|
|
47
|
+
*/
|
|
48
|
+
export declare function decodeFileBytes(label: string, mime: string, bytes: Buffer, config?: FileDeliveryConfig): {
|
|
49
|
+
part: AgentContentPart;
|
|
50
|
+
} | {
|
|
51
|
+
note: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Download a message's attachments and turn them into AG-UI content parts.
|
|
55
|
+
* Returns the parts plus human-readable `notes` for anything skipped (appended
|
|
56
|
+
* to the message as a text part by the caller).
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildFileContentParts(attachments: readonly TeamsAttachmentRef[], config?: FileDeliveryConfig): Promise<{
|
|
59
|
+
parts: AgentContentPart[];
|
|
60
|
+
notes: string[];
|
|
61
|
+
}>;
|
|
62
|
+
//# sourceMappingURL=download-files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download-files.d.ts","sourceRoot":"","sources":["../src/download-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEhE,uFAAuF;AACvF,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AASD,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA8DD,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGzE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,kBAAuB,GAC9B;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CA2C/C;AAmDD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,WAAW,EAAE,SAAS,kBAAkB,EAAE,EAC1C,MAAM,GAAE,kBAAuB,GAC9B,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAuCzD"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
maxBytesPerFile: 8 * 1024 * 1024,
|
|
3
|
+
maxFiles: 5,
|
|
4
|
+
maxTextBytes: 200 * 1024,
|
|
5
|
+
};
|
|
6
|
+
const TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
|
|
7
|
+
const TEXT_MIME_PREFIXES = ["text/"];
|
|
8
|
+
const TEXT_MIME_EXACT = new Set([
|
|
9
|
+
"application/json",
|
|
10
|
+
"application/csv",
|
|
11
|
+
"application/xml",
|
|
12
|
+
"application/x-ndjson",
|
|
13
|
+
"application/yaml",
|
|
14
|
+
]);
|
|
15
|
+
/** Map a filename extension to a MIME when the attachment doesn't give one. */
|
|
16
|
+
const EXT_MIME = {
|
|
17
|
+
csv: "text/csv",
|
|
18
|
+
tsv: "text/tab-separated-values",
|
|
19
|
+
txt: "text/plain",
|
|
20
|
+
md: "text/markdown",
|
|
21
|
+
json: "application/json",
|
|
22
|
+
xml: "application/xml",
|
|
23
|
+
yaml: "application/yaml",
|
|
24
|
+
yml: "application/yaml",
|
|
25
|
+
ndjson: "application/x-ndjson",
|
|
26
|
+
png: "image/png",
|
|
27
|
+
jpg: "image/jpeg",
|
|
28
|
+
jpeg: "image/jpeg",
|
|
29
|
+
gif: "image/gif",
|
|
30
|
+
webp: "image/webp",
|
|
31
|
+
pdf: "application/pdf",
|
|
32
|
+
};
|
|
33
|
+
function isText(mime) {
|
|
34
|
+
return (TEXT_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
|
35
|
+
TEXT_MIME_EXACT.has(mime));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The AG-UI media part type that carries this MIME, or null if it isn't a
|
|
39
|
+
* binary medium we pass through. Images/audio/video go by their top-level
|
|
40
|
+
* type; PDFs map to `document`. Everything else is left to the text path or
|
|
41
|
+
* skipped.
|
|
42
|
+
*/
|
|
43
|
+
function mediaPartType(mime) {
|
|
44
|
+
if (mime.startsWith("image/"))
|
|
45
|
+
return "image";
|
|
46
|
+
if (mime.startsWith("audio/"))
|
|
47
|
+
return "audio";
|
|
48
|
+
if (mime.startsWith("video/"))
|
|
49
|
+
return "video";
|
|
50
|
+
if (mime === "application/pdf")
|
|
51
|
+
return "document";
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** Best-effort MIME from a filename's extension, or undefined if unknown. */
|
|
55
|
+
export function mimeFromName(name) {
|
|
56
|
+
const ext = name?.split(".").pop()?.toLowerCase();
|
|
57
|
+
return ext ? EXT_MIME[ext] : undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Turn downloaded bytes into a single AG-UI content part, or a skip `note` when
|
|
61
|
+
* the type is unsupported or the file exceeds the byte cap. Shared by the
|
|
62
|
+
* attachment path (this module) and the Graph channel path (`graph-files.ts`)
|
|
63
|
+
* so both classify and truncate identically.
|
|
64
|
+
*/
|
|
65
|
+
export function decodeFileBytes(label, mime, bytes, config = {}) {
|
|
66
|
+
const maxBytes = config.maxBytesPerFile ?? DEFAULTS.maxBytesPerFile;
|
|
67
|
+
const maxText = config.maxTextBytes ?? DEFAULTS.maxTextBytes;
|
|
68
|
+
const media = mediaPartType(mime);
|
|
69
|
+
if (!media && !isText(mime)) {
|
|
70
|
+
return { note: `skipped "${label}" (${mime}): unsupported type` };
|
|
71
|
+
}
|
|
72
|
+
if (bytes.byteLength > maxBytes) {
|
|
73
|
+
return {
|
|
74
|
+
note: `skipped "${label}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte cap`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (media) {
|
|
78
|
+
// Image/audio/video/PDF → a binary data part the model reads natively
|
|
79
|
+
// (subject to its modality support). The bridge just delivers it.
|
|
80
|
+
return {
|
|
81
|
+
part: {
|
|
82
|
+
type: media,
|
|
83
|
+
source: {
|
|
84
|
+
type: "data",
|
|
85
|
+
value: bytes.toString("base64"),
|
|
86
|
+
mimeType: mime,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Truncate the BYTES then decode — slicing the decoded string by character
|
|
92
|
+
// index would corrupt multi-byte UTF-8 (and not actually bound the byte
|
|
93
|
+
// length). toString drops any malformed trailing bytes.
|
|
94
|
+
let buf = bytes;
|
|
95
|
+
let truncated = false;
|
|
96
|
+
if (buf.byteLength > maxText) {
|
|
97
|
+
buf = buf.subarray(0, maxText);
|
|
98
|
+
truncated = true;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
part: {
|
|
102
|
+
type: "text",
|
|
103
|
+
text: `Attached file "${label}" (${mime}${truncated ? ", truncated" : ""}):\n` +
|
|
104
|
+
buf.toString("utf8"),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a Teams attachment to a single fetchable source (URL + MIME + label),
|
|
110
|
+
* or null if it carries nothing we can download. `file.download.info` points at
|
|
111
|
+
* its pre-authenticated `downloadUrl`; everything else uses `contentUrl`.
|
|
112
|
+
*/
|
|
113
|
+
function resolveSource(att) {
|
|
114
|
+
const label = att.name ?? "file";
|
|
115
|
+
if (att.contentType === TEAMS_FILE_DOWNLOAD_INFO) {
|
|
116
|
+
const info = (att.content ?? {});
|
|
117
|
+
if (!info.downloadUrl)
|
|
118
|
+
return null;
|
|
119
|
+
const mime = mimeFromName(att.name) ??
|
|
120
|
+
(info.fileType ? EXT_MIME[info.fileType.toLowerCase()] : undefined) ??
|
|
121
|
+
"application/octet-stream";
|
|
122
|
+
return { url: info.downloadUrl, mime, label };
|
|
123
|
+
}
|
|
124
|
+
if (!att.contentUrl)
|
|
125
|
+
return null;
|
|
126
|
+
// For media/text attachments the contentType IS the MIME; fall back to the
|
|
127
|
+
// filename when it's a generic wrapper.
|
|
128
|
+
const mime = att.contentType && att.contentType.includes("/")
|
|
129
|
+
? att.contentType.toLowerCase()
|
|
130
|
+
: (mimeFromName(att.name) ?? "application/octet-stream");
|
|
131
|
+
return { url: att.contentUrl, mime, label };
|
|
132
|
+
}
|
|
133
|
+
/** Fetch a source's bytes. Decodes `data:` URIs in-process; fetches http(s). */
|
|
134
|
+
async function fetchBytes(url) {
|
|
135
|
+
if (url.startsWith("data:")) {
|
|
136
|
+
const comma = url.indexOf(",");
|
|
137
|
+
if (comma < 0)
|
|
138
|
+
throw new Error("malformed data URI");
|
|
139
|
+
const meta = url.slice(5, comma);
|
|
140
|
+
const payload = url.slice(comma + 1);
|
|
141
|
+
return meta.includes(";base64")
|
|
142
|
+
? Buffer.from(payload, "base64")
|
|
143
|
+
: Buffer.from(decodeURIComponent(payload), "utf8");
|
|
144
|
+
}
|
|
145
|
+
const res = await fetch(url);
|
|
146
|
+
if (!res.ok)
|
|
147
|
+
throw new Error(`download failed (HTTP ${res.status})`);
|
|
148
|
+
return Buffer.from(await res.arrayBuffer());
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Download a message's attachments and turn them into AG-UI content parts.
|
|
152
|
+
* Returns the parts plus human-readable `notes` for anything skipped (appended
|
|
153
|
+
* to the message as a text part by the caller).
|
|
154
|
+
*/
|
|
155
|
+
export async function buildFileContentParts(attachments, config = {}) {
|
|
156
|
+
const maxFiles = config.maxFiles ?? DEFAULTS.maxFiles;
|
|
157
|
+
const parts = [];
|
|
158
|
+
const notes = [];
|
|
159
|
+
const considered = attachments.slice(0, maxFiles);
|
|
160
|
+
if (attachments.length > maxFiles) {
|
|
161
|
+
notes.push(`(only the first ${maxFiles} of ${attachments.length} files processed)`);
|
|
162
|
+
}
|
|
163
|
+
for (const att of considered) {
|
|
164
|
+
const source = resolveSource(att);
|
|
165
|
+
if (!source) {
|
|
166
|
+
// A file.download.info with no downloadUrl is a real drop we want to
|
|
167
|
+
// surface; other contentTypes (Adaptive Cards, the text/html mention)
|
|
168
|
+
// simply aren't files, so skip them quietly.
|
|
169
|
+
if (att.contentType === TEAMS_FILE_DOWNLOAD_INFO) {
|
|
170
|
+
notes.push(`skipped "${att.name ?? "file"}": file.download.info had no downloadUrl`);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const { url, mime, label } = source;
|
|
175
|
+
let bytes;
|
|
176
|
+
try {
|
|
177
|
+
bytes = await fetchBytes(url);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
notes.push(`skipped "${label}": ${err.message}`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const decoded = decodeFileBytes(label, mime, bytes, config);
|
|
184
|
+
if ("note" in decoded)
|
|
185
|
+
notes.push(decoded.note);
|
|
186
|
+
else
|
|
187
|
+
parts.push(decoded.part);
|
|
188
|
+
}
|
|
189
|
+
return { parts, notes };
|
|
190
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download-files.test.d.ts","sourceRoot":"","sources":["../src/download-files.test.ts"],"names":[],"mappings":""}
|