@dbx-tools/teams 0.3.39

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Interceptor defaults for the Teams plugin.
3
+ *
4
+ * Building a card is a pure transform, but it still runs through
5
+ * `Plugin.execute()` so it shares the app's telemetry / timeout posture; the
6
+ * one operation that does I/O is posting a card to a Teams incoming webhook.
7
+ * The settings are kept here rather than at the call sites so the caching /
8
+ * retry / timeout posture of each is reviewable in one place.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /**
14
+ * The `PluginExecuteConfig` slice this package sets. Mirrored structurally
15
+ * because AppKit's `PluginExecuteConfig` lives behind a subpath its `exports`
16
+ * map does not publish, so the nominal type cannot be imported. Written as a
17
+ * type alias rather than an interface so it stays assignable to the nominal
18
+ * type's index signature.
19
+ */
20
+ export type TeamsExecuteConfig = {
21
+ cache?: { enabled?: boolean; ttl?: number; cacheKey?: (string | number | object)[] };
22
+ retry?: { enabled?: boolean; attempts?: number; initialDelay?: number; maxDelay?: number };
23
+ timeout?: number;
24
+ };
25
+
26
+ /**
27
+ * The `PluginExecutionSettings` shape accepted by AppKit's `Plugin.execute()`.
28
+ * Mirrored structurally for the same reason as {@link TeamsExecuteConfig}.
29
+ */
30
+ export type TeamsExecutionSettings = {
31
+ default: TeamsExecuteConfig;
32
+ user?: TeamsExecuteConfig;
33
+ };
34
+
35
+ /** Ceiling on how long a single card build may take. */
36
+ export const BUILD_TIMEOUT_MS = 5_000;
37
+
38
+ /** Ceiling on how long a single webhook POST may take. */
39
+ export const POST_TIMEOUT_MS = 15_000;
40
+
41
+ /** Attempts allowed for a webhook POST, including the first. */
42
+ export const POST_ATTEMPTS = 3;
43
+
44
+ /** Execution settings for building a card (a pure, in-process transform). */
45
+ export const TEAMS_BUILD_SETTINGS: TeamsExecutionSettings = {
46
+ default: {
47
+ // Cache disabled: the build is cheap and deterministic, so a cache would
48
+ // add a cross-identity key surface for no measurable saving.
49
+ cache: { enabled: false },
50
+ // Retry disabled: the transform performs no I/O, so a failure is
51
+ // deterministic and a second attempt would fail identically.
52
+ retry: { enabled: false },
53
+ timeout: BUILD_TIMEOUT_MS,
54
+ },
55
+ };
56
+
57
+ /**
58
+ * Ceiling on one conversation turn. Generous relative to the other two: a turn
59
+ * spans a full agent call (model latency plus any tool the agent runs), so it is
60
+ * bounded by the same order of magnitude as a chat request rather than a
61
+ * transform.
62
+ */
63
+ export const TURN_TIMEOUT_MS = 120_000;
64
+
65
+ /** Execution settings for one Teams conversation turn (an agent call). */
66
+ export const TEAMS_TURN_SETTINGS: TeamsExecutionSettings = {
67
+ default: {
68
+ // Cache disabled: a turn is conversational and stateful - the same text in
69
+ // a different conversation must not replay an earlier card.
70
+ cache: { enabled: false },
71
+ // Retry disabled: a turn may have run tools with side effects before it
72
+ // failed, and re-running the model would double them.
73
+ retry: { enabled: false },
74
+ timeout: TURN_TIMEOUT_MS,
75
+ },
76
+ };
77
+
78
+ /** Execution settings for posting a card to a Teams incoming webhook. */
79
+ export const TEAMS_POST_SETTINGS: TeamsExecutionSettings = {
80
+ default: {
81
+ // Cache disabled: a post is a side effect, not a value. Replaying a cached
82
+ // result would report a delivery that never happened.
83
+ cache: { enabled: false },
84
+ // Retry enabled: a webhook POST is idempotent enough that a transient 5xx
85
+ // or timeout at the Teams edge is worth one or two more attempts.
86
+ retry: { enabled: true, attempts: POST_ATTEMPTS },
87
+ timeout: POST_TIMEOUT_MS,
88
+ },
89
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The Teams messaging endpoint's turn: what happens between Azure Bot Service
3
+ * POSTing an activity and a card appearing in the channel.
4
+ *
5
+ * Split out from `conversation.ts` because the two callers have genuinely
6
+ * different contracts. `conversation.ts` runs a turn and RETURNS the reply, which
7
+ * is what a direct HTTP caller (or the in-repo preview UI) wants. A real channel
8
+ * cannot work that way: Bot Service ignores the response body and expects a fast
9
+ * `200`, so this module acknowledges first and delivers the reply out-of-band
10
+ * through the Connector API.
11
+ *
12
+ * The sequence for a `message` activity:
13
+ *
14
+ * 1. validate the inbound JWT and pin the reply destination to the
15
+ * token's `serviceUrl` (see `auth.ts`);
16
+ * 2. return `200` immediately - Bot Service retries an activity it thinks
17
+ * timed out, and a duplicate turn means a duplicate card;
18
+ * 3. show the typing indicator, run the agent, and POST the card back.
19
+ *
20
+ * Step 2 is why this is fire-and-forget rather than awaited: a card-producing
21
+ * agent turn takes seconds to tens of seconds, far longer than the ~15s Bot
22
+ * Service allows a bot to acknowledge.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { error, log } from "@dbx-tools/shared-core";
28
+ import { activity as activityContract } from "@dbx-tools/shared-teams";
29
+ import { connectorToken, isAllowedServiceUrl } from "./auth";
30
+ import { sendActivity, sendTyping } from "./connector";
31
+ import { runCardTurn, type CardAgentLike, type CardContextFactory } from "./conversation";
32
+
33
+ const logger = log.logger("teams:messaging");
34
+
35
+ /**
36
+ * Message shown in the channel when a turn fails.
37
+ *
38
+ * A bot that silently drops a failed turn looks broken - the user sees their
39
+ * message land and nothing come back, forever. A short apology is posted instead
40
+ * so the conversation stays legible; the real error goes to the logs.
41
+ */
42
+ const FAILURE_TEXT = "Sorry - I could not put together an answer for that. Please try again.";
43
+
44
+ /** Resolved bot credentials a delivered turn needs. */
45
+ export interface BotCredentials {
46
+ /** Entra app (client) id of the bot registration. */
47
+ appId: string;
48
+ /** Client secret for {@link appId}. */
49
+ appPassword: string;
50
+ /** Tenant id for a single-tenant bot. */
51
+ appTenantId?: string;
52
+ }
53
+
54
+ /** Everything {@link deliverTurn} needs to answer one inbound activity. */
55
+ export interface DeliverTurnOptions {
56
+ /** The agent that composes the card. */
57
+ agent: CardAgentLike;
58
+ /** The validated inbound activity. */
59
+ activity: activityContract.Activity;
60
+ /** Bot credentials used to fetch the outbound Connector token. */
61
+ credentials: BotCredentials;
62
+ /**
63
+ * Reply destination, already validated against the inbound token. Passing this
64
+ * explicitly (rather than reading `activity.serviceUrl`) keeps the security
65
+ * decision in the caller, where the token is in scope.
66
+ */
67
+ serviceUrl: string;
68
+ /**
69
+ * Builds the agent's per-turn request context, so the delivered turn has the
70
+ * same tool reach (Genie and every other user-scoped tool) as a chat turn.
71
+ */
72
+ createRequestContext?: CardContextFactory;
73
+ /** Cancels the turn (process shutdown, or a test tearing down). */
74
+ signal?: AbortSignal;
75
+ }
76
+
77
+ /**
78
+ * Read the reply destination off an inbound activity, if it names a usable one.
79
+ *
80
+ * `serviceUrl` rides on the activity as a plain string, so it is validated
81
+ * against the verified token's own `serviceurl` claim before anything
82
+ * authenticated is sent there.
83
+ */
84
+ export const resolveServiceUrl = (
85
+ activity: activityContract.Activity,
86
+ tokenServiceUrl?: string,
87
+ ): string | null => {
88
+ const raw = (activity as { serviceUrl?: unknown }).serviceUrl;
89
+ const candidate = typeof raw === "string" ? raw.trim() : "";
90
+ if (!candidate) return null;
91
+ return isAllowedServiceUrl(candidate, tokenServiceUrl) ? candidate : null;
92
+ };
93
+
94
+ /**
95
+ * Run one turn and deliver the card back through the Connector API.
96
+ *
97
+ * Awaited by nobody on the request path (the route has already answered `200`),
98
+ * so this owns its own error handling: a failure posts {@link FAILURE_TEXT} to
99
+ * the channel and is logged, never rethrown into an unhandled rejection.
100
+ */
101
+ export const deliverTurn = async (options: DeliverTurnOptions): Promise<void> => {
102
+ const { agent, activity, credentials, serviceUrl } = options;
103
+ const conversationId = activity.conversation?.id;
104
+ if (!conversationId) {
105
+ logger.warn("dropping activity with no conversation id");
106
+ return;
107
+ }
108
+
109
+ const base = {
110
+ serviceUrl,
111
+ conversationId,
112
+ ...(activity.id ? { replyToId: activity.id } : {}),
113
+ ...(options.signal ? { signal: options.signal } : {}),
114
+ };
115
+
116
+ let token: string;
117
+ try {
118
+ token = await connectorToken({
119
+ appId: credentials.appId,
120
+ appPassword: credentials.appPassword,
121
+ ...(credentials.appTenantId ? { appTenantId: credentials.appTenantId } : {}),
122
+ ...(options.signal ? { signal: options.signal } : {}),
123
+ });
124
+ } catch (err) {
125
+ // No token means nothing can be delivered - not even the apology - so this
126
+ // is the one failure that can only be logged.
127
+ logger.error("could not obtain a connector token", { error: error.errorMessage(err) });
128
+ return;
129
+ }
130
+
131
+ const target = { ...base, token };
132
+ await sendTyping(target);
133
+
134
+ try {
135
+ const activities = await runCardTurn(agent, activity, {
136
+ ...(options.createRequestContext
137
+ ? { createRequestContext: options.createRequestContext }
138
+ : {}),
139
+ ...(options.signal ? { signal: options.signal } : {}),
140
+ });
141
+ for (const reply of activities) {
142
+ await sendActivity(reply, target);
143
+ }
144
+ logger.info("turn delivered", { conversation: conversationId, replies: activities.length });
145
+ } catch (err) {
146
+ logger.error("turn failed", { conversation: conversationId, error: error.errorMessage(err) });
147
+ try {
148
+ await sendActivity({ type: "message", text: FAILURE_TEXT }, target);
149
+ } catch (postErr) {
150
+ logger.error("could not report the failure to the channel", {
151
+ error: error.errorMessage(postErr),
152
+ });
153
+ }
154
+ }
155
+ };