@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/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { teams, TeamsAdapter } from "./adapter.js";
|
|
2
|
+
export type { TeamsAdapterOptions, TeamsReplyTarget, ConversationKey, } from "./types.js";
|
|
3
|
+
export { TeamsConversationStore } from "./conversation-store.js";
|
|
4
|
+
export { createRunRenderer } from "./event-renderer.js";
|
|
5
|
+
export { conversationKeyOf, parseCardAction } from "./interaction.js";
|
|
6
|
+
export { renderTeamsMarkdown } from "./render/markdown.js";
|
|
7
|
+
export { autoCloseOpenMarkdown } from "./render/auto-close.js";
|
|
8
|
+
export { renderAdaptiveCard, isPlainText, collectPlainText, ADAPTIVE_CARD_CONTENT_TYPE, } from "./render/adaptive-card.js";
|
|
9
|
+
export type { AdaptiveCard } from "./render/adaptive-card.js";
|
|
10
|
+
export { TEAMS_LIMITS, truncateText, clampArray } from "./render/budget.js";
|
|
11
|
+
export { TeamsMessageStream } from "./message-stream.js";
|
|
12
|
+
export type { TeamsMessageStreamConfig } from "./message-stream.js";
|
|
13
|
+
export { createTeamsServer } from "./listener.js";
|
|
14
|
+
export type { TeamsServer, TeamsServerConfig } from "./listener.js";
|
|
15
|
+
export { SanitizingHttpAgent } from "./sanitizing-http-agent.js";
|
|
16
|
+
export { buildFileContentParts, decodeFileBytes } from "./download-files.js";
|
|
17
|
+
export type { TeamsAttachmentRef, FileDeliveryConfig, } from "./download-files.js";
|
|
18
|
+
export { buildChannelFileContentParts } from "./graph-files.js";
|
|
19
|
+
export type { GraphCredentials, ChannelMessageRef } from "./graph-files.js";
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACnD,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EACL,kBAAkB,EAClB,WAAW,EACX,gBAAgB,EAChB,0BAA0B,GAC3B,MAAM,2BAA2B,CAAC;AACnC,YAAY,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAC9D,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEpE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC7E,YAAY,EACV,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAChE,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Public API for @copilotkit/channels-teams.
|
|
2
|
+
export { teams, TeamsAdapter } from "./adapter.js";
|
|
3
|
+
export { TeamsConversationStore } from "./conversation-store.js";
|
|
4
|
+
export { createRunRenderer } from "./event-renderer.js";
|
|
5
|
+
export { conversationKeyOf, parseCardAction } from "./interaction.js";
|
|
6
|
+
export { renderTeamsMarkdown } from "./render/markdown.js";
|
|
7
|
+
export { autoCloseOpenMarkdown } from "./render/auto-close.js";
|
|
8
|
+
export { renderAdaptiveCard, isPlainText, collectPlainText, ADAPTIVE_CARD_CONTENT_TYPE, } from "./render/adaptive-card.js";
|
|
9
|
+
export { TEAMS_LIMITS, truncateText, clampArray } from "./render/budget.js";
|
|
10
|
+
export { TeamsMessageStream } from "./message-stream.js";
|
|
11
|
+
export { createTeamsServer } from "./listener.js";
|
|
12
|
+
export { SanitizingHttpAgent } from "./sanitizing-http-agent.js";
|
|
13
|
+
export { buildFileContentParts, decodeFileBytes } from "./download-files.js";
|
|
14
|
+
export { buildChannelFileContentParts } from "./graph-files.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive Card `Action.Submit` decoding for the Teams adapter.
|
|
3
|
+
*
|
|
4
|
+
* A button rendered by {@link ../render/adaptive-card.renderButton} carries its
|
|
5
|
+
* opaque minted action id (`ck:...`) and tiny `value` in the action's `data`.
|
|
6
|
+
* When clicked, Teams delivers a **Message activity** whose `value` is that
|
|
7
|
+
* `data` object (merged with any card inputs) and whose `text` is empty. These
|
|
8
|
+
* helpers recognize and decode that activity so the engine's `awaitChoice`
|
|
9
|
+
* waiter resolves.
|
|
10
|
+
*/
|
|
11
|
+
/** Minimal shape of the inbound Teams activity we read for interaction decoding. */
|
|
12
|
+
export interface TeamsActivityLike {
|
|
13
|
+
value?: unknown;
|
|
14
|
+
conversation?: {
|
|
15
|
+
id?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stable conversation key shared by ingress (`onTurn`) and interaction decoding
|
|
20
|
+
* so the engine's `awaitChoice` waiters resolve. Teams gives one stable id per
|
|
21
|
+
* conversation; **both paths MUST derive the key here**. A mismatch silently
|
|
22
|
+
* strands the waiter. (The issue mandates a single shared helper.)
|
|
23
|
+
*/
|
|
24
|
+
export declare function conversationKeyOf(activity: TeamsActivityLike): string;
|
|
25
|
+
/**
|
|
26
|
+
* Recognize and parse an Adaptive Card `Action.Submit`. Returns the opaque
|
|
27
|
+
* action id + button value when the activity carries our `ckActionId`, else
|
|
28
|
+
* `undefined` (i.e. it's an ordinary chat message). Carries ONLY the opaque id
|
|
29
|
+
* and the tiny button value: no resume-data smuggling; durability rides on the
|
|
30
|
+
* engine's ActionStore keyed by that id.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseCardAction(activity: TeamsActivityLike): {
|
|
33
|
+
id: string;
|
|
34
|
+
value: unknown;
|
|
35
|
+
} | undefined;
|
|
36
|
+
//# sourceMappingURL=interaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interaction.d.ts","sourceRoot":"","sources":["../src/interaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,YAAY,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAChC;AAQD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,CAErE;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,iBAAiB,GAC1B;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAU5C"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive Card `Action.Submit` decoding for the Teams adapter.
|
|
3
|
+
*
|
|
4
|
+
* A button rendered by {@link ../render/adaptive-card.renderButton} carries its
|
|
5
|
+
* opaque minted action id (`ck:...`) and tiny `value` in the action's `data`.
|
|
6
|
+
* When clicked, Teams delivers a **Message activity** whose `value` is that
|
|
7
|
+
* `data` object (merged with any card inputs) and whose `text` is empty. These
|
|
8
|
+
* helpers recognize and decode that activity so the engine's `awaitChoice`
|
|
9
|
+
* waiter resolves.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Stable conversation key shared by ingress (`onTurn`) and interaction decoding
|
|
13
|
+
* so the engine's `awaitChoice` waiters resolve. Teams gives one stable id per
|
|
14
|
+
* conversation; **both paths MUST derive the key here**. A mismatch silently
|
|
15
|
+
* strands the waiter. (The issue mandates a single shared helper.)
|
|
16
|
+
*/
|
|
17
|
+
export function conversationKeyOf(activity) {
|
|
18
|
+
return activity.conversation?.id ?? "";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Recognize and parse an Adaptive Card `Action.Submit`. Returns the opaque
|
|
22
|
+
* action id + button value when the activity carries our `ckActionId`, else
|
|
23
|
+
* `undefined` (i.e. it's an ordinary chat message). Carries ONLY the opaque id
|
|
24
|
+
* and the tiny button value: no resume-data smuggling; durability rides on the
|
|
25
|
+
* engine's ActionStore keyed by that id.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCardAction(activity) {
|
|
28
|
+
const data = activity.value;
|
|
29
|
+
if (!data ||
|
|
30
|
+
typeof data !== "object" ||
|
|
31
|
+
typeof data.ckActionId !== "string") {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return { id: data.ckActionId, value: data.value };
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interaction.test.d.ts","sourceRoot":"","sources":["../src/interaction.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { conversationKeyOf, parseCardAction } from "./interaction.js";
|
|
3
|
+
describe("conversationKeyOf", () => {
|
|
4
|
+
it("derives the key from activity.conversation.id", () => {
|
|
5
|
+
expect(conversationKeyOf({ conversation: { id: "19:abc@thread.tacv2" } })).toBe("19:abc@thread.tacv2");
|
|
6
|
+
});
|
|
7
|
+
it("is empty when there is no conversation id (never throws)", () => {
|
|
8
|
+
expect(conversationKeyOf({})).toBe("");
|
|
9
|
+
expect(conversationKeyOf({ conversation: {} })).toBe("");
|
|
10
|
+
});
|
|
11
|
+
it("matches between an ingress message and its later card-action submit", () => {
|
|
12
|
+
// Both paths MUST agree or the awaitChoice waiter is stranded.
|
|
13
|
+
const id = "19:meeting_xyz@thread.v2";
|
|
14
|
+
const ingress = conversationKeyOf({ conversation: { id } });
|
|
15
|
+
const submit = conversationKeyOf({
|
|
16
|
+
conversation: { id },
|
|
17
|
+
value: { ckActionId: "ck:1", value: { confirmed: true } },
|
|
18
|
+
});
|
|
19
|
+
expect(submit).toBe(ingress);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("parseCardAction", () => {
|
|
23
|
+
it("decodes an Action.Submit carrying our ckActionId + value", () => {
|
|
24
|
+
const parsed = parseCardAction({
|
|
25
|
+
value: { ckActionId: "ck:approve-1", value: { confirmed: true } },
|
|
26
|
+
});
|
|
27
|
+
expect(parsed).toEqual({ id: "ck:approve-1", value: { confirmed: true } });
|
|
28
|
+
});
|
|
29
|
+
it("returns undefined for an ordinary chat message (no value)", () => {
|
|
30
|
+
expect(parseCardAction({ conversation: { id: "c1" } })).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
it("returns undefined when value lacks a ckActionId", () => {
|
|
33
|
+
expect(parseCardAction({ value: { foo: "bar" } })).toBeUndefined();
|
|
34
|
+
expect(parseCardAction({ value: "just text" })).toBeUndefined();
|
|
35
|
+
expect(parseCardAction({ value: { ckActionId: 42 } })).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
it("passes through a falsy/absent button value", () => {
|
|
38
|
+
expect(parseCardAction({ value: { ckActionId: "ck:x" } })).toEqual({
|
|
39
|
+
id: "ck:x",
|
|
40
|
+
value: undefined,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import type { CloudAdapter, TurnContext } from "@microsoft/agents-hosting";
|
|
3
|
+
export interface TeamsServerConfig {
|
|
4
|
+
adapter: CloudAdapter;
|
|
5
|
+
port: number;
|
|
6
|
+
/**
|
|
7
|
+
* Invoked for every inbound activity, inside the adapter's turn (so
|
|
8
|
+
* `context.sendActivity` replies on the originating request). Throwing here
|
|
9
|
+
* is surfaced through the adapter's `onTurnError`.
|
|
10
|
+
*/
|
|
11
|
+
onTurnContext: (context: TurnContext) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface TeamsServer {
|
|
14
|
+
start(): Promise<Server>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Stand up the bot's `POST /api/messages` endpoint.
|
|
19
|
+
*
|
|
20
|
+
* The M365 Agents Playground (and Azure Bot Service) deliver activities here as
|
|
21
|
+
* JSON; `CloudAdapter.process` authenticates the request (anonymous in local
|
|
22
|
+
* Playground mode), builds the `TurnContext`, runs the middleware pipeline, and
|
|
23
|
+
* writes the HTTP response once `onTurnContext` resolves.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createTeamsServer(config: TeamsServerConfig): TeamsServer;
|
|
26
|
+
//# sourceMappingURL=listener.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EAEZ,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,YAAY,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,aAAa,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACzB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,WAAW,CAmDxE"}
|
package/dist/listener.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Stand up the bot's `POST /api/messages` endpoint.
|
|
4
|
+
*
|
|
5
|
+
* The M365 Agents Playground (and Azure Bot Service) deliver activities here as
|
|
6
|
+
* JSON; `CloudAdapter.process` authenticates the request (anonymous in local
|
|
7
|
+
* Playground mode), builds the `TurnContext`, runs the middleware pipeline, and
|
|
8
|
+
* writes the HTTP response once `onTurnContext` resolves.
|
|
9
|
+
*/
|
|
10
|
+
export function createTeamsServer(config) {
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use(express.json());
|
|
13
|
+
app.post("/api/messages", (req, res) => {
|
|
14
|
+
config.adapter
|
|
15
|
+
.process(
|
|
16
|
+
// Cast across HTTP-layer types: the M365 SDK types `process` against its
|
|
17
|
+
// own `express` types, which can differ in major (and `Response` generic
|
|
18
|
+
// arity) from the one resolved here. Express's req/res satisfy the SDK's
|
|
19
|
+
// structural Request/Response at runtime regardless.
|
|
20
|
+
req, res, async (context) => {
|
|
21
|
+
await config.onTurnContext(context);
|
|
22
|
+
})
|
|
23
|
+
// A failed turn (e.g. an auth error talking to the Bot Connector) must
|
|
24
|
+
// not become an unhandled rejection, which crashes the whole Node
|
|
25
|
+
// process and takes every other conversation down with it. Contain it:
|
|
26
|
+
// log, and make sure the inbound request still gets a response so the
|
|
27
|
+
// channel doesn't hang.
|
|
28
|
+
.catch((err) => {
|
|
29
|
+
console.error("[bot-teams] POST /api/messages failed:", err);
|
|
30
|
+
if (!res.headersSent)
|
|
31
|
+
res.status(500).end();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
// A trivial liveness probe, handy when running behind a tunnel.
|
|
35
|
+
app.get("/healthz", (_req, res) => {
|
|
36
|
+
res.status(200).send("ok");
|
|
37
|
+
});
|
|
38
|
+
let server;
|
|
39
|
+
return {
|
|
40
|
+
start() {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
server = app.listen(config.port, () => resolve(server));
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
stop() {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
if (!server) {
|
|
48
|
+
resolve();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listener.test.d.ts","sourceRoot":"","sources":["../src/listener.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createTeamsServer } from "./listener.js";
|
|
3
|
+
/**
|
|
4
|
+
* Regression coverage for the crash-resilience fix: a failed turn (e.g. a Bot
|
|
5
|
+
* Connector 401) must be contained, not become an unhandled rejection that
|
|
6
|
+
* crashes the whole process. The handler `.catch`es and 500s instead.
|
|
7
|
+
*/
|
|
8
|
+
describe("createTeamsServer", () => {
|
|
9
|
+
async function listen(adapter) {
|
|
10
|
+
const server = createTeamsServer({
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
|
+
adapter: adapter,
|
|
13
|
+
port: 0,
|
|
14
|
+
onTurnContext: async () => { },
|
|
15
|
+
});
|
|
16
|
+
const httpServer = await server.start();
|
|
17
|
+
const { port } = httpServer.address();
|
|
18
|
+
return { server, port };
|
|
19
|
+
}
|
|
20
|
+
it("contains a rejecting adapter.process and responds 500 (no crash)", async () => {
|
|
21
|
+
const process = vi
|
|
22
|
+
.fn()
|
|
23
|
+
.mockRejectedValue(new Error("Unknown error type: 401 denied"));
|
|
24
|
+
const { server, port } = await listen({ process });
|
|
25
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/messages`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ type: "message", text: "hi" }),
|
|
29
|
+
});
|
|
30
|
+
// Pre-fix this request hung forever (the rejection was `void`ed and `res`
|
|
31
|
+
// was never written); now it is caught and answered.
|
|
32
|
+
expect(res.status).toBe(500);
|
|
33
|
+
expect(process).toHaveBeenCalledTimes(1);
|
|
34
|
+
await server.stop();
|
|
35
|
+
});
|
|
36
|
+
it("serves the /healthz liveness probe", async () => {
|
|
37
|
+
const { server, port } = await listen({ process: vi.fn() });
|
|
38
|
+
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
|
|
39
|
+
expect(res.status).toBe(200);
|
|
40
|
+
expect(await res.text()).toBe("ok");
|
|
41
|
+
await server.stop();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-Teams-message streamed-by-edit state machine.
|
|
3
|
+
*
|
|
4
|
+
* Teams' baseline streaming model (the one the M365 Agents Playground and the
|
|
5
|
+
* Bot Framework connector support without native token streaming) is
|
|
6
|
+
* **post-then-edit**: send one message activity, then `updateActivity` it as the
|
|
7
|
+
* text grows. This wraps a single such message and lets a caller feed it the
|
|
8
|
+
* growing buffer of a streaming reply.
|
|
9
|
+
*
|
|
10
|
+
* Edits are throttled (Teams rate-limits activity updates) and *serialised*
|
|
11
|
+
* through a per-message promise queue so an in-flight edit of "AL" can't be
|
|
12
|
+
* overtaken by a later edit of "ALPHA" and leave the message reading "AL".
|
|
13
|
+
*
|
|
14
|
+
* Nothing here knows about AG-UI or the M365 SDK. It's a pure primitive:
|
|
15
|
+
* "give me text, I'll keep one Teams message in sync with it." The caller
|
|
16
|
+
* supplies `post` (first send, returns the activity id), `update` (subsequent
|
|
17
|
+
* edits), and an optional `typing` hook fired once before the first post.
|
|
18
|
+
*/
|
|
19
|
+
export interface TeamsMessageStreamConfig {
|
|
20
|
+
/** First send. Returns the posted activity id (used for later edits). */
|
|
21
|
+
post: (text: string) => Promise<string>;
|
|
22
|
+
/** Edit the posted activity to `text`. */
|
|
23
|
+
update: (id: string, text: string) => Promise<void>;
|
|
24
|
+
/** Optional: fire a typing indicator once, before the first post. */
|
|
25
|
+
typing?: () => Promise<void>;
|
|
26
|
+
/** Minimum gap between consecutive flushes, in ms (defaults to 700). */
|
|
27
|
+
minIntervalMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class TeamsMessageStream {
|
|
30
|
+
private buffer;
|
|
31
|
+
private posted;
|
|
32
|
+
private id;
|
|
33
|
+
private queue;
|
|
34
|
+
private lastFlushedAt;
|
|
35
|
+
private flushTimer;
|
|
36
|
+
private readonly minIntervalMs;
|
|
37
|
+
private readonly config;
|
|
38
|
+
constructor(config: TeamsMessageStreamConfig);
|
|
39
|
+
/** Replace the in-flight buffer (callers pass the accumulated text). */
|
|
40
|
+
append(text: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Mark the stream done: cancel any pending throttled flush, enqueue a final
|
|
43
|
+
* flush, and resolve once the whole queue has drained. The posted activity
|
|
44
|
+
* then reflects the final buffer. Returns the activity id (or `undefined` if
|
|
45
|
+
* nothing was ever posted, e.g. an empty stream).
|
|
46
|
+
*/
|
|
47
|
+
finish(): Promise<string | undefined>;
|
|
48
|
+
private scheduleFlush;
|
|
49
|
+
private enqueueFlush;
|
|
50
|
+
private flushNow;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=message-stream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-stream.d.ts","sourceRoot":"","sources":["../src/message-stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,wBAAwB;IACvC,yEAAyE;IACzE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,0CAA0C;IAC1C,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAID,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,EAAE,CAAqB;IAC/B,OAAO,CAAC,KAAK,CAAoC;IACjD,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAA4C;IAC9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;gBAEtC,MAAM,EAAE,wBAAwB;IAK5C,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM1B;;;;;OAKG;IACG,MAAM,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAU3C,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,YAAY;YAIN,QAAQ;CAsBvB"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const DEFAULT_MIN_INTERVAL_MS = 700;
|
|
2
|
+
export class TeamsMessageStream {
|
|
3
|
+
buffer = "";
|
|
4
|
+
posted = "";
|
|
5
|
+
id;
|
|
6
|
+
queue = Promise.resolve();
|
|
7
|
+
lastFlushedAt = 0;
|
|
8
|
+
flushTimer;
|
|
9
|
+
minIntervalMs;
|
|
10
|
+
config;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.minIntervalMs = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
14
|
+
}
|
|
15
|
+
/** Replace the in-flight buffer (callers pass the accumulated text). */
|
|
16
|
+
append(text) {
|
|
17
|
+
if (text === this.buffer)
|
|
18
|
+
return;
|
|
19
|
+
this.buffer = text;
|
|
20
|
+
this.scheduleFlush();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Mark the stream done: cancel any pending throttled flush, enqueue a final
|
|
24
|
+
* flush, and resolve once the whole queue has drained. The posted activity
|
|
25
|
+
* then reflects the final buffer. Returns the activity id (or `undefined` if
|
|
26
|
+
* nothing was ever posted, e.g. an empty stream).
|
|
27
|
+
*/
|
|
28
|
+
async finish() {
|
|
29
|
+
if (this.flushTimer) {
|
|
30
|
+
clearTimeout(this.flushTimer);
|
|
31
|
+
this.flushTimer = undefined;
|
|
32
|
+
}
|
|
33
|
+
this.enqueueFlush();
|
|
34
|
+
await this.queue;
|
|
35
|
+
return this.id;
|
|
36
|
+
}
|
|
37
|
+
scheduleFlush() {
|
|
38
|
+
if (this.flushTimer)
|
|
39
|
+
return;
|
|
40
|
+
const elapsed = Date.now() - this.lastFlushedAt;
|
|
41
|
+
const delay = Math.max(0, this.minIntervalMs - elapsed);
|
|
42
|
+
this.flushTimer = setTimeout(() => {
|
|
43
|
+
this.flushTimer = undefined;
|
|
44
|
+
this.enqueueFlush();
|
|
45
|
+
}, delay);
|
|
46
|
+
}
|
|
47
|
+
enqueueFlush() {
|
|
48
|
+
this.queue = this.queue.then(() => this.flushNow());
|
|
49
|
+
}
|
|
50
|
+
async flushNow() {
|
|
51
|
+
const text = this.buffer;
|
|
52
|
+
if (text === this.posted)
|
|
53
|
+
return;
|
|
54
|
+
// Don't post an empty first message; wait for real content.
|
|
55
|
+
if (this.id === undefined && text.trim().length === 0)
|
|
56
|
+
return;
|
|
57
|
+
this.posted = text;
|
|
58
|
+
try {
|
|
59
|
+
if (this.id === undefined) {
|
|
60
|
+
if (this.config.typing)
|
|
61
|
+
await this.config.typing();
|
|
62
|
+
this.id = await this.config.post(text);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await this.config.update(this.id, text);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// A single failed edit shouldn't sink the stream; reset `posted` so the
|
|
70
|
+
// next flush retries with the latest buffer.
|
|
71
|
+
this.posted = "";
|
|
72
|
+
console.error("[teams-message-stream] flush failed:", err);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
this.lastFlushedAt = Date.now();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-stream.test.d.ts","sourceRoot":"","sources":["../src/message-stream.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { TeamsMessageStream } from "./message-stream.js";
|
|
3
|
+
describe("TeamsMessageStream", () => {
|
|
4
|
+
it("posts once with the final text for a single synchronous burst", async () => {
|
|
5
|
+
const post = vi.fn(async () => "act-1");
|
|
6
|
+
const update = vi.fn(async () => { });
|
|
7
|
+
const typing = vi.fn(async () => { });
|
|
8
|
+
const s = new TeamsMessageStream({ post, update, typing });
|
|
9
|
+
s.append("Hello");
|
|
10
|
+
s.append("Hello world");
|
|
11
|
+
const id = await s.finish();
|
|
12
|
+
expect(id).toBe("act-1");
|
|
13
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
14
|
+
expect(post).toHaveBeenCalledWith("Hello world");
|
|
15
|
+
expect(update).not.toHaveBeenCalled();
|
|
16
|
+
expect(typing).toHaveBeenCalledTimes(1);
|
|
17
|
+
});
|
|
18
|
+
it("never posts for an empty stream", async () => {
|
|
19
|
+
const post = vi.fn(async () => "x");
|
|
20
|
+
const update = vi.fn(async () => { });
|
|
21
|
+
const s = new TeamsMessageStream({ post, update });
|
|
22
|
+
const id = await s.finish();
|
|
23
|
+
expect(id).toBeUndefined();
|
|
24
|
+
expect(post).not.toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
it("posts first, then edits the same message as the buffer grows", async () => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
try {
|
|
29
|
+
const post = vi.fn(async () => "act-1");
|
|
30
|
+
const update = vi.fn(async () => { });
|
|
31
|
+
const s = new TeamsMessageStream({ post, update, minIntervalMs: 100 });
|
|
32
|
+
s.append("A");
|
|
33
|
+
await vi.advanceTimersByTimeAsync(150); // let the throttled flush fire
|
|
34
|
+
expect(post).toHaveBeenCalledWith("A");
|
|
35
|
+
s.append("AB");
|
|
36
|
+
const id = await s.finish();
|
|
37
|
+
expect(id).toBe("act-1");
|
|
38
|
+
expect(update).toHaveBeenCalledWith("act-1", "AB");
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
vi.useRealTimers();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BotNode } from "@copilotkit/channels-ui";
|
|
2
|
+
/** Teams attachment content type for an Adaptive Card. */
|
|
3
|
+
export declare const ADAPTIVE_CARD_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive";
|
|
4
|
+
/** A minimally-typed Adaptive Card (1.5). Elements/actions are open bags: the
|
|
5
|
+
* schema is large and we only emit a curated subset. */
|
|
6
|
+
export interface AdaptiveCard {
|
|
7
|
+
type: "AdaptiveCard";
|
|
8
|
+
$schema: string;
|
|
9
|
+
version: string;
|
|
10
|
+
body: CardElement[];
|
|
11
|
+
actions?: CardAction[];
|
|
12
|
+
}
|
|
13
|
+
type CardElement = Record<string, unknown>;
|
|
14
|
+
type CardAction = Record<string, unknown>;
|
|
15
|
+
/**
|
|
16
|
+
* Render a cross-platform component IR tree (already expanded by `renderToIR`
|
|
17
|
+
* and pre-bound by the action registry, so event props are `{ id }`) into a
|
|
18
|
+
* Teams **Adaptive Card** (1.5).
|
|
19
|
+
*
|
|
20
|
+
* Structural nodes map to body elements (`<Header>`→bold `TextBlock`,
|
|
21
|
+
* `<Section>`/`<Markdown>`→wrapped `TextBlock`, `<Fields>`→`FactSet`,
|
|
22
|
+
* `<Table>`→native `Table`, `<Image>`→`Image`). Interactive nodes split by
|
|
23
|
+
* Adaptive Card shape: `<Button>`→a top-level `Action.Submit` (per the V1
|
|
24
|
+
* decision to use `Action.Submit`), while `<Input>`/`<Select>` become
|
|
25
|
+
* `Input.Text`/`Input.ChoiceSet` in the body. Each action/input carries the
|
|
26
|
+
* registry-stamped opaque id in its `data`/`id` so a later interaction can be
|
|
27
|
+
* decoded back into the engine (round-trip is a follow-up; rendering is here).
|
|
28
|
+
*
|
|
29
|
+
* The renderer is total: unknown intrinsics are skipped. Collections clamp and
|
|
30
|
+
* text truncates to {@link TEAMS_LIMITS} so the card stays within Teams' payload
|
|
31
|
+
* ceiling.
|
|
32
|
+
*/
|
|
33
|
+
export declare function renderAdaptiveCard(ir: BotNode[]): AdaptiveCard;
|
|
34
|
+
/**
|
|
35
|
+
* Does this IR collapse to plain text (no structural or interactive elements)?
|
|
36
|
+
* Such replies are sent as a normal Teams text activity rather than wrapped in
|
|
37
|
+
* an Adaptive Card. A bare `Echo: hi` shouldn't render as a card.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isPlainText(ir: BotNode[]): boolean;
|
|
40
|
+
/** Plain-text projection of an IR tree (depth-first text, blocks joined). */
|
|
41
|
+
export declare function collectPlainText(ir: BotNode[]): string;
|
|
42
|
+
export {};
|
|
43
|
+
//# sourceMappingURL=adaptive-card.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adaptive-card.d.ts","sourceRoot":"","sources":["../../src/render/adaptive-card.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAGvD,0DAA0D;AAC1D,eAAO,MAAM,0BAA0B,4CACI,CAAC;AAE5C;yDACyD;AACzD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,WAAW,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,UAAU,EAAE,CAAC;CACxB;AACD,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC3C,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAK1C;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,YAAY,CAc9D;AAsVD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,OAAO,CAsBlD;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,MAAM,CAMtD"}
|