@arbium/enterprise-agent-connect 0.0.0-stage → 0.1.1

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 (137) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +117 -2
  3. package/dist/connectors/dell-techdirect/connector.json +39 -0
  4. package/dist/connectors/dell-techdirect/index.js +7 -0
  5. package/dist/connectors/dell-techdirect/index.js.map +1 -0
  6. package/dist/connectors/dell-techdirect/tools.js +49 -0
  7. package/dist/connectors/dell-techdirect/tools.js.map +1 -0
  8. package/dist/connectors/dell-techdirect/vendor.js +57 -0
  9. package/dist/connectors/dell-techdirect/vendor.js.map +1 -0
  10. package/dist/connectors/entra/connector.json +85 -0
  11. package/dist/connectors/entra/index.js +4 -0
  12. package/dist/connectors/entra/index.js.map +1 -0
  13. package/dist/connectors/entra/tools.js +262 -0
  14. package/dist/connectors/entra/tools.js.map +1 -0
  15. package/dist/connectors/fedex/connector.json +118 -0
  16. package/dist/connectors/fedex/index.js +7 -0
  17. package/dist/connectors/fedex/index.js.map +1 -0
  18. package/dist/connectors/fedex/tools.js +152 -0
  19. package/dist/connectors/fedex/tools.js.map +1 -0
  20. package/dist/connectors/fedex/vendor.js +98 -0
  21. package/dist/connectors/fedex/vendor.js.map +1 -0
  22. package/dist/connectors/http/index.js +235 -0
  23. package/dist/connectors/http/index.js.map +1 -0
  24. package/dist/connectors/intune/connector.json +106 -0
  25. package/dist/connectors/intune/index.js +4 -0
  26. package/dist/connectors/intune/index.js.map +1 -0
  27. package/dist/connectors/intune/tools.js +416 -0
  28. package/dist/connectors/intune/tools.js.map +1 -0
  29. package/dist/connectors/jira/connector.json +129 -0
  30. package/dist/connectors/jira/index.js +7 -0
  31. package/dist/connectors/jira/index.js.map +1 -0
  32. package/dist/connectors/jira/jql.js +67 -0
  33. package/dist/connectors/jira/jql.js.map +1 -0
  34. package/dist/connectors/jira/tools.js +464 -0
  35. package/dist/connectors/jira/tools.js.map +1 -0
  36. package/dist/connectors/jira/vendor.js +94 -0
  37. package/dist/connectors/jira/vendor.js.map +1 -0
  38. package/dist/connectors/microsoft/client.js +434 -0
  39. package/dist/connectors/microsoft/client.js.map +1 -0
  40. package/dist/connectors/microsoft/provider.json +118 -0
  41. package/dist/connectors/outlook-calendar/connector.json +136 -0
  42. package/dist/connectors/outlook-calendar/index.js +4 -0
  43. package/dist/connectors/outlook-calendar/index.js.map +1 -0
  44. package/dist/connectors/outlook-calendar/tools.js +398 -0
  45. package/dist/connectors/outlook-calendar/tools.js.map +1 -0
  46. package/dist/connectors/outlook-mail/connector.json +154 -0
  47. package/dist/connectors/outlook-mail/index.js +4 -0
  48. package/dist/connectors/outlook-mail/index.js.map +1 -0
  49. package/dist/connectors/outlook-mail/tools.js +236 -0
  50. package/dist/connectors/outlook-mail/tools.js.map +1 -0
  51. package/dist/connectors/salesforce/connector.json +122 -0
  52. package/dist/connectors/salesforce/index.js +7 -0
  53. package/dist/connectors/salesforce/index.js.map +1 -0
  54. package/dist/connectors/salesforce/soql.js +60 -0
  55. package/dist/connectors/salesforce/soql.js.map +1 -0
  56. package/dist/connectors/salesforce/tools.js +234 -0
  57. package/dist/connectors/salesforce/tools.js.map +1 -0
  58. package/dist/connectors/salesforce/vendor.js +107 -0
  59. package/dist/connectors/salesforce/vendor.js.map +1 -0
  60. package/dist/connectors/sharepoint-files/connector.json +194 -0
  61. package/dist/connectors/sharepoint-files/excel.js +300 -0
  62. package/dist/connectors/sharepoint-files/excel.js.map +1 -0
  63. package/dist/connectors/sharepoint-files/index.js +12 -0
  64. package/dist/connectors/sharepoint-files/index.js.map +1 -0
  65. package/dist/connectors/sharepoint-files/tools.js +365 -0
  66. package/dist/connectors/sharepoint-files/tools.js.map +1 -0
  67. package/dist/connectors/sharepoint-files/vendor.js +167 -0
  68. package/dist/connectors/sharepoint-files/vendor.js.map +1 -0
  69. package/dist/connectors/teams/connector.json +298 -0
  70. package/dist/connectors/teams/index.js +5 -0
  71. package/dist/connectors/teams/index.js.map +1 -0
  72. package/dist/connectors/teams/tools.js +391 -0
  73. package/dist/connectors/teams/tools.js.map +1 -0
  74. package/dist/packages/connector-kit/src/branding.js +102 -0
  75. package/dist/packages/connector-kit/src/branding.js.map +1 -0
  76. package/dist/packages/connector-kit/src/certificate.js +84 -0
  77. package/dist/packages/connector-kit/src/certificate.js.map +1 -0
  78. package/dist/packages/connector-kit/src/common.js +107 -0
  79. package/dist/packages/connector-kit/src/common.js.map +1 -0
  80. package/dist/packages/connector-kit/src/connector.js +492 -0
  81. package/dist/packages/connector-kit/src/connector.js.map +1 -0
  82. package/dist/packages/connector-kit/src/oauth.js +79 -0
  83. package/dist/packages/connector-kit/src/oauth.js.map +1 -0
  84. package/dist/packages/connector-kit/src/types.js +20 -0
  85. package/dist/packages/connector-kit/src/types.js.map +1 -0
  86. package/dist/packages/server/migrations/0001_tables.sql +268 -0
  87. package/dist/packages/server/migrations/0002_organizations.sql +72 -0
  88. package/dist/packages/server/migrations/0003_drop_unused.sql +3 -0
  89. package/dist/packages/server/migrations/0004_trigger_runners.sql +15 -0
  90. package/dist/packages/server/migrations/0005_run_runner.sql +3 -0
  91. package/dist/packages/server/migrations/0006_approvals.sql +36 -0
  92. package/dist/packages/server/migrations/rollback/to-legacy.sql +87 -0
  93. package/dist/packages/server/src/api.js +684 -0
  94. package/dist/packages/server/src/api.js.map +1 -0
  95. package/dist/packages/server/src/app.js +444 -0
  96. package/dist/packages/server/src/app.js.map +1 -0
  97. package/dist/packages/server/src/builtin.js +24 -0
  98. package/dist/packages/server/src/builtin.js.map +1 -0
  99. package/dist/packages/server/src/consent.js +36 -0
  100. package/dist/packages/server/src/consent.js.map +1 -0
  101. package/dist/packages/server/src/index.js +129 -0
  102. package/dist/packages/server/src/index.js.map +1 -0
  103. package/dist/packages/server/src/migrate.js +134 -0
  104. package/dist/packages/server/src/migrate.js.map +1 -0
  105. package/dist/packages/server/src/openapi.js +101 -0
  106. package/dist/packages/server/src/openapi.js.map +1 -0
  107. package/dist/packages/server/src/secrets.js +76 -0
  108. package/dist/packages/server/src/secrets.js.map +1 -0
  109. package/dist/packages/server/src/server.js +69 -0
  110. package/dist/packages/server/src/server.js.map +1 -0
  111. package/dist/packages/server/src/service.js +908 -0
  112. package/dist/packages/server/src/service.js.map +1 -0
  113. package/dist/packages/server/src/store.js +411 -0
  114. package/dist/packages/server/src/store.js.map +1 -0
  115. package/dist/packages/server/src/teams/activity.js +187 -0
  116. package/dist/packages/server/src/teams/activity.js.map +1 -0
  117. package/dist/packages/server/src/teams/admin.js +699 -0
  118. package/dist/packages/server/src/teams/admin.js.map +1 -0
  119. package/dist/packages/server/src/teams/approval.js +118 -0
  120. package/dist/packages/server/src/teams/approval.js.map +1 -0
  121. package/dist/packages/server/src/teams/bot.js +124 -0
  122. package/dist/packages/server/src/teams/bot.js.map +1 -0
  123. package/dist/packages/server/src/teams/claude.js +165 -0
  124. package/dist/packages/server/src/teams/claude.js.map +1 -0
  125. package/dist/packages/server/src/teams/jwt.js +121 -0
  126. package/dist/packages/server/src/teams/jwt.js.map +1 -0
  127. package/dist/packages/server/src/teams/package.js +319 -0
  128. package/dist/packages/server/src/teams/package.js.map +1 -0
  129. package/dist/packages/server/src/teams/provision.js +505 -0
  130. package/dist/packages/server/src/teams/provision.js.map +1 -0
  131. package/dist/packages/server/src/teams/runner.js +143 -0
  132. package/dist/packages/server/src/teams/runner.js.map +1 -0
  133. package/dist/packages/server/src/teams/runtime.js +701 -0
  134. package/dist/packages/server/src/teams/runtime.js.map +1 -0
  135. package/dist/packages/server/src/teams/store.js +507 -0
  136. package/dist/packages/server/src/teams/store.js.map +1 -0
  137. package/package.json +42 -4
@@ -0,0 +1,701 @@
1
+ import { effectiveCapabilities } from "../../../connector-kit/src/connector.js";
2
+ import { credentialAad } from "../secrets.js";
3
+ import { ConnectError } from "../../../connector-kit/src/types.js";
4
+ import { decide, guid, installChange, placeOf, plainText, promptText, newReplyToken, hashReplyToken, } from "./activity.js";
5
+ import { approvalCard, cardAttachment, invokeCard, invokeError, invokeMessage, MAX_COMMENT, } from "./approval.js";
6
+ import { validServiceUrl } from "./bot.js";
7
+ import { openTriggerSecret } from "./runner.js";
8
+ import { Unauthorized } from "./jwt.js";
9
+ // Teams' requests to the agents' bots, the queue they feed, and the sessions that answer them
10
+ // (Claude Managed Agents or another runner, runner.ts). docs/teams.md 6.
11
+ const SESSION_MAX_AGE_MS = 12 * 3600_000;
12
+ const SESSION_MAX_TURNS = 20;
13
+ const THREAD_CONTEXT = 20;
14
+ const FAILED_NOTICE = "Sorry, I couldn't finish this. Your admin can see what happened.";
15
+ const BUDGET_NOTICE = "I stopped because this answer reached its spending limit.";
16
+ const NO_REPLY_NOTICE = "I could not answer this one. Ask your admin to check my Teams settings.";
17
+ // A turn still running after this is stopped: it holds a slot and the thread's later messages.
18
+ const STUCK_MS = 30 * 60_000;
19
+ // How long a passed trigger check is trusted before the next answer checks again.
20
+ const VERIFY_TTL_MS = 60_000;
21
+ // Teams activities are small; anything bigger is not Teams.
22
+ const MAX_BODY = 256 * 1024;
23
+ // What the admin sees when Microsoft's request for a bot was refused.
24
+ const REFUSED = {
25
+ "wrong recipient": "A message for another bot arrived at this bot's address.",
26
+ "wrong tenant": "Microsoft sent a message from another organization.",
27
+ "not a Teams service address": "A message came from an address that is not Microsoft Teams.",
28
+ "serviceUrl does not match the token": "A message did not match Microsoft's signature.",
29
+ "key not endorsed for this channel": "A message came from a channel other than Teams.",
30
+ };
31
+ /** The body as text, or null once it passes `max` bytes (read no further). */
32
+ export async function readCapped(req, max = MAX_BODY) {
33
+ if (Number(req.headers.get("content-length") ?? 0) > max)
34
+ return null;
35
+ if (!req.body)
36
+ return "";
37
+ const chunks = [];
38
+ let size = 0;
39
+ for await (const chunk of req.body) {
40
+ size += chunk.length;
41
+ if (size > max)
42
+ return null;
43
+ chunks.push(chunk);
44
+ }
45
+ return Buffer.concat(chunks).toString("utf8");
46
+ }
47
+ export const botAad = (org, botId) => credentialAad(org, `teams-bot:${botId}`);
48
+ const eventAad = (botId, activityId) => `agent-connect:teams-event:${botId}:${activityId}`;
49
+ /** The bot's sign-in: its app id, tenant, certificate and private key. */
50
+ export function botCredential(key, bot) {
51
+ if (!bot.botAppId)
52
+ throw new Error("The bot has no app id yet");
53
+ const secret = key.open({ ciphertext: bot.credential, keyId: bot.credentialKeyId }, botAad(bot.organizationId, bot.id));
54
+ if (!bot.config.certificate || !secret.private_key)
55
+ throw new Error("The bot has no certificate");
56
+ return {
57
+ botAppId: bot.botAppId,
58
+ tenantId: bot.tenantId,
59
+ certificate: bot.config.certificate,
60
+ privateKey: secret.private_key,
61
+ };
62
+ }
63
+ const reply = (status, body) => new Response(body === undefined ? null : JSON.stringify(body), {
64
+ status,
65
+ headers: body === undefined ? {} : { "content-type": "application/json" },
66
+ });
67
+ export function createTeamsRuntime(deps) {
68
+ const { teams, key, bots } = deps;
69
+ async function post(bot, serviceUrl, conversation, activity, replyTo) {
70
+ const path = `/v3/conversations/${encodeURIComponent(conversation)}/activities${replyTo ? `/${encodeURIComponent(replyTo)}` : ""}`;
71
+ const res = await bots.connector(botCredential(key, bot), serviceUrl, {
72
+ method: "POST",
73
+ path,
74
+ body: replyTo ? { ...activity, replyToId: replyTo } : activity,
75
+ });
76
+ // The bot was removed or blocked in a chat [4]. A team's channel can refuse a post for
77
+ // other reasons (moderation), so teams are only removed by their uninstall event.
78
+ if (res.status === 403) {
79
+ const chat = (await teams.conversations(bot.id)).find((c) => c.conversationId === conversation && c.kind !== "team");
80
+ if (chat)
81
+ await teams.removeConversation(bot.id, chat.conversationId);
82
+ }
83
+ return res;
84
+ }
85
+ // ---- inbound ----
86
+ /** POST /teams/bots/:botAppId/messages. Proven by Microsoft's token for this bot, queued,
87
+ * answered at once. */
88
+ async function inbound(botAppId, req) {
89
+ if (req.headers.has("origin"))
90
+ return reply(403);
91
+ let signed = false;
92
+ try {
93
+ // The token first: nothing from the body is read without a valid Bot Framework token.
94
+ const matches = await deps.auth.token(req.headers.get("authorization"), botAppId);
95
+ signed = true;
96
+ const raw = await readCapped(req);
97
+ if (raw === null)
98
+ return reply(413);
99
+ let activity;
100
+ try {
101
+ activity = JSON.parse(raw);
102
+ }
103
+ catch {
104
+ return reply(400);
105
+ }
106
+ const serviceUrl = activity.serviceUrl ?? "";
107
+ matches({ serviceUrl, channelId: activity.channelId ?? "" });
108
+ const bot = await teams.botByAppId(botAppId);
109
+ if (!bot || bot.status === "awaiting_setup")
110
+ throw new Unauthorized("unknown bot");
111
+ if (activity.recipient?.id !== `28:${botAppId}`)
112
+ throw new Unauthorized("wrong recipient");
113
+ if (activity.channelData?.tenant?.id?.toLowerCase() !== bot.tenantId)
114
+ throw new Unauthorized("wrong tenant");
115
+ if (!validServiceUrl(serviceUrl))
116
+ throw new Unauthorized("not a Teams service address");
117
+ // A card button: answered in this response, never queued, so it starts no run whatever
118
+ // the trigger says.
119
+ if (activity.type === "invoke" && activity.name === "adaptiveCard/action")
120
+ return reply(200, await cardAction(bot, activity));
121
+ if (!activity.id)
122
+ return reply(200);
123
+ const place = placeOf(activity);
124
+ const sealed = key.seal({ activity: JSON.stringify(activity) }, eventAad(bot.id, activity.id));
125
+ const fresh = await teams.insertEvent({
126
+ botId: bot.id,
127
+ activityId: activity.id,
128
+ type: activity.type ?? "unknown",
129
+ conversationId: place?.conversationId ?? activity.conversation?.id ?? "",
130
+ threadId: place?.threadId ?? null,
131
+ fromAadId: activity.from?.aadObjectId ?? null,
132
+ payload: sealed.ciphertext,
133
+ payloadKeyId: sealed.keyId,
134
+ });
135
+ if (fresh)
136
+ kick();
137
+ return reply(200);
138
+ }
139
+ catch (error) {
140
+ if (!(error instanceof Unauthorized))
141
+ throw error;
142
+ console.warn(`[teams] refused a request for bot ${botAppId}: ${error.message}`);
143
+ // Only a request with a valid token is shown to the admin; anyone can send the rest.
144
+ const why = REFUSED[error.message];
145
+ if (signed && why)
146
+ await teams.refused(botAppId, why);
147
+ return reply(403);
148
+ }
149
+ }
150
+ // ---- approval cards (approval.ts) ----
151
+ /** An Action.Execute click on an approval card, after the token, bot, recipient, tenant and
152
+ * service address checks (the request is authenticated by a Bot Framework token; its body is
153
+ * not signed). Counts only from the approver (matched on the Entra object id Teams sends), in
154
+ * the team or chat the card was posted in, once, while pending and unexpired; every click is
155
+ * audited, accepted or not. */
156
+ async function cardAction(bot, a) {
157
+ const value = (a.value ?? {});
158
+ const verb = value.action?.verb;
159
+ const data = value.action?.data ?? {};
160
+ const id = guid(data.approval);
161
+ const clicker = guid(a.from?.aadObjectId);
162
+ // The team (for a channel) or the chat the click came from.
163
+ const conversationId = placeOf(a)?.conversationId ?? null;
164
+ const audit = async (decision, reason) => {
165
+ const state = await teams.agentState(bot.agentId);
166
+ await deps.store.audit({
167
+ organizationId: bot.organizationId,
168
+ agentId: bot.agentId,
169
+ agentName: state?.agentName ?? "",
170
+ connectorId: "teams",
171
+ tool: "teams:approval",
172
+ target: id,
173
+ decision,
174
+ reason: reason.slice(0, 500),
175
+ vendorStatus: null,
176
+ });
177
+ };
178
+ if (value.action?.type !== "Action.Execute" ||
179
+ (verb !== "approve" && verb !== "reject") ||
180
+ !id) {
181
+ await audit("denied", `unknown card action clicked by ${clicker ?? "unknown"}`);
182
+ return invokeError("This card action is not one this agent knows");
183
+ }
184
+ const approval = conversationId
185
+ ? await teams.approvalForBot(bot.id, id, conversationId)
186
+ : null;
187
+ if (!approval) {
188
+ await audit("denied", `${verb} clicked by ${clicker ?? "unknown"} in ${conversationId ?? "an unknown place"}: no such approval for this bot there`);
189
+ return invokeMessage("This request is not known to this agent.");
190
+ }
191
+ if (!clicker || clicker !== approval.approverAadId) {
192
+ await audit("denied", `${verb} clicked by ${clicker ?? "unknown"}, not the approver`);
193
+ return invokeMessage(`Only ${approval.approverName} can answer this request.`);
194
+ }
195
+ if (approval.status !== "pending") {
196
+ await audit("denied", `${verb} clicked again: already ${approval.status}`);
197
+ return invokeCard(approvalCard(approval));
198
+ }
199
+ if (approval.expired) {
200
+ await audit("denied", `${verb} clicked after it expired`);
201
+ await updateCard(bot, approval);
202
+ return invokeCard(approvalCard(approval));
203
+ }
204
+ const comment = typeof data.comment === "string" ? data.comment.trim().slice(0, MAX_COMMENT) || null : null;
205
+ const decided = await teams.decideApproval(bot.id, approval.id, approval.conversationId, {
206
+ status: verb === "approve" ? "approved" : "rejected",
207
+ byAadId: clicker,
208
+ byName: (a.from?.name?.trim() || approval.approverName).slice(0, 200),
209
+ }, comment);
210
+ if (!decided) {
211
+ // Answered or expired in the meantime.
212
+ const now = (await teams.approvalForBot(bot.id, approval.id, approval.conversationId)) ?? approval;
213
+ await audit("denied", `${verb} clicked again: already ${now.status}`);
214
+ return invokeCard(approvalCard(now));
215
+ }
216
+ await audit("allowed", `${decided.status} by ${clicker}${comment ? " with a comment" : ""}`);
217
+ await updateCard(bot, decided);
218
+ return invokeCard(approvalCard(decided));
219
+ }
220
+ // The answer in the invoke changes only the clicker's view; editing the bot's message changes
221
+ // everyone's. A failed edit is logged, never undoes the decision.
222
+ async function updateCard(bot, approval) {
223
+ if (!approval.threadId || !approval.activityId)
224
+ return;
225
+ try {
226
+ const res = await bots.connector(botCredential(key, bot), approval.serviceUrl, {
227
+ method: "PUT",
228
+ path: `/v3/conversations/${encodeURIComponent(approval.threadId)}/activities/${encodeURIComponent(approval.activityId)}`,
229
+ body: {
230
+ type: "message",
231
+ id: approval.activityId,
232
+ attachments: [cardAttachment(approvalCard(approval))],
233
+ },
234
+ });
235
+ if (res.status < 200 || res.status >= 300)
236
+ console.error(`[teams] editing approval card ${approval.id}: ${res.status}`);
237
+ }
238
+ catch (error) {
239
+ console.error(`[teams] editing approval card ${approval.id}`, error);
240
+ }
241
+ }
242
+ // ---- the queue ----
243
+ // One drain at a time per process; a kick during one makes it look again before stopping.
244
+ let draining = null;
245
+ let again = false;
246
+ let stopped = false;
247
+ function kick() {
248
+ if (stopped)
249
+ return;
250
+ again = true;
251
+ draining ??= drain()
252
+ .catch((error) => console.error("[teams] worker", error))
253
+ .finally(() => {
254
+ draining = null;
255
+ if (again)
256
+ kick();
257
+ });
258
+ }
259
+ async function drain() {
260
+ while (again) {
261
+ again = false;
262
+ while (!stopped) {
263
+ const event = await teams.claimEvent();
264
+ if (!event)
265
+ break;
266
+ try {
267
+ await handle(event);
268
+ }
269
+ catch (error) {
270
+ console.error(`[teams] event ${event.id} failed`, error);
271
+ await teams.finishEvent(event.id, "failed", "Internal error");
272
+ }
273
+ }
274
+ }
275
+ }
276
+ async function handle(event) {
277
+ const bot = await teams.botById(event.botId);
278
+ if (!bot || !event.payload || !event.payloadKeyId)
279
+ return teams.finishEvent(event.id, "failed", "bot or payload gone");
280
+ const activity = JSON.parse(key.open({ ciphertext: event.payload, keyId: event.payloadKeyId }, eventAad(bot.id, event.activityId)).activity);
281
+ const place = placeOf(activity);
282
+ if (!place)
283
+ return teams.finishEvent(event.id, "ignored", "unknown conversation type");
284
+ const installed = installChange(activity, `28:${bot.botAppId}`);
285
+ if (installed === false) {
286
+ await teams.removeConversation(bot.id, place.conversationId);
287
+ return teams.finishEvent(event.id, "done", "removed");
288
+ }
289
+ await remember(bot, activity, place);
290
+ if (activity.type !== "message")
291
+ return teams.finishEvent(event.id, "done", installed ? "installed" : (activity.type ?? null));
292
+ return respond(bot, event, activity, place);
293
+ }
294
+ async function remember(bot, a, place) {
295
+ await teams.upsertConversation({
296
+ botId: bot.id,
297
+ conversationId: place.conversationId,
298
+ kind: place.kind,
299
+ serviceUrl: a.serviceUrl,
300
+ teamId: place.teamId,
301
+ teamGroupId: place.teamGroupId,
302
+ name: place.name,
303
+ userAadId: place.kind === "personal" ? (a.from?.aadObjectId ?? null) : null,
304
+ });
305
+ }
306
+ async function respond(bot, event, a, place) {
307
+ const [state, trigger] = await Promise.all([
308
+ teams.agentState(bot.agentId),
309
+ teams.trigger(bot.agentId),
310
+ ]);
311
+ const runner = trigger ? deps.runners.get(trigger.runner) : undefined;
312
+ const runnerUnready = !trigger?.enabled
313
+ ? null
314
+ : runner
315
+ ? await runner.unready(bot.organizationId)
316
+ : `runner ${trigger.runner} is not offered by this deployment`;
317
+ const capabilities = state?.grant && state.connection?.status === "connected"
318
+ ? effectiveCapabilities(deps.connector, state.grant.capabilities, state.connection.capabilities).map((c) => c.id)
319
+ : [];
320
+ // Where this team is installed, its group id is known even if the message lacks it.
321
+ if (place.kind === "team" && !place.teamGroupId)
322
+ place.teamGroupId =
323
+ (await teams.conversations(bot.id)).find((c) => c.conversationId === place.conversationId)
324
+ ?.teamGroupId ?? null;
325
+ const decision = decide({
326
+ activity: a,
327
+ place,
328
+ botAppId: bot.botAppId,
329
+ tenantId: bot.tenantId,
330
+ agentActive: state?.agentStatus === "active",
331
+ capabilities,
332
+ limits: state?.grant?.limits ?? {},
333
+ trigger,
334
+ runnerUnready,
335
+ counts: await teams.runCounts(bot.agentId, bot.id),
336
+ });
337
+ if (decision.action === "ignore")
338
+ return teams.finishEvent(event.id, "ignored", decision.reason);
339
+ if (decision.action === "limit") {
340
+ await limitNotice(bot, a.serviceUrl, place, place.threadId, decision.notice);
341
+ return teams.finishEvent(event.id, "ignored", decision.reason);
342
+ }
343
+ // Not named in the allowed people: only a member of the organization, never a guest.
344
+ if (decision.checkMember && !(await member(bot, a, place)))
345
+ return teams.finishEvent(event.id, "ignored", "sender is not a member of the organization");
346
+ const token = newReplyToken();
347
+ const latest = await teams.latestRun(bot.id, place.threadId);
348
+ // A session holds what it found for the person who asked; it is not reused for anyone else,
349
+ // nor by another runner than the one that started it.
350
+ const reusable = latest?.state === "idle" &&
351
+ latest.runner === trigger.runner &&
352
+ latest.requestedBy === decision.sender &&
353
+ latest.sessionId &&
354
+ latest.turns < SESSION_MAX_TURNS &&
355
+ Date.now() - Date.parse(latest.startedAt) < SESSION_MAX_AGE_MS;
356
+ let run = null;
357
+ if (reusable) {
358
+ if (!(await teams.continueRun(latest.id, a.id, hashReplyToken(token))))
359
+ return teams.finishEvent(event.id, "held", "conversation busy");
360
+ run = latest;
361
+ }
362
+ else {
363
+ run = await teams.startRun({
364
+ agentId: bot.agentId,
365
+ botId: bot.id,
366
+ conversationId: place.conversationId,
367
+ threadId: place.threadId,
368
+ triggerActivityId: a.id,
369
+ replyTokenHash: hashReplyToken(token),
370
+ requestedBy: decision.sender,
371
+ runner: trigger.runner,
372
+ });
373
+ if (!run)
374
+ return teams.finishEvent(event.id, "held", "conversation busy");
375
+ }
376
+ try {
377
+ const secret = openTriggerSecret(key, bot.organizationId, trigger);
378
+ await verified(runner, bot, state?.agentName ?? "", trigger, secret);
379
+ await deps.store.audit({
380
+ organizationId: bot.organizationId,
381
+ agentId: bot.agentId,
382
+ agentName: state?.agentName ?? "",
383
+ connectorId: "teams",
384
+ tool: "teams:run",
385
+ target: place.threadId,
386
+ decision: "allowed",
387
+ reason: `run ${run.id} ${reusable ? "continued" : "started"} for ${decision.sender}`,
388
+ vendorStatus: null,
389
+ });
390
+ await post(bot, a.serviceUrl, place.threadId, { type: "typing" });
391
+ const channelName = a.channelData?.channel?.name ?? null;
392
+ const context = reusable ? null : await threadContext(bot, place, a.id);
393
+ if (context?.note)
394
+ await teams.updateRun(run.id, { note: context.note });
395
+ const text = promptText({
396
+ botName: bot.displayName,
397
+ sender: { name: a.from?.name ?? "Someone", aadObjectId: decision.sender },
398
+ place,
399
+ channelName,
400
+ thread: context?.messages ?? null,
401
+ message: plainText(a.text ?? ""),
402
+ replyToken: token,
403
+ continuing: Boolean(reusable),
404
+ });
405
+ const input = {
406
+ org: bot.organizationId,
407
+ agentId: bot.agentId,
408
+ runId: run.id,
409
+ botId: bot.id,
410
+ requestedBy: decision.sender,
411
+ text,
412
+ replyToken: token,
413
+ budgetCents: trigger.budgetCents,
414
+ trigger: trigger,
415
+ secret,
416
+ };
417
+ if (reusable) {
418
+ await runner.continue(latest.sessionId, input);
419
+ await teams.updateRun(run.id, { state: "running" });
420
+ }
421
+ else {
422
+ const sessionId = await runner.start(input);
423
+ await teams.updateRun(run.id, { state: "running", sessionId });
424
+ }
425
+ await teams.finishEvent(event.id, "run", null);
426
+ }
427
+ catch (error) {
428
+ console.error(`[teams] run ${run.id} failed to start`, error);
429
+ await teams.updateRun(run.id, {
430
+ state: "failed",
431
+ error: (error instanceof Error ? error.message : String(error)).slice(0, 500),
432
+ });
433
+ await notice(bot, a.serviceUrl, place.threadId, FAILED_NOTICE, a.id).catch((e) => console.error(`[teams] notice for run ${run.id}`, e));
434
+ await teams.finishEvent(event.id, "failed", "could not start the response");
435
+ }
436
+ }
437
+ // The trigger, checked again before each answer (e.g. the Claude agent and vaults, see
438
+ // verifyTrigger). A refusal fails the run, which the admin sees with its reason, and is
439
+ // audited as denied.
440
+ const verifiedAt = new Map();
441
+ async function verified(runner, bot, agentName, trigger, secret) {
442
+ const k = JSON.stringify([
443
+ bot.agentId,
444
+ trigger.runner,
445
+ trigger.settings,
446
+ trigger.managedAgentId,
447
+ trigger.vaultIds,
448
+ ]);
449
+ if (Date.now() - (verifiedAt.get(k) ?? 0) < VERIFY_TTL_MS)
450
+ return;
451
+ try {
452
+ await runner.verify(trigger, bot.organizationId, secret, "answer");
453
+ }
454
+ catch (error) {
455
+ if (error instanceof ConnectError)
456
+ await deps.store.audit({
457
+ organizationId: bot.organizationId,
458
+ agentId: bot.agentId,
459
+ agentName,
460
+ connectorId: "teams",
461
+ tool: "teams:run",
462
+ target: trigger.managedAgentId ?? trigger.runner,
463
+ decision: "denied",
464
+ reason: `trigger check failed: ${error.message}`.slice(0, 500),
465
+ vendorStatus: null,
466
+ });
467
+ throw error;
468
+ }
469
+ verifiedAt.set(k, Date.now());
470
+ }
471
+ // A limit reached, said at most once an hour in the conversation: every message past it
472
+ // would otherwise get one.
473
+ async function limitNotice(bot, serviceUrl, place, threadId, text) {
474
+ if (await teams.claimLimitNotice(bot.id, place.conversationId))
475
+ await notice(bot, serviceUrl, threadId, text);
476
+ }
477
+ // What became of one response (failed to start, budget, no reply, stuck): once per run, so
478
+ // always said.
479
+ async function notice(bot, serviceUrl, threadId, text, replyTo) {
480
+ await post(bot, serviceUrl, threadId, { type: "message", text }, replyTo);
481
+ }
482
+ // The sender as Teams knows them: in the bot's own tenant, and a member, not a guest.
483
+ async function member(bot, a, place) {
484
+ if (!a.from?.id)
485
+ return false;
486
+ const res = await bots.connector(botCredential(key, bot), a.serviceUrl, {
487
+ method: "GET",
488
+ path: `/v3/conversations/${encodeURIComponent(place.conversationId)}/members/${encodeURIComponent(a.from.id)}`,
489
+ });
490
+ const m = res.data;
491
+ return (res.status === 200 && m?.tenantId?.toLowerCase() === bot.tenantId && m.userRole === "user");
492
+ }
493
+ // Earlier messages in the thread or group chat, through Graph with the bot's own token.
494
+ // Direct chats cannot be read this way; their earlier turns are in the reused session.
495
+ async function threadContext(bot, place, activityId) {
496
+ if (place.kind === "personal")
497
+ return null;
498
+ const blocked = (status) => status === 403
499
+ ? {
500
+ messages: null,
501
+ note: "Could not read earlier messages: your organization blocks apps from reading them.",
502
+ }
503
+ : { messages: null };
504
+ if (place.kind === "team") {
505
+ if (!place.teamGroupId || !place.channelId)
506
+ return null;
507
+ const root = /;messageid=(\d+)/.exec(place.threadId)?.[1];
508
+ if (!root || root === activityId)
509
+ return null;
510
+ const base = `/v1.0/teams/${place.teamGroupId}/channels/${encodeURIComponent(place.channelId)}/messages/${root}`;
511
+ const [first, replies] = await Promise.all([
512
+ bots.graph(botCredential(key, bot), base),
513
+ bots.graph(botCredential(key, bot), `${base}/replies?$top=${THREAD_CONTEXT}`),
514
+ ]);
515
+ if (first.status !== 200)
516
+ return blocked(first.status);
517
+ if (replies.status !== 200)
518
+ return blocked(replies.status);
519
+ const list = [
520
+ first.data,
521
+ ...(replies.data.value ?? []).reverse(),
522
+ ];
523
+ return {
524
+ messages: simplify(list.filter((m) => m.id !== activityId)).slice(-THREAD_CONTEXT),
525
+ };
526
+ }
527
+ const path = `/v1.0/chats/${encodeURIComponent(place.conversationId)}/messages?$top=${THREAD_CONTEXT + 1}&$orderby=createdDateTime desc`;
528
+ const res = await bots.graph(botCredential(key, bot), path);
529
+ if (res.status !== 200)
530
+ return blocked(res.status);
531
+ const list = (res.data.value ?? []).reverse();
532
+ return { messages: simplify(list.filter((m) => m.id !== activityId)).slice(-THREAD_CONTEXT) };
533
+ }
534
+ // ---- watching the sessions ----
535
+ /** One pass: finished sessions are recorded; messages held while a thread was busy go back
536
+ * into the queue once it is not. */
537
+ async function watch() {
538
+ const stale = await teams.failStaleStarts();
539
+ if (stale)
540
+ console.error(`[teams] ${stale} run(s) failed after not starting`);
541
+ for (const run of await teams.runningRuns()) {
542
+ try {
543
+ const bot = await teams.botById(run.botId);
544
+ if (!bot)
545
+ continue;
546
+ const runner = deps.runners.get(run.runner);
547
+ // The session itself is left running: no runner here can stop it.
548
+ if (!runner) {
549
+ await teams.updateRun(run.id, {
550
+ state: "failed",
551
+ error: `Runner ${run.runner} is not offered by this deployment`,
552
+ });
553
+ continue;
554
+ }
555
+ if (!(await teams.trigger(run.agentId))) {
556
+ await teams.updateRun(run.id, { state: "failed", error: "Its trigger was removed" });
557
+ continue;
558
+ }
559
+ const s = await runner.state(run, bot.organizationId);
560
+ if ("gone" in s) {
561
+ await teams.updateRun(run.id, { state: "failed", error: s.gone });
562
+ continue;
563
+ }
564
+ const costCents = s.costCents ?? undefined;
565
+ const tell = (text) => runNotice(bot, run, text);
566
+ if (s.status === "idle" && s.atBudget) {
567
+ await teams.updateRun(run.id, {
568
+ state: "failed",
569
+ error: "Stopped at its spending limit",
570
+ costCents,
571
+ });
572
+ await tell(BUDGET_NOTICE);
573
+ }
574
+ else if (s.status === "idle" && run.replyCount === 0) {
575
+ await teams.updateRun(run.id, {
576
+ state: "no_reply",
577
+ error: "Finished without replying in Teams",
578
+ costCents,
579
+ });
580
+ await tell(NO_REPLY_NOTICE);
581
+ }
582
+ else if (s.status === "idle")
583
+ await teams.updateRun(run.id, { state: "idle", costCents });
584
+ else if (s.status === "terminated")
585
+ await teams.updateRun(run.id, { state: "ended", costCents });
586
+ else if (Date.now() - Date.parse(run.replyTokenAt) > STUCK_MS) {
587
+ await runner
588
+ .stop(run.sessionId, bot.organizationId)
589
+ .catch((e) => console.error(`[teams] interrupting run ${run.id}`, e));
590
+ await teams.updateRun(run.id, {
591
+ state: "failed",
592
+ error: "Stopped after 30 minutes without finishing",
593
+ costCents,
594
+ });
595
+ await tell(FAILED_NOTICE);
596
+ }
597
+ }
598
+ catch (error) {
599
+ console.error(`[teams] watching run ${run.id}`, error);
600
+ }
601
+ }
602
+ if (await teams.requeueHeld())
603
+ kick();
604
+ }
605
+ async function runNotice(bot, run, text) {
606
+ const place = (await teams.conversations(bot.id)).find((c) => c.conversationId === run.conversationId);
607
+ if (place)
608
+ await notice(bot, place.serviceUrl, run.threadId, text, run.triggerActivityId).catch((e) => console.error(`[teams] notice for run ${run.id}`, e));
609
+ }
610
+ let timers = [];
611
+ return {
612
+ inbound,
613
+ kick,
614
+ watch,
615
+ /** Drains the queue now and then (events from a worker that stopped) and watches sessions
616
+ * every few seconds. */
617
+ start() {
618
+ stopped = false;
619
+ timers = [
620
+ setInterval(() => {
621
+ kick();
622
+ watch().catch((error) => console.error("[teams] watch", error));
623
+ }, 5000),
624
+ setInterval(() => teams.pruneEvents().catch((error) => console.error("[teams] prune", error)), 3600_000),
625
+ ];
626
+ for (const t of timers)
627
+ t.unref();
628
+ },
629
+ /** Stops claiming events and waits for the one in hand, so a rollout neither strands it
630
+ * in 'processing' nor answers it twice. */
631
+ async stop() {
632
+ for (const t of timers)
633
+ clearInterval(t);
634
+ stopped = true;
635
+ again = false;
636
+ while (draining)
637
+ await draining;
638
+ },
639
+ /** What an agent's Teams tools reach: only its own bot. */
640
+ access(bot) {
641
+ return {
642
+ places: () => teams.conversations(bot.id),
643
+ graph: (path) => bots.graph(botCredential(key, bot), path),
644
+ listChannels: (place) => bots.connector(botCredential(key, bot), place.serviceUrl, {
645
+ method: "GET",
646
+ path: `/v3/teams/${encodeURIComponent(place.teamId)}/conversations`,
647
+ }),
648
+ post: (serviceUrl, conversation, text, replyTo) => post(bot, serviceUrl, conversation, { type: "message", text, textFormat: "markdown" }, replyTo),
649
+ newThread: (place, channelId, text) => bots.connector(botCredential(key, bot), place.serviceUrl, {
650
+ method: "POST",
651
+ path: "/v3/conversations",
652
+ body: {
653
+ isGroup: true,
654
+ channelData: { channel: { id: channelId }, tenant: { id: bot.tenantId } },
655
+ activity: { type: "message", text, textFormat: "markdown" },
656
+ },
657
+ }),
658
+ postCard: (serviceUrl, conversation, card) => post(bot, serviceUrl, conversation, {
659
+ type: "message",
660
+ attachments: [cardAttachment(card)],
661
+ }),
662
+ newThreadCard: (place, channelId, card) => bots.connector(botCredential(key, bot), place.serviceUrl, {
663
+ method: "POST",
664
+ path: "/v3/conversations",
665
+ body: {
666
+ isGroup: true,
667
+ channelData: { channel: { id: channelId }, tenant: { id: bot.tenantId } },
668
+ activity: { type: "message", attachments: [cardAttachment(card)] },
669
+ },
670
+ }),
671
+ createApproval: (input) => teams.createApproval({ ...input, agentId: bot.agentId, botId: bot.id }),
672
+ // A click that landed before the card's id was kept could not edit the card for
673
+ // everyone; it is edited now.
674
+ approvalPosted: async (id, threadId, activityId) => {
675
+ const row = await teams.approvalPosted(id, threadId, activityId);
676
+ if (row && row.status !== "pending")
677
+ await updateCard(bot, row);
678
+ },
679
+ discardApproval: (id) => teams.discardApproval(id),
680
+ approval: (tokenHash) => teams.approvalForAgent(bot.agentId, tokenHash),
681
+ runForReply: (tokenHash) => teams.runForReply(bot.agentId, tokenHash),
682
+ claimReply: (runId) => teams.claimReply(bot.agentId, runId),
683
+ releaseReply: (runId) => teams.releaseReply(runId),
684
+ };
685
+ },
686
+ /** Settles the queue (tests). */
687
+ async idle() {
688
+ while (draining)
689
+ await draining;
690
+ },
691
+ };
692
+ }
693
+ export function simplify(list) {
694
+ return list
695
+ .filter((m) => (m.messageType ?? "message") === "message")
696
+ .map((m) => ({
697
+ from: m.from?.user?.displayName ?? m.from?.application?.displayName ?? "Someone",
698
+ text: m.body?.contentType === "html" ? plainText(m.body.content ?? "") : (m.body?.content ?? ""),
699
+ }));
700
+ }
701
+ //# sourceMappingURL=runtime.js.map