@open-everywhere/provider-teams 0.0.0-stage → 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 (146) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +46 -2
  4. package/agent/channels/teams-bot-name.ts +162 -0
  5. package/agent/channels/teams-catch-up.ts +244 -0
  6. package/agent/channels/teams-connector.ts +183 -0
  7. package/agent/channels/teams-conversation-ref.ts +398 -0
  8. package/agent/channels/teams-directory.ts +193 -0
  9. package/agent/channels/teams-engagement.ts +391 -0
  10. package/agent/channels/teams-fire-eligibility.ts +152 -0
  11. package/agent/channels/teams-front.ts +1404 -0
  12. package/agent/channels/teams-graph-boundary.ts +178 -0
  13. package/agent/channels/teams-graph-team-id.ts +106 -0
  14. package/agent/channels/teams-id-restore.ts +131 -0
  15. package/agent/channels/teams-identity.ts +395 -0
  16. package/agent/channels/teams-input-response.ts +129 -0
  17. package/agent/channels/teams-message-ops.ts +79 -0
  18. package/agent/channels/teams-origin-codec.ts +104 -0
  19. package/agent/channels/teams-post-arm.ts +323 -0
  20. package/agent/channels/teams-reaction-feedback.ts +127 -0
  21. package/agent/channels/teams-read.ts +510 -0
  22. package/agent/channels/teams-roster.ts +136 -0
  23. package/agent/channels/teams-stop.ts +319 -0
  24. package/agent/channels/teams-supervisor.ts +638 -0
  25. package/agent/channels/teams.ts +270 -0
  26. package/agent/contributions.ts +31 -0
  27. package/agent/docs-generated.ts +15 -0
  28. package/agent/ops.ts +129 -0
  29. package/agent/provider-events.ts +69 -0
  30. package/agent/register.ts +219 -0
  31. package/agent/surface-parity.ts +112 -0
  32. package/dist/agent/channels/teams-bot-name.d.ts +36 -0
  33. package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
  34. package/dist/agent/channels/teams-bot-name.js +141 -0
  35. package/dist/agent/channels/teams-bot-name.js.map +1 -0
  36. package/dist/agent/channels/teams-catch-up.d.ts +53 -0
  37. package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
  38. package/dist/agent/channels/teams-catch-up.js +221 -0
  39. package/dist/agent/channels/teams-catch-up.js.map +1 -0
  40. package/dist/agent/channels/teams-connector.d.ts +57 -0
  41. package/dist/agent/channels/teams-connector.d.ts.map +1 -0
  42. package/dist/agent/channels/teams-connector.js +132 -0
  43. package/dist/agent/channels/teams-connector.js.map +1 -0
  44. package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
  45. package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
  46. package/dist/agent/channels/teams-conversation-ref.js +368 -0
  47. package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
  48. package/dist/agent/channels/teams-directory.d.ts +11 -0
  49. package/dist/agent/channels/teams-directory.d.ts.map +1 -0
  50. package/dist/agent/channels/teams-directory.js +179 -0
  51. package/dist/agent/channels/teams-directory.js.map +1 -0
  52. package/dist/agent/channels/teams-engagement.d.ts +174 -0
  53. package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
  54. package/dist/agent/channels/teams-engagement.js +336 -0
  55. package/dist/agent/channels/teams-engagement.js.map +1 -0
  56. package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
  57. package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
  58. package/dist/agent/channels/teams-fire-eligibility.js +126 -0
  59. package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
  60. package/dist/agent/channels/teams-front.d.ts +249 -0
  61. package/dist/agent/channels/teams-front.d.ts.map +1 -0
  62. package/dist/agent/channels/teams-front.js +1143 -0
  63. package/dist/agent/channels/teams-front.js.map +1 -0
  64. package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
  65. package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
  66. package/dist/agent/channels/teams-graph-boundary.js +168 -0
  67. package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
  68. package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
  69. package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
  70. package/dist/agent/channels/teams-graph-team-id.js +89 -0
  71. package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
  72. package/dist/agent/channels/teams-id-restore.d.ts +34 -0
  73. package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
  74. package/dist/agent/channels/teams-id-restore.js +123 -0
  75. package/dist/agent/channels/teams-id-restore.js.map +1 -0
  76. package/dist/agent/channels/teams-identity.d.ts +148 -0
  77. package/dist/agent/channels/teams-identity.d.ts.map +1 -0
  78. package/dist/agent/channels/teams-identity.js +336 -0
  79. package/dist/agent/channels/teams-identity.js.map +1 -0
  80. package/dist/agent/channels/teams-input-response.d.ts +23 -0
  81. package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
  82. package/dist/agent/channels/teams-input-response.js +102 -0
  83. package/dist/agent/channels/teams-input-response.js.map +1 -0
  84. package/dist/agent/channels/teams-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/teams-message-ops.js +72 -0
  87. package/dist/agent/channels/teams-message-ops.js.map +1 -0
  88. package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
  89. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/teams-origin-codec.js +99 -0
  91. package/dist/agent/channels/teams-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/teams-post-arm.d.ts +37 -0
  93. package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/teams-post-arm.js +280 -0
  95. package/dist/agent/channels/teams-post-arm.js.map +1 -0
  96. package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
  97. package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
  98. package/dist/agent/channels/teams-reaction-feedback.js +108 -0
  99. package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
  100. package/dist/agent/channels/teams-read.d.ts +127 -0
  101. package/dist/agent/channels/teams-read.d.ts.map +1 -0
  102. package/dist/agent/channels/teams-read.js +424 -0
  103. package/dist/agent/channels/teams-read.js.map +1 -0
  104. package/dist/agent/channels/teams-roster.d.ts +54 -0
  105. package/dist/agent/channels/teams-roster.d.ts.map +1 -0
  106. package/dist/agent/channels/teams-roster.js +121 -0
  107. package/dist/agent/channels/teams-roster.js.map +1 -0
  108. package/dist/agent/channels/teams-stop.d.ts +74 -0
  109. package/dist/agent/channels/teams-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/teams-stop.js +252 -0
  111. package/dist/agent/channels/teams-stop.js.map +1 -0
  112. package/dist/agent/channels/teams-supervisor.d.ts +351 -0
  113. package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/teams-supervisor.js +366 -0
  115. package/dist/agent/channels/teams-supervisor.js.map +1 -0
  116. package/dist/agent/channels/teams.d.ts +5 -0
  117. package/dist/agent/channels/teams.d.ts.map +1 -0
  118. package/dist/agent/channels/teams.js +246 -0
  119. package/dist/agent/channels/teams.js.map +1 -0
  120. package/dist/agent/contributions.d.ts +12 -0
  121. package/dist/agent/contributions.d.ts.map +1 -0
  122. package/dist/agent/contributions.js +31 -0
  123. package/dist/agent/contributions.js.map +1 -0
  124. package/dist/agent/docs-generated.d.ts +3 -0
  125. package/dist/agent/docs-generated.d.ts.map +1 -0
  126. package/dist/agent/docs-generated.js +13 -0
  127. package/dist/agent/docs-generated.js.map +1 -0
  128. package/dist/agent/ops.d.ts +14 -0
  129. package/dist/agent/ops.d.ts.map +1 -0
  130. package/dist/agent/ops.js +122 -0
  131. package/dist/agent/ops.js.map +1 -0
  132. package/dist/agent/provider-events.d.ts +3 -0
  133. package/dist/agent/provider-events.d.ts.map +1 -0
  134. package/dist/agent/provider-events.js +67 -0
  135. package/dist/agent/provider-events.js.map +1 -0
  136. package/dist/agent/register.d.ts +16 -0
  137. package/dist/agent/register.d.ts.map +1 -0
  138. package/dist/agent/register.js +198 -0
  139. package/dist/agent/register.js.map +1 -0
  140. package/dist/agent/surface-parity.d.ts +3 -0
  141. package/dist/agent/surface-parity.d.ts.map +1 -0
  142. package/dist/agent/surface-parity.js +106 -0
  143. package/dist/agent/surface-parity.js.map +1 -0
  144. package/package.json +183 -4
  145. package/tsconfig.build.json +22 -0
  146. package/tsconfig.json +15 -0
@@ -0,0 +1,104 @@
1
+ // The Teams UnifiedWakeOrigin codec + the Bot-Connector serviceUrl host allowlist — MOVED VERBATIM
2
+ // out of core's channels/wake-origin-codecs.ts (OPE-114 stage 4). STORED-DATA COMPATIBILITY IS THE
3
+ // CONTRACT: a Teams origin is JSON-serialised into the durable operational store and fail-closed
4
+ // re-validated on read, so every behaviour here is frozen by core's
5
+ // scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's boot wiring —
6
+ // scripts/wake-origin-fixtures-boot.ts). Validator tolerances (typeof-only optional checks, extra
7
+ // unknown fields) are LOAD-BEARING for rows already on disk — never tighten them here.
8
+ //
9
+ // The `UnifiedWakeOrigin` TYPE union (including its teams arm) stays in core — it is the wire
10
+ // schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
11
+ // registerTeamsProvider() (../register.ts). `isTrustedTeamsServiceUrl` is exported for the teams
12
+ // post arm (BOTH of its dispatch paths re-validate the serviceUrl before POSTing the Connector
13
+ // token) and applied here on the stored-row `validate` arm — every route by which a serviceUrl can
14
+ // reach a token POST passes it. (There was a third route, the `dmOrigin` DM-delegation-anchor mint
15
+ // arm, which applied the same allowlist; it went with the anchor at OPE-368 — a removed route needs
16
+ // no check.) Core no longer re-exports the predicate (the stale unified-window re-export was removed
17
+ // in stage 4).
18
+
19
+ import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
20
+
21
+ type TeamsOrigin = Extract<UnifiedWakeOrigin, { platform: "teams" }>;
22
+
23
+ /** COLON-FREE by construction: a task windowId is `[^:]+` (core channels/window-key.ts), so any colon
24
+ * in a raw surface id (Teams' `19:...@thread.v2;messageid=` conversation ids) is folded to `_` —
25
+ * deterministic, so spawn + ingress still agree on identity. */
26
+ const noColon = (s: string): string => s.replace(/:/g, "_");
27
+
28
+ /**
29
+ * Bot Connector `serviceUrl` host allowlist. The Teams reply POSTs a `client_credentials` bearer
30
+ * token to `${serviceUrl}/v3/conversations/...`, so the host must be a real Microsoft Bot
31
+ * Framework endpoint — not merely https. `serviceUrl` rides in off the inbound activity BODY, and
32
+ * eve's JWT verifier does NOT bind it to the token's `serviceurl` claim, so a scheme-only check
33
+ * (`startsWith("https://")`) would let a body-supplied `https://evil.example` exfiltrate the
34
+ * token. Constrain the host to the documented Bot Connector set. Fail-closed on any parse error.
35
+ */
36
+ export function isTrustedTeamsServiceUrl(serviceUrl: unknown): boolean {
37
+ if (typeof serviceUrl !== "string") return false;
38
+ let host: string;
39
+ try {
40
+ const u = new URL(serviceUrl);
41
+ if (u.protocol !== "https:") return false;
42
+ host = u.hostname.toLowerCase();
43
+ } catch {
44
+ return false;
45
+ }
46
+ // An entry beginning with "." is a dot-anchored suffix; otherwise an exact host.
47
+ return teamsServiceHostAllowlist().some((h) => (h.startsWith(".") ? host.endsWith(h) : host === h));
48
+ }
49
+
50
+ /** The GLOBAL (commercial) cloud Bot Connector hosts. Sovereign/national clouds use DIFFERENT
51
+ * hosts and are NOT in the default (keeping the default minimal, per the no-extrapolation rule):
52
+ * a GCC / GCC High / DoD / 21Vianet operator opts theirs in via OE_TEAMS_SERVICE_HOSTS. Verified
53
+ * values (Microsoft Learn, "Send proactive messages"): GCC `smba.infra.gcc.teams.microsoft.com`,
54
+ * GCC High `smba.infra.gov.teams.microsoft.us`, DoD `smba.infra.dod.teams.microsoft.us`. */
55
+ const DEFAULT_TEAMS_SERVICE_HOSTS: readonly string[] = [
56
+ "smba.trafficmanager.net",
57
+ ".smba.trafficmanager.net",
58
+ "botframework.com",
59
+ ".botframework.com",
60
+ ];
61
+
62
+ /** The effective serviceUrl host allowlist = the global default PLUS any operator-supplied
63
+ * OE_TEAMS_SERVICE_HOSTS (comma-separated; each entry an exact host or a ".suffix"). This is how
64
+ * a sovereign-cloud deployment admits its own Bot Connector host without weakening the default —
65
+ * the reply's bearer token only ever POSTs to a MATCHED host, all Microsoft-owned. */
66
+ function teamsServiceHostAllowlist(): readonly string[] {
67
+ const extra = (process.env.OE_TEAMS_SERVICE_HOSTS ?? "")
68
+ .split(",")
69
+ .map((s) => s.trim().toLowerCase())
70
+ .filter((s) => s.length > 0);
71
+ return extra.length > 0 ? [...DEFAULT_TEAMS_SERVICE_HOSTS, ...extra] : DEFAULT_TEAMS_SERVICE_HOSTS;
72
+ }
73
+
74
+ export const teamsWakeOriginCodec: WakeOriginCodec<TeamsOrigin> = {
75
+ validate: (o) =>
76
+ // The Bot Connector bearer token is POSTed to serviceUrl, so it must be a Microsoft Bot
77
+ // Framework host, not merely https (isTrustedTeamsServiceUrl) — the reply path re-validates
78
+ // regardless, but a bad host must never survive into the map in the first place.
79
+ isTrustedTeamsServiceUrl(o.serviceUrl) &&
80
+ typeof o.conversationId === "string" &&
81
+ o.conversationId.length > 0 &&
82
+ (o.replyToActivityId === undefined || typeof o.replyToActivityId === "string") &&
83
+ (o.tenantId === undefined || typeof o.tenantId === "string"),
84
+ threadRef: (origin) => ({ channelKey: noColon(`teams/${origin.conversationId}`), threadKey: noColon(origin.replyToActivityId ?? origin.conversationId) }),
85
+ // replyToActivityId present ⇔ the trigger sits in a reply chain (the chain IS the thread);
86
+ // absent = a plain conversation send.
87
+ isThreadScoped: (origin) => typeof origin.replyToActivityId === "string" && origin.replyToActivityId.length > 0,
88
+ containerId: (origin) => origin.conversationId,
89
+ // A `dmOrigin` arm lived here (OPE-306 seam, landed OPE-315) to mint a DM delegation anchor's reply
90
+ // origin from the binding's `target` (`serviceUrl` + `conversationId`). OPE-368 deleted the anchor
91
+ // and the interface member with it — a Teams personal/group chat now diverts into its own
92
+ // first-class window (OPE-357), so its origin is minted and STORED at dispatch time like every other
93
+ // wake. NOTHING WEAKENED BY THE REMOVAL: the serviceUrl host allowlist that arm applied at mint is
94
+ // ALSO applied by `validate` above (every stored row) and again by the post arm before any Connector
95
+ // token leaves the process, so `isTrustedTeamsServiceUrl` remains on every route to a token POST.
96
+ //
97
+ // NO `placementRef` AND NO `carryPlacement`, AND THAT ABSENCE IS THE DECLARATION (OPE-1826). Teams
98
+ // keeps its placement INSIDE the conversation identity: `threadRef` keys on `replyToActivityId`,
99
+ // which is the very activity a reply hangs under, so two origins that pass `sameUnifiedConversation`
100
+ // already agree about where the answer lands and there is no gap for a carry to fill. Declaring an
101
+ // axis here would be worse than absent — it would make two origins of ONE thread compare unequal on
102
+ // a field the thread key has already settled. The position is recorded, and held to account against
103
+ // the live registry, by the `origin-placement-carry` row in core's identity/surface-parity.ts.
104
+ };
@@ -0,0 +1,323 @@
1
+ // teams-post-arm — Teams's POST arm for the post-arms registry (OPE-114 stage 1c). Absorbs, VERBATIM,
2
+ // Teams's arms out of the three reply/delivery spines: reply-post.ts (resolveReplyTarget's teams case
3
+ // + dispatchReply's send/reply branch + the teams splitTeamsMessageText chunking used by
4
+ // postTerminalProse), unified-reply.ts (liveTeamsPost, incl. the trusted-serviceUrl re-validation),
5
+ // and unified-redrive.ts (the teams describe/container cases). The transport is eve's own Bot-Framework
6
+ // Connector token (minted from MICROSOFT_APP_ID/PASSWORD) over a HOST-SIDE Connector fetch — see
7
+ // teams-connector.ts's header for why the send/reply/update calls are no longer eve's own
8
+ // `sendTeamsActivity`/`replyToTeamsActivity`/`updateTeamsActivity` (OPE-1237: eve's own transport
9
+ // throws away the response headers, so there is no way to honour a 429's `Retry-After` by wrapping
10
+ // its return value).
11
+ //
12
+ // ONE DELIBERATE DIVERGENCE FROM "VERBATIM" (OPE-315): dispatchTeamsReply now applies the
13
+ // isTrustedTeamsServiceUrl host allowlist its unified sibling always did. That was a real gap, not a
14
+ // stylistic one — see the check's own comment.
15
+
16
+ import { resolveTeamsAppId, splitTeamsMessageText, TEAMS_MESSAGE_TEXT_MAX_LENGTH } from "eve/channels/teams";
17
+ import {
18
+ teamsConnectorWrite,
19
+ sendTeamsActivityBackedOff,
20
+ replyToTeamsActivityBackedOff,
21
+ updateTeamsActivityBackedOff,
22
+ } from "./teams-connector.js";
23
+ import { isTrustedTeamsServiceUrl } from "./teams-origin-codec.js";
24
+ import {
25
+ teamsPersonalConversationRef,
26
+ teamsSoleTenantRegion,
27
+ rememberTeamsPersonalConversation,
28
+ type TeamsConversationRef,
29
+ } from "./teams-conversation-ref.js";
30
+ import { restoreTeamsPersonId } from "./teams-id-restore.js";
31
+ import { markNotApplied, carryFailureSignals } from "@open-everywhere/core/agent/adapter-api.js";
32
+ import type {
33
+ PostArm,
34
+ TargetResolveBag,
35
+ ReplyTarget,
36
+ UnifiedWakeOrigin,
37
+ UnifiedReplyDeps,
38
+ EditLocus,
39
+ DirectMessageRequest,
40
+ DirectMessageDelivery,
41
+ } from "@open-everywhere/core/agent/adapter-api.js";
42
+
43
+ /** Exported for `teams-message-ops.ts` (OPE-899) — the SurfaceMessageOps registration reuses this
44
+ * exact shape (serviceUrl + conversationId + an optional reply anchor) rather than inventing a
45
+ * second one; see `dispatchTeamsReply`'s own export note. */
46
+ export type TeamsTarget = Extract<ReplyTarget, { platform: "teams" }>;
47
+ type TeamsOrigin = Extract<UnifiedWakeOrigin, { platform: "teams" }>;
48
+
49
+ /** MID-TURN: a Bot-Framework message activity into the captured conversation reference. eve mints the
50
+ * Connector token from MICROSOFT_APP_ID/PASSWORD; check the id for an honest early error. Thread onto
51
+ * the trigger's root activity when the caller resolved one (a channel/group reply chains via
52
+ * replyToTeamsActivity — eve's exact reply semantics, matching the unified arm and channel.thread.post);
53
+ * a plain conversation send otherwise (personal chats, or no root id). */
54
+ /** Exported (OPE-899) so `teams-message-ops.ts`'s SurfaceMessageOps `postText` can post through the
55
+ * SAME transport this mid-turn reply arm uses, rather than a second copy of the Connector send.
56
+ * That module resolves its own `TeamsTarget` from the `conversation`-grain address store
57
+ * (`teams-conversation-ref.ts`, OPE-899) instead of from a live binding, but the target shape and
58
+ * every refusal below are identical either way. */
59
+ export async function dispatchTeamsReply(target: TeamsTarget, text: string): Promise<string> {
60
+ // Both pre-flight refusals are DEFINITIVE (OPE-821): the call never leaves this host, so nothing
61
+ // reached the room and the post spine may release its dedupe claim for an identical retry.
62
+ if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
63
+ throw markNotApplied(new Error("reply couldn't post to Teams — MICROSOFT_APP_ID is not set."));
64
+ }
65
+ // 🔴 THE SAME HOST ALLOWLIST THE UNIFIED ARM APPLIES (OPE-315). This path POSTs a client_credentials
66
+ // Connector bearer token to `target.serviceUrl`, and until now it was the ONE serviceUrl route with
67
+ // no host check: the stored-origin path is gated by the codec validator (teams-origin-codec.ts) and
68
+ // re-gated in liveTeamsPost, but a mid-turn target is assembled by resolveTarget from binding
69
+ // attributes with only a non-empty test. Narrow (it takes a rogue Bot Framework channel on the same
70
+ // bot registration to place an attacker host there) but the same class as the unified arm's, and
71
+ // the fix is three lines. Token-free error, mirroring liveTeamsPost's.
72
+ if (!isTrustedTeamsServiceUrl(target.serviceUrl)) {
73
+ throw markNotApplied(new Error(`reply couldn't post to Teams — serviceUrl is not a Bot Framework host (conversation ${target.conversationId})`));
74
+ }
75
+ const res = target.replyToActivityId
76
+ ? await replyToTeamsActivityBackedOff({
77
+ serviceUrl: target.serviceUrl,
78
+ conversationId: target.conversationId,
79
+ activityId: target.replyToActivityId,
80
+ body: { type: "message", text },
81
+ })
82
+ : await sendTeamsActivityBackedOff({
83
+ serviceUrl: target.serviceUrl,
84
+ conversationId: target.conversationId,
85
+ body: { type: "message", text },
86
+ });
87
+ return res.id;
88
+ }
89
+
90
+ /** UNIFIED: a Bot-Framework message activity into the frozen conversation reference — the SAME
91
+ * transport + env credentials the mid-turn reply arm uses. Anchored into the reply CHAIN via
92
+ * replyToTeamsActivity when the origin froze a thread-root activity id (teamsThreadRootActivityId),
93
+ * else a plain conversation send. Every target field rides the host-frozen wake origin, never the
94
+ * model. */
95
+ async function liveTeamsPost(origin: TeamsOrigin, text: string): Promise<string> {
96
+ if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
97
+ // DEFINITIVE, like the mid-turn arm's pre-flights: nothing left this host.
98
+ throw markNotApplied(new Error(`unified reply couldn't post to Teams — MICROSOFT_APP_ID is not set (conversation ${origin.conversationId})`));
99
+ }
100
+ // Belt-and-braces re-validation (isUnifiedWakeOrigin already enforces it): the Connector bearer
101
+ // token is POSTed to this host, so it must be a Microsoft Bot Framework endpoint — a scheme-only
102
+ // https check would let a body-supplied serviceUrl exfiltrate the token.
103
+ if (!isTrustedTeamsServiceUrl(origin.serviceUrl)) {
104
+ throw markNotApplied(new Error(`unified reply couldn't post to Teams — serviceUrl is not a Bot Framework host (conversation ${origin.conversationId})`));
105
+ }
106
+ let id: string;
107
+ try {
108
+ const res = origin.replyToActivityId
109
+ ? await replyToTeamsActivityBackedOff({
110
+ serviceUrl: origin.serviceUrl,
111
+ conversationId: origin.conversationId,
112
+ activityId: origin.replyToActivityId,
113
+ body: { type: "message", text },
114
+ })
115
+ : await sendTeamsActivityBackedOff({
116
+ serviceUrl: origin.serviceUrl,
117
+ conversationId: origin.conversationId,
118
+ body: { type: "message", text },
119
+ });
120
+ id = res.id;
121
+ } catch (err) {
122
+ // Token-free error: never echo the transport error body (defensive parity with the other arms).
123
+ // AMBIGUOUS unless the Connector gave a status to say otherwise (OPE-821) — a send whose answer
124
+ // never arrived may still have reached the room, so the class carries over rather than being
125
+ // dropped by the re-wrap. A number is the only thing carried; no body, no URL, no token.
126
+ throw carryFailureSignals(err, new Error(`unified reply couldn't post to Teams — the Connector post failed (conversation ${origin.conversationId})`));
127
+ }
128
+ return id || "unknown";
129
+ }
130
+
131
+ /**
132
+ * EDIT (OPE-793): rewrite one of our own posted activities via eve's `updateTeamsActivity` (the Bot
133
+ * Framework update-activity call), on the SAME transport and credentials the post arms use. The
134
+ * Teams client shows an "Edited" marker on the activity, so the change is visible in the room.
135
+ *
136
+ * THE SAME TRUSTED-HOST ALLOWLIST BOTH POST ARMS APPLY. This call POSTs a client_credentials
137
+ * Connector bearer token to `locus.serviceUrl`, so it must be a Bot Framework host — the identical
138
+ * check, for the identical reason, and its absence here would be the OPE-315 gap re-opened on a new
139
+ * route. The serviceUrl reaches this arm from the session binding or the frozen wake origin (never
140
+ * model input), and it is re-validated anyway.
141
+ *
142
+ * NO SPLIT, like the Discord arm: one activity is one activity, so text past Teams' own cap is
143
+ * refused rather than truncated. Ownership was decided upstream against the own-post ledger.
144
+ */
145
+ /** Exported (OPE-899) so `teams-message-ops.ts`'s SurfaceMessageOps `editMessage` reuses this SAME
146
+ * update-activity transport rather than a second copy of it. */
147
+ export async function editTeamsMessage(locus: EditLocus, messageId: string, text: string): Promise<void> {
148
+ // The three pre-flight refusals are DEFINITIVE (OPE-802): the call never leaves this host, so the
149
+ // message cannot have changed and a fresh `reply` is the right remedy. The Connector failure below
150
+ // is not — a failed update may still have applied — so it stays unmarked and fails closed.
151
+ if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
152
+ throw markNotApplied(new Error("edit_reply couldn't update the Teams message — MICROSOFT_APP_ID is not set."));
153
+ }
154
+ if (!locus.serviceUrl || !isTrustedTeamsServiceUrl(locus.serviceUrl)) {
155
+ throw markNotApplied(
156
+ new Error(`edit_reply couldn't update the Teams message — serviceUrl is not a Bot Framework host (conversation ${locus.channelId})`),
157
+ );
158
+ }
159
+ if (text.length > TEAMS_MESSAGE_TEXT_MAX_LENGTH) {
160
+ throw markNotApplied(
161
+ new Error(
162
+ `edit_reply couldn't update the Teams message — the new text is ${text.length} characters and Teams caps one message at ${TEAMS_MESSAGE_TEXT_MAX_LENGTH}. An edit cannot split a message; shorten it, or post a new reply.`,
163
+ ),
164
+ );
165
+ }
166
+ try {
167
+ await updateTeamsActivityBackedOff({
168
+ serviceUrl: locus.serviceUrl,
169
+ conversationId: locus.channelId,
170
+ activityId: messageId,
171
+ body: { type: "message", text },
172
+ });
173
+ } catch (err) {
174
+ // Token-free error, matching both post arms: never echo the transport error body. `err` may now
175
+ // be the NAMED `RateLimitExhaustedError` (OPE-1237) rather than an ordinary Connector throw —
176
+ // `carryFailureSignals` copies its `status` (429, always AMBIGUOUS per platform-failure.ts) onto
177
+ // the wrapped error, the same treatment `liveTeamsPost`'s catch below gives it. That keeps the
178
+ // 429 case failing closed exactly as an unclassified Connector failure already did — this stays
179
+ // an improvement in what a caller reading `.status` can tell, never a behaviour change.
180
+ throw carryFailureSignals(err, new Error(`edit_reply couldn't update the Teams message — the Connector update failed (conversation ${locus.channelId})`));
181
+ }
182
+ }
183
+
184
+ /** Teams' own pre-flight: without an app id nothing can mint a Connector token. DEFINITIVE — the
185
+ * call never leaves this host — so the caller may retry an identical send once the id is set. */
186
+ function requireTeamsAppCredential(what: string): void {
187
+ if (!process.env.MICROSOFT_APP_ID && !process.env.TEAMS_APP_ID) {
188
+ throw markNotApplied(new Error(`${what} couldn't reach Teams — MICROSOFT_APP_ID is not set.`));
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Open (or re-open) the 1:1 chat between the bot and one person — `POST {serviceUrl}/v3/conversations`
194
+ * with the bot, the member and the tenant.
195
+ *
196
+ * IT IS THE ONLY WAY TO COLD-ADDRESS A TEAMS PERSON, and it is why OPE-887 exists: the Bot Framework
197
+ * has no `conversations.open` keyed on a bare user id, so the deployment must already know a REGION
198
+ * (`serviceUrl`) and a TENANT before it can ask. Both come from the proactive-address table
199
+ * (channels/teams-conversation-ref.ts), captured off real inbound activities.
200
+ *
201
+ * The create is IDEMPOTENT for a one-member, non-group conversation: Microsoft documents it as
202
+ * returning the EXISTING 1:1 chat rather than minting a second one. That is a documented fact and not
203
+ * a measured one — if it turned out to be false, the visible symptom would be a duplicate chat, never
204
+ * a lost message, and the stored reference below means the call happens at most once per person
205
+ * anyway.
206
+ *
207
+ * 🔴 THE REFUSAL THIS ARM MUST STAY HONEST ABOUT: Microsoft refuses the create when the Teams app is
208
+ * not installed for that person in the PERSONAL scope. So a Teams DM arm reaches the people who have
209
+ * the app, and NOT the whole tenant. The error names that, because "the message did not send" without
210
+ * the reason sends a caller looking in the wrong place.
211
+ */
212
+ async function openTeamsPersonalConversation(region: { readonly serviceUrl: string; readonly tenantId: string }, principalId: string): Promise<string> {
213
+ const appId = await resolveTeamsAppId();
214
+ const res = await teamsConnectorWrite({
215
+ operation: "conversations.create",
216
+ serviceUrl: region.serviceUrl,
217
+ path: "/v3/conversations",
218
+ method: "POST",
219
+ body: {
220
+ isGroup: false,
221
+ bot: { id: `28:${appId}` },
222
+ members: [{ id: principalId }],
223
+ channelData: { tenant: { id: region.tenantId } },
224
+ tenantId: region.tenantId,
225
+ },
226
+ });
227
+ const id = res.ok && res.body && typeof res.body === "object" ? (res.body as { id?: unknown }).id : undefined;
228
+ if (typeof id !== "string" || id.length === 0) {
229
+ // Token-free, and never the Connector's own body: the response can echo request material.
230
+ throw new Error(
231
+ `couldn't open a Teams 1:1 chat with that account (Bot Framework answered ${res.status}). Microsoft refuses this when the Teams app is not installed for that person in the personal scope.`,
232
+ );
233
+ }
234
+ return id;
235
+ }
236
+
237
+ /**
238
+ * DM (OPE-887): deliver `text` to one person's 1:1 Teams chat. The THIRD provider to implement this
239
+ * member, after Slack and Discord, and the one whose transport needed a durable seam first.
240
+ *
241
+ * WHAT IT COSTS TO GET HERE, in one paragraph. `DirectMessageRequest` names WHO and never WHERE, which
242
+ * is right and which Slack and Discord satisfy from a bot token alone. Teams cannot: a send needs a
243
+ * regional `serviceUrl` and a Microsoft-minted conversation id, and neither is derivable from a person
244
+ * id. So the deployment captures both off inbound activities (channels/teams-conversation-ref.ts) and
245
+ * this arm reads them back. The caller's contract is unchanged.
246
+ *
247
+ * THREE OUTCOMES, and each is a real answer:
248
+ * 1. a 1:1 reference is stored ⇒ one send.
249
+ * 2. no 1:1 reference, but this deployment knows exactly one tenant and its region ⇒ open the chat,
250
+ * remember it, send.
251
+ * 3. neither ⇒ REFUSE, loudly and token-free. That is not a defect to paper over: this deployment
252
+ * genuinely has no address for that person, and a silent no-op would leave a consent ask
253
+ * apparently sent and actually nowhere.
254
+ *
255
+ * `actions` ARE IGNORED, deliberately and per the registry's "undefined = unsupported, never faked":
256
+ * this arm posts a plain message activity, and a Teams button would need an Adaptive Card plus an
257
+ * invoke route to receive the tap. Core's copy always stands on its own without a button
258
+ * ({@link DirectMessageAction}'s own rule), so the text delivered here is complete.
259
+ */
260
+ async function deliverTeamsDirectMessage(input: DirectMessageRequest): Promise<DirectMessageDelivery> {
261
+ requireTeamsAppCredential("the direct message");
262
+ // 🔴 RESTORE THE RAW `29:` ID ONCE, HERE, BEFORE ANY USE (OPE-1078). A granted `teams:person/<id>`
263
+ // address carries the SURFACE-KEY spelling — the only one the resource-grant grammar admits — and
264
+ // every use below needs the raw Bot Framework id: the stored-reference lookup keys on it, and
265
+ // `openTeamsPersonalConversation` puts it in the Connector's `members` list, where a wrong spelling
266
+ // is a refused create rather than a delivered message. A caller that already holds the raw id (the
267
+ // recipient's own binding) is answered unchanged and pays no store read.
268
+ const principalId = await restoreTeamsPersonId(input.principalId.trim());
269
+ if (principalId.length === 0) throw markNotApplied(new Error("the direct message couldn't reach Teams — no account id was given."));
270
+
271
+ const stored = await teamsPersonalConversationRef(principalId);
272
+ let ref: TeamsConversationRef;
273
+ if (stored) {
274
+ ref = stored;
275
+ } else {
276
+ const region = await teamsSoleTenantRegion();
277
+ if (!region) {
278
+ // DEFINITIVE: nothing left this host. Naming the two ways an address is acquired is what lets an
279
+ // operator act — the alternative is a message that never arrives and no reason why.
280
+ throw markNotApplied(
281
+ new Error(
282
+ "the direct message couldn't reach Teams — this deployment holds no Bot Framework conversation reference for that account, and no single Teams tenant it could open one in. A reference is captured when the person messages the bot, or when the bot sees a channel in exactly one tenant.",
283
+ ),
284
+ );
285
+ }
286
+ ref = { ...region, conversationId: await openTeamsPersonalConversation(region, principalId) };
287
+ // Best-effort: a failed write costs a repeated create next time, never a failed send.
288
+ await rememberTeamsPersonalConversation(principalId, ref);
289
+ }
290
+
291
+ // READ-TIME allowlist, for the same reason every other Teams write applies one: this POSTs a
292
+ // Connector bearer token to the host. The stored reference was checked on the way in too.
293
+ if (!isTrustedTeamsServiceUrl(ref.serviceUrl)) {
294
+ throw markNotApplied(new Error("the direct message couldn't reach Teams — the stored serviceUrl is not a Bot Framework host."));
295
+ }
296
+
297
+ // Teams caps one activity, so a long notice is split exactly as every other Teams post is; the
298
+ // FIRST part's id is the returned anchor, matching the Discord arm.
299
+ const parts = splitTeamsMessageText(input.text);
300
+ let messageId = "";
301
+ for (const part of parts) {
302
+ const res = await sendTeamsActivityBackedOff({ serviceUrl: ref.serviceUrl, conversationId: ref.conversationId, body: { type: "message", text: part } });
303
+ if (messageId.length === 0) messageId = res.id;
304
+ }
305
+ return { surfaceId: ref.conversationId, messageId: messageId || "unknown" };
306
+ }
307
+
308
+ export const teamsPostArm: PostArm = {
309
+ platform: "teams",
310
+ resolveTarget: (bag: TargetResolveBag): ReplyTarget | null => {
311
+ // Proactive Teams sends need the Bot-Framework conversation reference (serviceUrl + conversation
312
+ // id) the inbound turn captured — you cannot cold-address a Teams chat.
313
+ const serviceUrl = bag.str("serviceUrl");
314
+ const conversationId = bag.str("conversationId");
315
+ return serviceUrl && conversationId ? { platform: "teams", serviceUrl, conversationId } : null;
316
+ },
317
+ dispatchReply: (target, text) => dispatchTeamsReply(target as TeamsTarget, text),
318
+ dispatchUnified: (origin, text, deps: UnifiedReplyDeps) => (deps.postTeams ?? liveTeamsPost)(origin as TeamsOrigin, text),
319
+ describeRedriveTarget: (origin) => `teams conversation ${(origin as TeamsOrigin).conversationId}`,
320
+ redriveContainerId: (origin) => (origin as TeamsOrigin).conversationId,
321
+ editMessage: editTeamsMessage,
322
+ deliverDirectMessage: deliverTeamsDirectMessage,
323
+ };
@@ -0,0 +1,127 @@
1
+ // THE TEAMS REACTION-FEEDBACK ARM (OPE-1502) — a reaction on the agent's own message becomes a
2
+ // durable feedback signal, the same way it already does on Slack and Discord.
3
+ //
4
+ // The inbound half is FREE on this surface: Teams already delivers `messageReaction` activities, and
5
+ // the stop arm (channels/teams-stop.ts) already parses them. What was missing is the second consumer
6
+ // — the one that turns a 👍 or a 👎 on the agent's own reply into a row a person can query. This
7
+ // module is that consumer, and it adds NO second parse and NO second ingress: it takes the activity
8
+ // the stop arm already parsed and hands it core's surface-neutral `ingestReaction`.
9
+ //
10
+ // ── WHAT MICROSOFT DELIVERS, AND WHY IT SHAPES THE GUARDS
11
+ //
12
+ // 1. 🔴 TEAMS SENDS THE EVENT ONLY FOR MESSAGES THE AGENT ITSELF SENT ("a user adds or removes
13
+ // reactions to a message, which was sent by your agent" — learn.microsoft.com, "Conversation
14
+ // events"). That FENCE is what makes the feedback case the common case here rather than the rare
15
+ // one: a reaction on somebody else's message never reaches this arm at all.
16
+ // 2. 🔴 `replyToId` NAMES THAT MESSAGE, AND ITS PRESENCE IS THE WHOLE `onBotReply` TEST — WHICH IS
17
+ // NOT THE CHECK THE OTHER SURFACES MAKE. Slack, Discord and Linear each compare the reacted
18
+ // message's AUTHOR to the bot's own id. Teams delivers no author for the reacted message, so no
19
+ // such comparison exists here: the guarantee is Microsoft's fence (point 1), and the code checks
20
+ // only that a target id is present. An activity carrying `replyToId` for a message the agent did
21
+ // not write would be recorded as feedback, and nothing in this repository could notice. The
22
+ // absence of `replyToId` reads as "no message to name" — telemetry only, never a feedback row.
23
+ // 3. THE REACTION VOCABULARY IS SIX FIXED VALUES: `angry`, `heart`, `laugh`, `like`, `sad`,
24
+ // `surprised`. Core's `classifyEmoji` buckets them; `surprised` is `other`, which is correct —
25
+ // surprise is not a verdict.
26
+ // 4. The activity carries no message content, so nothing here reads or needs any.
27
+ //
28
+ // ── WHY THE STOP REACTION NEVER REACHES HERE
29
+ //
30
+ // `angry` is this surface's stop trigger (channels/teams-stop.ts). ✋ is a MECHANISM, not feedback,
31
+ // and the same rule holds for the glyph that stands in for it: an ADDED `angry` means "stop this" and
32
+ // must never ALSO be recorded as a sentiment about the message it sits on. The stop arm therefore
33
+ // keeps that activity and this arm never sees it. A REMOVED `angry` is not a stop — nothing cancels
34
+ // on a removal — so it arrives here as an ordinary retraction, exactly as a removed ✋ does on Slack.
35
+
36
+ import {
37
+ buildIngestedReaction,
38
+ ingestReaction,
39
+ liveReactionSink,
40
+ resolvePartition,
41
+ type ReactionIngestOutcome,
42
+ type ReactionSinkDeps,
43
+ } from "@open-everywhere/core/agent/adapter-api.js";
44
+ import { buildTeamsAuth } from "./teams-identity.js";
45
+ import type { TeamsReactionActivity } from "./teams-stop.js";
46
+
47
+ /** Injected seams, so parse + guard + record is unit-tested against stubs with no Bot registration. */
48
+ export interface TeamsReactionFeedbackDeps {
49
+ readonly sink: ReactionSinkDeps;
50
+ }
51
+
52
+ /** Production deps: the live audit/telemetry sink core already owns. There is no Teams-specific
53
+ * sink — the whole point of the seam is that the durable row is written in ONE place. */
54
+ export function liveTeamsReactionFeedbackDeps(): TeamsReactionFeedbackDeps {
55
+ return { sink: liveReactionSink() };
56
+ }
57
+
58
+ /** The Bot Framework's own prefix for an application id. A reactor wearing it is a bot whatever the
59
+ * `recipient` field says — the same belt-and-braces check the stop arm makes. */
60
+ function teamsReactorIsMachine(rx: TeamsReactionActivity): boolean {
61
+ return rx.reactorId === rx.agentId || rx.reactorId?.startsWith("28:") === true;
62
+ }
63
+
64
+ /**
65
+ * Ingest every reaction one Teams `messageReaction` activity carries. Returns one outcome per
66
+ * reaction — added first, then removed — so a caller's log says what happened to each.
67
+ *
68
+ * It NEVER THROWS into the caller: core's sinks are best-effort by contract, and a lost feedback row
69
+ * must never cost the ingress its ack.
70
+ *
71
+ * An activity with nothing to ingest returns an EMPTY array. That is the honest answer for a bot's
72
+ * own reaction, an activity with no reactor, and an activity naming no conversation — none of the
73
+ * three is a reaction from a person on a message this deployment can key.
74
+ */
75
+ export async function routeTeamsReactionFeedback(
76
+ rx: TeamsReactionActivity,
77
+ deps: TeamsReactionFeedbackDeps,
78
+ ): Promise<readonly ReactionIngestOutcome[]> {
79
+ // The agent's own reactions ride the same activity stream and are never feedback. 🔴 THE `28:`
80
+ // PREFIX IS THE LOAD-BEARING HALF of this guard, not the `recipient` comparison: with an
81
+ // unresolved agent id the prefix is the only thing left, and — unlike the ingest paths on Slack and
82
+ // Discord, where a missed bot reactor costs a stray telemetry row — a missed one HERE costs a
83
+ // durable feedback row attributed to a machine, because `onBotReply` rests on a present
84
+ // `replyToId` rather than on an author comparison (header, point 2).
85
+ if (!rx.reactorId || teamsReactorIsMachine(rx)) return [];
86
+ if (!rx.conversationId) return [];
87
+
88
+ const partition = resolvePartition({
89
+ channel: { kind: "teams" },
90
+ session: {
91
+ auth: {
92
+ current: buildTeamsAuth({
93
+ userId: rx.reactorId,
94
+ conversationId: rx.conversationId,
95
+ ...(rx.tenantId ? { tenantId: rx.tenantId } : {}),
96
+ ...(rx.teamsChannelId ? { channelId: rx.teamsChannelId } : {}),
97
+ ...(rx.teamId ? { teamId: rx.teamId } : {}),
98
+ ...(rx.scope ? { scope: rx.scope } : {}),
99
+ }),
100
+ },
101
+ },
102
+ });
103
+
104
+ // A PRESENCE CHECK, not an author comparison — the header's point 2 says why this surface offers
105
+ // no other, and what that costs. No `replyToId` means no message to name, so the reaction is
106
+ // telemetry only.
107
+ const onBotReply = Boolean(rx.replyToId);
108
+ const outcomes: ReactionIngestOutcome[] = [];
109
+ for (const [verb, emojis] of [
110
+ ["added", rx.added],
111
+ ["removed", rx.removed],
112
+ ] as const) {
113
+ for (const emoji of emojis) {
114
+ const reaction = buildIngestedReaction({
115
+ surface: "teams",
116
+ verb,
117
+ reactorId: rx.reactorId,
118
+ emoji,
119
+ target: { channelId: rx.conversationId, messageId: rx.replyToId ?? "" },
120
+ onBotReply,
121
+ partition,
122
+ });
123
+ outcomes.push(await ingestReaction(reaction, deps.sink));
124
+ }
125
+ }
126
+ return outcomes;
127
+ }