@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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/adapter.d.ts +133 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +539 -0
  6. package/dist/adapter.test.d.ts +2 -0
  7. package/dist/adapter.test.d.ts.map +1 -0
  8. package/dist/adapter.test.js +202 -0
  9. package/dist/conversation-store.d.ts +36 -0
  10. package/dist/conversation-store.d.ts.map +1 -0
  11. package/dist/conversation-store.js +78 -0
  12. package/dist/conversation-store.test.d.ts +2 -0
  13. package/dist/conversation-store.test.d.ts.map +1 -0
  14. package/dist/conversation-store.test.js +72 -0
  15. package/dist/download-files.d.ts +62 -0
  16. package/dist/download-files.d.ts.map +1 -0
  17. package/dist/download-files.js +190 -0
  18. package/dist/download-files.test.d.ts +2 -0
  19. package/dist/download-files.test.d.ts.map +1 -0
  20. package/dist/download-files.test.js +96 -0
  21. package/dist/event-renderer.d.ts +23 -0
  22. package/dist/event-renderer.d.ts.map +1 -0
  23. package/dist/event-renderer.js +135 -0
  24. package/dist/event-renderer.test.d.ts +2 -0
  25. package/dist/event-renderer.test.d.ts.map +1 -0
  26. package/dist/event-renderer.test.js +95 -0
  27. package/dist/graph-files.d.ts +50 -0
  28. package/dist/graph-files.d.ts.map +1 -0
  29. package/dist/graph-files.js +126 -0
  30. package/dist/graph-files.test.d.ts +2 -0
  31. package/dist/graph-files.test.d.ts.map +1 -0
  32. package/dist/graph-files.test.js +114 -0
  33. package/dist/index.d.ts +20 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +14 -0
  36. package/dist/interaction.d.ts +36 -0
  37. package/dist/interaction.d.ts.map +1 -0
  38. package/dist/interaction.js +35 -0
  39. package/dist/interaction.test.d.ts +2 -0
  40. package/dist/interaction.test.d.ts.map +1 -0
  41. package/dist/interaction.test.js +43 -0
  42. package/dist/listener.d.ts +26 -0
  43. package/dist/listener.d.ts.map +1 -0
  44. package/dist/listener.js +55 -0
  45. package/dist/listener.test.d.ts +2 -0
  46. package/dist/listener.test.d.ts.map +1 -0
  47. package/dist/listener.test.js +43 -0
  48. package/dist/message-stream.d.ts +52 -0
  49. package/dist/message-stream.d.ts.map +1 -0
  50. package/dist/message-stream.js +78 -0
  51. package/dist/message-stream.test.d.ts +2 -0
  52. package/dist/message-stream.test.d.ts.map +1 -0
  53. package/dist/message-stream.test.js +44 -0
  54. package/dist/render/adaptive-card.d.ts +43 -0
  55. package/dist/render/adaptive-card.d.ts.map +1 -0
  56. package/dist/render/adaptive-card.js +401 -0
  57. package/dist/render/adaptive-card.test.d.ts +2 -0
  58. package/dist/render/adaptive-card.test.d.ts.map +1 -0
  59. package/dist/render/adaptive-card.test.js +240 -0
  60. package/dist/render/auto-close.d.ts +27 -0
  61. package/dist/render/auto-close.d.ts.map +1 -0
  62. package/dist/render/auto-close.js +153 -0
  63. package/dist/render/auto-close.test.d.ts +2 -0
  64. package/dist/render/auto-close.test.d.ts.map +1 -0
  65. package/dist/render/auto-close.test.js +46 -0
  66. package/dist/render/budget.d.ts +38 -0
  67. package/dist/render/budget.d.ts.map +1 -0
  68. package/dist/render/budget.js +44 -0
  69. package/dist/render/budget.test.d.ts +2 -0
  70. package/dist/render/budget.test.d.ts.map +1 -0
  71. package/dist/render/budget.test.js +25 -0
  72. package/dist/render/markdown.d.ts +13 -0
  73. package/dist/render/markdown.d.ts.map +1 -0
  74. package/dist/render/markdown.js +121 -0
  75. package/dist/render/markdown.test.d.ts +2 -0
  76. package/dist/render/markdown.test.d.ts.map +1 -0
  77. package/dist/render/markdown.test.js +58 -0
  78. package/dist/sanitizing-http-agent.d.ts +35 -0
  79. package/dist/sanitizing-http-agent.d.ts.map +1 -0
  80. package/dist/sanitizing-http-agent.js +58 -0
  81. package/dist/types.d.ts +53 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +1 -0
  84. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=conversation-store.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=download-files.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-files.test.d.ts","sourceRoot":"","sources":["../src/download-files.test.ts"],"names":[],"mappings":""}