@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,391 @@
1
+ // THE TEAMS AMBIENT ENGAGEMENT SEAM (chat-surface contract §2.5) — the Teams wiring over core's
2
+ // pure engagement policy (`packages/core/agent/channels/engagement.ts`). It gives Teams the middle
3
+ // posture Slack and Discord already have: an @mention marks the conversation ENGAGED, a later PLAIN
4
+ // message in that conversation wakes the agent, and `stand_down` stores the stop.
5
+ //
6
+ // THE ANCHOR is `conversation.id`, and it names a different container per scope — which is exactly
7
+ // what the seam wants:
8
+ // • a TEAM CHANNEL activity carries `<channelId>;messageid=<root>`, so the anchor is the REPLY
9
+ // CHAIN, not the channel. Engagement is per reply chain, matching Slack's per-thread grain.
10
+ // • a GROUP CHAT activity carries the CHAT id, so the anchor is the chat.
11
+ // • a 1:1 (personal) chat carries NO anchor at all. That omission is the honest half, and it is
12
+ // Discord's DM precedent (discord-identity.ts): a 1:1 is answered unconditionally, so no gate
13
+ // anywhere reads a mark on it and writing one would suppress nothing. `stand_down` refuses on a
14
+ // 1:1 for the same reason, in its own words.
15
+ //
16
+ // 🔴 THE PLATFORM CONSTRAINT, AND WHY IT IS NOT A DEFECT. Microsoft delivers a NON-MENTION message
17
+ // to a bot only where the tenant has consented to the matching resource-specific-consent (RSC)
18
+ // grant: `ChannelMessage.Read.Group` for a team channel, `ChatMessage.Read.Chat` for a chat (both
19
+ // are in `teams-app/manifest.json`). Without it, only @mention activities ever arrive, so the seam
20
+ // is armed but nothing can exercise it — the surface answers once per mention, exactly as before.
21
+ // There is NO API that reports whether a tenant granted an RSC permission, so this module states
22
+ // the dependency out loud instead of guessing, and logs the named reason ONCE per scope while no
23
+ // proof has arrived. Never a silent gap (contract §3), and never a refusal either — a declaration.
24
+ //
25
+ // 🔴 BE EXACT ABOUT WHAT THE PROOF COVERS, because the sentence here used to overstate it
26
+ // (OPE-1527). The probe sits on the seam's own read, which the front reaches for a HUMAN-authored
27
+ // activity with a RESOLVABLE partition and nothing else: a machine-authored activity skips the seam,
28
+ // and an activity whose identity will not resolve returns `unanchored` above the probe. So the proof
29
+ // is one-directional. A scope that HAS printed the proof is granted — Microsoft delivered a
30
+ // non-mention message, and nothing else could have. A scope that has NOT printed it says only that
31
+ // no such message has been SEEN in this process; the grant may be present and the traffic may simply
32
+ // have been mentions, bot posts, or activities this deployment could not anchor. A serverless
33
+ // invocation is a fresh process, so a granted tenant keeps printing "delivery UNPROVEN" per cold
34
+ // start. That is the honest reading of a signal no API can give us, not a defect to design around.
35
+
36
+ import type { TeamsConversationScope, TeamsMessageActivity } from "eve/channels/teams";
37
+ import { buildTeamsAuth, teamsMembershipType, teamsMode } from "./teams-identity.js";
38
+ import {
39
+ getOperationalStore,
40
+ getThreadEngagement,
41
+ markThreadEngaged,
42
+ resolvePartition,
43
+ roomEngagementAddress,
44
+ shouldDeliverReply,
45
+ type EngagementRow,
46
+ type Partition,
47
+ } from "@open-everywhere/core/agent/adapter-api.js";
48
+
49
+ /** The RSC permission Microsoft requires before it delivers NON-MENTION messages in this scope, or
50
+ * `undefined` for a scope the seam does not anchor (a 1:1 chat, which needs no grant — a personal
51
+ * message is delivered to its own bot unconditionally). */
52
+ export function teamsSeamGrant(scope: TeamsConversationScope): string | undefined {
53
+ if (scope === "personal") return undefined;
54
+ return scope === "groupChat" ? "ChatMessage.Read.Chat" : "ChannelMessage.Read.Group";
55
+ }
56
+
57
+ /** The named platform reason the seam is UNSUPPORTED without the tenant's RSC grant. One sentence,
58
+ * naming the grant, so a log line or a doc row can carry it verbatim. */
59
+ export function teamsSeamUnsupportedReason(scope: TeamsConversationScope): string {
60
+ const grant = teamsSeamGrant(scope) ?? "no RSC grant";
61
+ const room = scope === "groupChat" ? "group-chat" : "channel";
62
+ return (
63
+ `Microsoft delivers non-mention ${room} messages only with the ${grant} RSC grant consented in this tenant; ` +
64
+ `without it a plain follow-up never reaches this deployment, so the engaged seam can wake nothing here`
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Is this activity MACHINE-AUTHORED — the agent itself, a Bot-Framework bot role, or any `28:`
70
+ * application id?
71
+ *
72
+ * 🔴 ONE PREDICATE, TWO CALLERS, AND THAT IS THE POINT. `teams-front.ts` computes this to decide
73
+ * whether an activity may wake anything, and the native gate below needs the SAME answer: a
74
+ * machine-authored CHANNEL activity is still FORWARDED to the native path (only the DM branch and
75
+ * the single-window branch drop one), so an engaged conversation would otherwise admit a bot's own
76
+ * post and self-trigger. A weaker second copy here — `from.role === "bot"` alone, say — would miss
77
+ * exactly the id-shaped cases the front catches.
78
+ */
79
+ export function teamsMachineAuthored(a: TeamsMessageActivity): boolean {
80
+ // DEFENSIVE ON `recipient`, unlike the front's own copy. The front only ever sees an activity
81
+ // `parseTeamsActivity` produced, which always carries one; this is also called from the native
82
+ // handler, which a test or an ops script can drive with a hand-built activity. An absent recipient
83
+ // must not throw here — the author question still has two other answers.
84
+ const self = a.recipient?.id;
85
+ return (self !== undefined && a.from.id === self) || a.from.role === "bot" || a.from.id.startsWith("28:");
86
+ }
87
+
88
+ /**
89
+ * The engagement-registry anchor for one conversation, or `undefined` for a 1:1 chat.
90
+ *
91
+ * 🔴 IT IS THE ONE PREDICATE THE MAPPER AND THE FRONT SHARE. `bindTeams` publishes exactly this as
92
+ * `engagementKey`, so `stand_down`'s mark and this seam's read address the same row. Two spellings
93
+ * of "which conversations are anchored" is how a stop comes to mark a row no gate reads.
94
+ */
95
+ export function teamsEngagementAnchor(scope: TeamsConversationScope, conversationId: string | undefined): string | undefined {
96
+ if (!conversationId || conversationId.length === 0) return undefined;
97
+ return teamsMode(scope) === "dm" ? undefined : conversationId;
98
+ }
99
+
100
+ // --- the RSC support declaration (per-process, once per scope) ---------------------------------
101
+ //
102
+ // Module-scoped once-flags, the same shape `teams-front.ts`'s `warnedDedupeStore` uses: a serverless
103
+ // invocation is a fresh process, so "once" means once per cold start per scope rather than once
104
+ // forever. That is the right grain — the declaration is for the operator reading logs, and a
105
+ // deployment whose grant is missing keeps saying so on each cold start rather than saying it once
106
+ // and never again.
107
+
108
+ const seamDeliveryProven = new Set<string>();
109
+ const seamUnsupportedLogged = new Set<string>();
110
+
111
+ /** Record that a NON-MENTION message arrived in this scope — positive proof the RSC grant is
112
+ * consented, because Microsoft would not have delivered it otherwise. ONE-DIRECTIONAL: silence
113
+ * here is not evidence of an absent grant (see the module header). */
114
+ export function noteTeamsSeamDelivery(scope: TeamsConversationScope): void {
115
+ if (teamsSeamGrant(scope) !== undefined) seamDeliveryProven.add(scope);
116
+ }
117
+
118
+ /** Has a non-mention message ever arrived in this scope in this process? */
119
+ export function teamsSeamDeliveryProven(scope: TeamsConversationScope): boolean {
120
+ return seamDeliveryProven.has(scope);
121
+ }
122
+
123
+ /**
124
+ * Declare the seam UNSUPPORTED-so-far for this scope, ONCE. Called when an @mention arms the seam
125
+ * and no non-mention message has yet proved the grant present. It logs and returns whether it
126
+ * logged, so a test can drive the once-ness; it never throws and never suppresses a wake.
127
+ */
128
+ export function noteTeamsSeamArmed(scope: TeamsConversationScope): boolean {
129
+ if (teamsSeamGrant(scope) === undefined) return false; // a 1:1 needs no grant
130
+ if (seamDeliveryProven.has(scope) || seamUnsupportedLogged.has(scope)) return false;
131
+ seamUnsupportedLogged.add(scope);
132
+ // WORDED AS A DEPENDENCY, NOT A VERDICT. On a cold start whose first activity is a mention, nothing
133
+ // has been observed either way — and a serverless invocation is a fresh process, so that is the
134
+ // COMMON case even on a fully granted tenant. Saying "unsupported" there would be a confident wrong
135
+ // answer in an operator's log. The line says what the seam needs and what would prove it instead.
136
+ console.warn(`[open-everywhere:teams-engagement] engaged seam armed, delivery UNPROVEN in this process — ${teamsSeamUnsupportedReason(scope)}. One non-mention message from a person, in a conversation this deployment can anchor, would prove the grant present; nothing else this process sees can.`);
137
+ return true;
138
+ }
139
+
140
+ /** Test seam: clear both per-process flags. */
141
+ export function resetTeamsSeamSupport(): void {
142
+ seamDeliveryProven.clear();
143
+ seamUnsupportedLogged.clear();
144
+ }
145
+
146
+ // --- the verdict -------------------------------------------------------------------------------
147
+
148
+ /**
149
+ * What the seam says about one inbound activity. Four outcomes, and each names what the CALLER owes:
150
+ *
151
+ * • `unanchored` — a 1:1 chat (or an activity with no conversation id). The seam has nothing to say
152
+ * and the caller keeps today's path exactly.
153
+ * • `engage` — an @mention. The caller marks the conversation engaged ({@link markTeamsEngaged});
154
+ * the mention itself is answered by the caller's normal path, never by the seam. It carries the
155
+ * row's `org` so the caller can DEDUPE the mark: a mark re-invites, which clears a `stopped`
156
+ * flag, and a redelivered mention must never clear a stand-down nobody asked to lift.
157
+ * • `deliver` — a plain message in an engaged conversation. The caller wakes a turn.
158
+ * • `hold` — a plain message the seam does not admit. `stopped` says WHY, and it is the one field a
159
+ * caller must branch on: `stopped: true` means the agent was asked to stand down here, so the
160
+ * message must be SUPPRESSED outright — including on an ambient leg that never reads this
161
+ * registry. `stopped: false` means the conversation simply is not engaged, which is not a
162
+ * suppression: the caller keeps today's path.
163
+ */
164
+ export type TeamsEngagementVerdict =
165
+ | { readonly kind: "unanchored"; readonly reason: string }
166
+ | { readonly kind: "engage"; readonly anchor: string; readonly org: string }
167
+ | { readonly kind: "deliver"; readonly anchor: string; readonly reason: string }
168
+ | { readonly kind: "hold"; readonly anchor: string; readonly reason: string; readonly stopped: boolean };
169
+
170
+ /** Injectable seams so the whole verdict is unit-tested without a store. */
171
+ export interface TeamsEngagementDeps {
172
+ readonly getEngagement?: (p: Partition, anchor: string) => Promise<EngagementRow | null>;
173
+ readonly markEngaged?: (p: Partition, anchor: string, atIso: string) => Promise<void>;
174
+ readonly now?: () => string;
175
+ }
176
+
177
+ /**
178
+ * The partition one activity's engagement row lives in — the CONVERSATION grain
179
+ * (`teams/<tenant>/<conversationId>`), never `domainKeyOf`'s channel grain.
180
+ *
181
+ * 🔴 THE GRAIN IS THE CONTRACT WITH `stand_down`. The verb resolves its own partition through
182
+ * `bindFrom` → `resolvePartition`, which keys on the binding's `surface` (the conversation). A seam
183
+ * that read the widened channel key would look at a different row from the one the stop marked, and
184
+ * the stop would suppress nothing. Null on an unresolvable identity — the caller then keeps today's
185
+ * path, the fail-open posture every carriage leg in this file family takes.
186
+ */
187
+ function teamsSeamPartition(a: TeamsMessageActivity): Partition | null {
188
+ try {
189
+ const auth = buildTeamsAuth({
190
+ userId: a.from.id,
191
+ tenantId: a.tenantId ?? a.conversation.tenantId,
192
+ conversationId: a.conversation.id,
193
+ channelId: a.teamsChannelId,
194
+ teamId: a.teamId,
195
+ scope: a.scope,
196
+ membershipType: teamsMembershipType(a.channelData),
197
+ });
198
+ return resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Resolve the seam for one verified message activity — READ-ONLY. The mark is a separate call
206
+ * ({@link markTeamsEngaged}) so that exactly one caller writes the row: two writers is how a mention
207
+ * comes to engage a conversation twice per activity, once on each path.
208
+ *
209
+ * ORDERING. A mention is settled before any read: it is always answered by the caller's own path, so
210
+ * its verdict costs no store round-trip. Only a plain message pays for a read, and it pays for
211
+ * exactly one — the ROW, not a boolean, because the row carries both facts the decision needs (is
212
+ * this engaged, and was the agent asked to stand down here) and two reads could disagree.
213
+ *
214
+ * The DECISION ITSELF IS CORE'S. `shouldDeliverReply` owns the disqualifier order and the reasons;
215
+ * this function only translates Teams facts into its input. Nothing here re-states the policy.
216
+ *
217
+ * FAIL-CLOSED on a store error: `getThreadEngagement` reads a failure as `null`, so an unreadable
218
+ * registry holds rather than delivers — the agent does not barge into a conversation it cannot prove
219
+ * it was invited to.
220
+ */
221
+ export async function resolveTeamsEngagement(
222
+ a: TeamsMessageActivity,
223
+ machineAuthored: boolean,
224
+ deps: TeamsEngagementDeps = {},
225
+ ): Promise<TeamsEngagementVerdict> {
226
+ const anchor = teamsEngagementAnchor(a.scope, a.conversation.id);
227
+ if (!anchor) return { kind: "unanchored", reason: "a 1:1 chat carries no engagement anchor — it is answered unconditionally" };
228
+ const partition = teamsSeamPartition(a);
229
+ if (!partition) return { kind: "unanchored", reason: "the activity has no resolvable partition — no row to address" };
230
+
231
+ if (a.isBotMentioned) return { kind: "engage", anchor, org: partition.org };
232
+
233
+ // A non-mention message ARRIVED, which is positive proof Microsoft is delivering this scope's
234
+ // ambient traffic — the RSC grant is consented. Recorded before the decision, so a stood-down
235
+ // conversation still counts as proof.
236
+ noteTeamsSeamDelivery(a.scope);
237
+
238
+ const read = deps.getEngagement ?? ((p, key) => getThreadEngagement(p, key));
239
+ const row = await read(partition, anchor);
240
+ const decision = shouldDeliverReply({
241
+ isEngaged: row !== null,
242
+ stopped: row?.stopped ?? false,
243
+ authorIsBot: machineAuthored,
244
+ isAppMention: false, // settled above — a mention never reaches this branch
245
+ inThread: true, // an anchored Teams conversation IS the container engagement is kept per
246
+ });
247
+ if (decision.deliver) return { kind: "deliver", anchor, reason: decision.reason };
248
+ return { kind: "hold", anchor, reason: decision.reason, stopped: row?.stopped === true };
249
+ }
250
+
251
+ /**
252
+ * Mark one conversation ENGAGED on an @mention, and declare the RSC dependency once.
253
+ *
254
+ * `participating: true` is the re-invitation: it promotes the row and CLEARS a previous stand-down,
255
+ * which is what "an @mention brings the agent back" means on every surface that wires this seam.
256
+ * Best-effort throughout — `markThreadEngaged` swallows a store failure, because engagement
257
+ * bookkeeping must never fail a turn.
258
+ *
259
+ * 🔴 THE PROMOTION CARRIES THE ACTIVITY ID, SO REPLAYING ONE THE ROW STILL HOLDS LIFTS NOTHING
260
+ * (OPE-1527). The
261
+ * Bot Framework redelivers an activity whenever it does not get a prompt 200, and a redelivered
262
+ * @mention is a REPLAYED invitation: it used to clear a `stopped` flag nobody asked to lift. The
263
+ * front's dedupe caught the ordinary case and could not catch this one, because it expires — its
264
+ * store TTL is ten minutes and a stand-down has no expiry at all, so a redelivery on a fresh process
265
+ * an hour later re-marked a stood-down conversation as engaged. `engagedBy` closes that durably: the
266
+ * store records the promoting id and refuses to clear `stopped` when a promotion repeats it. A
267
+ * SECOND, GENUINE @mention carries a different activity id and still brings the agent back, which is
268
+ * the behaviour this must not break.
269
+ *
270
+ * BE EXACT ABOUT THE REACH. The store keeps a BOUNDED LIST of recent promoters (OPE-1562), so what
271
+ * cannot be replayed is every promotion the list still holds — see `EngagementRow.recentPromoters`.
272
+ * Mention A, mention B, a stand-down, then a redelivery of A leaves the stop SET: a superseded
273
+ * mention is guarded like the latest one. What remains open is the BOUND — a replay of a mention
274
+ * that `PROMOTER_LIST_MAX` distinct later promotions have pushed off the list clears again.
275
+ */
276
+ export async function markTeamsEngaged(a: TeamsMessageActivity, deps: TeamsEngagementDeps = {}): Promise<string | null> {
277
+ const anchor = teamsEngagementAnchor(a.scope, a.conversation.id);
278
+ if (!anchor) return null;
279
+ const partition = teamsSeamPartition(a);
280
+ if (!partition) return null;
281
+ const now = deps.now ?? (() => new Date().toISOString());
282
+ const mark =
283
+ deps.markEngaged ?? ((p, key, atIso) => markThreadEngaged(p, key, atIso, { participating: true, engagedBy: a.id }));
284
+ await mark(partition, anchor, now());
285
+ // The seam is armed here. Whether Microsoft will DELIVER the follow-ups is the tenant's RSC grant,
286
+ // which nothing can probe — so say it once rather than leave the gap silent.
287
+ noteTeamsSeamArmed(a.scope);
288
+ return anchor;
289
+ }
290
+
291
+ // --- THE ROOM-GRAIN WATCH ROW (OPE-1511) -------------------------------------------------------
292
+ //
293
+ // 🔴 A SECOND GRAIN, FOR A SECOND WAKE. Everything above is keyed per CONVERSATION, because an
294
+ // @mention engages a reply chain. Leg A of the single-window front (teams-front.ts) is a different
295
+ // wake with a different grain: on a vouched channel it wakes on ANY message in the ROOM, including a
296
+ // reply chain nobody ever @mentioned the agent in. That wake read no registry of its own, so
297
+ // `stand_down` found no row, refused, and the only off switch left was an operator un-vouching the
298
+ // surface — a person in the room could not stop it and the agent could not stop it for them.
299
+ //
300
+ // THE RULE IS THAT A STORED STOP EXISTS EXACTLY WHERE A STORED WAKE EXISTS. So Leg A stores a
301
+ // room-grain row on each ambient wake, its own gate reads that row's `stopped` flag, and
302
+ // `tools/stand_down.ts` marks it when the conversation grain holds nothing. The address is core's
303
+ // (`roomEngagementAddress`), derived from the partition by BOTH sides, so the wake and the stop
304
+ // cannot drift onto two spellings of one room.
305
+ //
306
+ // WHAT THE ROOM ROW IS AND IS NOT. Leg E never reads it: the seam above keys on the CONVERSATION and
307
+ // is untouched by any of this. The only two fields that matter here are `stopped` and — through the
308
+ // promotion that clears it — `recentPromoters`.
309
+ //
310
+ // 🔴 `participating` ON A ROOM ROW MEANS NOTHING, AND THAT IS A HAZARD WORTH NAMING. An ambient
311
+ // WATCH writes `participating: false`, but the @mention clear below writes `true`, and the flag is
312
+ // monotonic in both store backends — so after the first @mention in the room it stays `true`
313
+ // forever. Nothing reads it today: no production caller reads `listEngagements`, and Teams never
314
+ // calls `bumpSilentStreak`, which is what the flag exists to gate. It is set as a side effect of
315
+ // being the only way to clear `stopped`, not as a claim that anybody invited the agent into the
316
+ // room. A future warm-shed rule must not read this row and conclude the agent was invited here.
317
+
318
+ /**
319
+ * Has a stand-down landed on this partition's ROOM row?
320
+ *
321
+ * 🔴 FAIL-CLOSED, and the direction is the opposite of core's `isThreadStopped` on purpose. That
322
+ * predicate fails OPEN because it decides whether to SILENCE an answer the model has already
323
+ * written, where "unknown" must mean deliver. This one is a WAKE GATE: it decides whether to barge
324
+ * into a room nobody invited the agent into, where "unknown" must mean stay out. It is the same
325
+ * posture the group-chat branch of `teams-front.ts` takes two screens above, in its own words — an
326
+ * unreadable registry cannot prove the agent is still welcome.
327
+ *
328
+ * The cost is real and worth stating: while the operational store is unreadable, EVERY room on this
329
+ * surface holds its ambient wakes, including rooms nobody ever stood down. An @mention is
330
+ * unaffected — the addressed arm never reads this — so a person can still reach the agent through
331
+ * the affordance the stop copy itself points them at. That is the right trade for a gate whose
332
+ * failure mode in the other direction is an agent that keeps talking in a room that asked it to
333
+ * stop, and cannot be re-silenced because the same outage fails the write.
334
+ *
335
+ * It reads the store DIRECTLY rather than through `getThreadEngagement`, because that wrapper
336
+ * swallows an error to `null` and a null is indistinguishable from "no row" — which is exactly the
337
+ * distinction this answer turns on.
338
+ */
339
+ export async function teamsRoomWatchStopped(partition: Partition): Promise<boolean> {
340
+ const room = roomEngagementAddress(partition);
341
+ try {
342
+ return (await getOperationalStore().getEngagement(room.partition.org, room.partition.surfaceKey, room.anchor))?.stopped === true;
343
+ } catch (err) {
344
+ console.warn(`[open-everywhere:teams-engagement] room stop unreadable for ${room.partition.surfaceKey} — HOLDING the ambient wake (fail-closed): ${String(err)}`);
345
+ return true;
346
+ }
347
+ }
348
+
349
+ /** Store the ROOM-grain wake row for an ambient (undirected) Leg A wake — the row a later stop
350
+ * marks. Best-effort: `markThreadEngaged` swallows a store failure, and a lost watch row costs a
351
+ * stop that could not land, never a turn. */
352
+ export async function markTeamsRoomWatched(partition: Partition, atIso: string): Promise<void> {
353
+ const room = roomEngagementAddress(partition);
354
+ // `participating: false` — a WATCH, not an invitation. It also means this write never clears a
355
+ // `stopped` flag, which is what keeps an ambient wake from lifting the stand-down that should be
356
+ // suppressing it.
357
+ await markThreadEngaged(room.partition, room.anchor, atIso, { participating: false });
358
+ }
359
+
360
+ /**
361
+ * Lift a ROOM-grain stand-down on an @mention — the way back in, and the reason the room stop is not
362
+ * a trap. Without it a room stop would outlive every invitation: Leg E's mention mark promotes the
363
+ * CONVERSATION row, which says nothing about the room, so the room would stay silent with no verb
364
+ * able to reach it.
365
+ *
366
+ * `engagedBy` carries the mention's activity id for the same reason the conversation mark does — a
367
+ * redelivered mention must not lift a stand-down twice over.
368
+ */
369
+ export async function clearTeamsRoomStop(partition: Partition, atIso: string, engagedBy: string): Promise<void> {
370
+ const room = roomEngagementAddress(partition);
371
+ await markThreadEngaged(room.partition, room.anchor, atIso, { participating: true, engagedBy });
372
+ }
373
+
374
+ /**
375
+ * The NATIVE path's one-line admission (channels/teams.ts's mention gate): may this plain,
376
+ * non-mention message run a turn?
377
+ *
378
+ * It exists because the native channel is where a Teams turn is actually minted — the front holds no
379
+ * continuation-token primitive it could wake an existing native session with, so admitting the
380
+ * activity through the mention gate IS the wake. The front owns the SUPPRESSION half above it (a
381
+ * stood-down conversation is never forwarded at all) and the MARK, so this call only ever answers
382
+ * the delivery question, and it never writes.
383
+ */
384
+ export async function teamsEngagedAdmits(a: TeamsMessageActivity, deps: TeamsEngagementDeps = {}): Promise<boolean> {
385
+ if (a.isBotMentioned) return true; // the caller's own mention gate already admitted it
386
+ // 🔴 THE AUTHOR GATE RUNS HERE TOO, over the SAME predicate the front uses. A machine-authored
387
+ // CHANNEL activity IS forwarded to the native path, so this is the line that stops an engaged
388
+ // conversation from admitting the agent's own post and looping on itself.
389
+ const verdict = await resolveTeamsEngagement(a, teamsMachineAuthored(a), deps);
390
+ return verdict.kind === "deliver";
391
+ }
@@ -0,0 +1,152 @@
1
+ // teams-fire-eligibility — Microsoft Graph `accountEnabled` + channel-membership probes for the
2
+ // routine fire-eligibility leg (OPE-522), mirroring core's Slack-only predicate
3
+ // (@open-everywhere/core routines/fire-eligibility.ts, OPE-506) for Teams. Registered into core's
4
+ // teams-bridge.ts (channels/teams-bridge.ts) by registerTeamsProvider() — core stays free of
5
+ // Graph-specific parsing, the same split teams-read.ts already draws for channel reads.
6
+ //
7
+ // IDENTITY: both probes are keyed on the creator's Azure AD OBJECT id, never the Bot Framework
8
+ // `from.id` that `Routine.createdBy` carries for Teams — a DIFFERENT identifier (see
9
+ // teams-identity.ts's separate `aadObjectId` capture, and core's fire-eligibility.ts header). The
10
+ // caller (core's dispatch.ts) reads it back off the routine's stored target via
11
+ // register.ts's resolveTeamsTarget, which stamps it onto the fresh DispatchTarget's `attributes`.
12
+ //
13
+ // TOKEN + TRANSPORT: reuses teams-read.ts's `liveTeamsGraphRequest` (app-only client-credentials
14
+ // Graph token, warm-cached, single-flight) — these are ordinary Graph GETs, no different in shape
15
+ // from a channel-history read. `liveTeamsGraphRequest` fails LOUD (throws) on a missing token or a
16
+ // non-ok response; every probe below catches around it, because this module's contract is fail-OPEN
17
+ // on uncertainty, not fail-loud like the read adapter's.
18
+ //
19
+ // FAIL-OPEN ON UNCERTAINTY, same posture as every other probe in this module family
20
+ // (routines/fire-eligibility.ts, fire-creator.ts): only a well-formed Graph response carrying a
21
+ // POSITIVE "no" (`accountEnabled === false`, or a FULLY-ENUMERATED member list that does not include
22
+ // the creator) skips a fire. A network error, an auth failure, a malformed response shape, or an id
23
+ // Graph can't resolve is UNRESOLVABLE — never a fabricated "no". This is DELIBERATELY STRICTER than
24
+ // routines/fire-eligibility.ts's own Slack room-membership probe on one point: that probe's
25
+ // pagination bound (ROOM_MEMBER_MAX_PAGES) falls through to a cached `false` when a channel has more
26
+ // members than the bound can scan, which reads as a confirmed "not a member" even though the roster
27
+ // was never fully seen. This module tracks whether the LAST page fetched still had a `nextLink`
28
+ // (more roster left unseen) and reports `roster_incomplete` in that case — "hit the page bound with
29
+ // more to see" is kept distinct from "scanned every member and did not find them".
30
+ //
31
+ // 🔴 THE MEMBERSHIP READ TAKES THE GRAPH TEAM ID, AND THE ACTIVITY DOES NOT CARRY ONE (OPE-1810).
32
+ // A Teams activity carries the Bot Connector's team id, a `19:…@thread.tacv2` string; every Graph
33
+ // URL under `/teams/{team-id}/…` takes the team's Microsoft 365 GROUP id instead. Handing the
34
+ // Connector spelling to Graph addresses nothing and answers 404 — so before OPE-1810 this probe
35
+ // threw on EVERY call, read as unresolvable, and the creator check never once enforced. It now
36
+ // resolves the one into the other through channels/teams-graph-team-id.ts, the same call the read
37
+ // verbs and the catch-up window already make.
38
+ //
39
+ // 🔴 AND THE ANSWER NOW BINDS (Tommy, 2026-09-13). A creator confirmed OUT of the channel stops
40
+ // getting their routine, the same outcome and the same `creator_removed_from_room` audit row the
41
+ // Slack path has produced since OPE-506. Fail-open survives only for an answer the probe could not
42
+ // GET, and every such arm names its own cause ({@link TeamsMembershipUnresolved}) so the dispatcher
43
+ // can audit it — a fail-open that writes nothing is indistinguishable from a pass, which is the
44
+ // state this module was in for its whole life before this ticket.
45
+
46
+ import type {
47
+ TeamsAccountActiveCheck,
48
+ TeamsMembershipUnresolved,
49
+ TeamsRoomMembership,
50
+ TeamsRoomMembershipCheck,
51
+ } from "@open-everywhere/core/agent/adapter-api.js";
52
+ import { teamsGraphTeamId } from "./teams-graph-team-id.js";
53
+ import { liveTeamsGraphRequest, type TeamsGraphRequester } from "./teams-read.js";
54
+
55
+ const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
56
+
57
+ /** Bounds a busy channel roster's pagination (mirrors teams-read.ts's TEAMS_GRAPH_MAX_PAGES, a
58
+ * larger bound here since a roster page is denser than a message page). */
59
+ const TEAMS_MEMBER_MAX_PAGES = 10;
60
+
61
+ interface GraphConversationMember {
62
+ /** The Azure AD object id for an `aadUserConversationMember` (every human Teams member). */
63
+ readonly userId?: string;
64
+ }
65
+
66
+ interface GraphMemberPage {
67
+ readonly value?: readonly GraphConversationMember[];
68
+ readonly ["@odata.nextLink"]?: string;
69
+ }
70
+
71
+ /** The one spelling of an unresolved answer, so no arm below can forget to name its cause. */
72
+ function unresolved(cause: TeamsMembershipUnresolved): TeamsRoomMembership {
73
+ return { member: undefined, unresolved: cause };
74
+ }
75
+
76
+ /**
77
+ * Is `aadObjectId` a member of Teams channel `channelId` (inside team `botTeamId`) RIGHT NOW?
78
+ * Resolves `botTeamId` — the activity's own Bot Connector team id — into the Microsoft 365 group id
79
+ * Graph takes, then paginates `GET /teams/{team}/channels/{channel}/members`, bounded at
80
+ * {@link TEAMS_MEMBER_MAX_PAGES}.
81
+ *
82
+ * Pure but for the injected `req` — exported (not the `req`-defaulted live wrapper below) so the
83
+ * resolution, the pagination and every fail-open arm are unit-testable with a canned requester, no
84
+ * token mint and no network call. The team-id resolution is driven in a test through
85
+ * teams-graph-team-id.ts's own `_seedTeamsGraphTeamIdForTest` seam rather than a second injected
86
+ * dependency here: one team id maps to one group id for the life of the process, so the cache IS
87
+ * the seam.
88
+ */
89
+ export async function teamsRoomMembershipViaGraph(
90
+ botTeamId: string,
91
+ channelId: string,
92
+ aadObjectId: string,
93
+ serviceUrl: string | undefined,
94
+ req: TeamsGraphRequester,
95
+ ): Promise<TeamsRoomMembership> {
96
+ if (botTeamId.length === 0 || channelId.length === 0 || aadObjectId.length === 0) return unresolved("missing_ids");
97
+ // 🔴 RESOLVE FIRST, AND NEVER FALL BACK TO `botTeamId`. A fallback would put the Connector
98
+ // spelling back in the Graph URL, which is the exact defect OPE-1810 removes — and because the
99
+ // read would then 404 into `read_error`, the fallback would be INVISIBLE in the audit trail.
100
+ const graphTeamId = await teamsGraphTeamId(botTeamId, serviceUrl);
101
+ if (graphTeamId === undefined) return unresolved("team_id_unresolved");
102
+ try {
103
+ let url: string | undefined = `${GRAPH_BASE}/teams/${encodeURIComponent(graphTeamId)}/channels/${encodeURIComponent(channelId)}/members`;
104
+ for (let page = 0; page < TEAMS_MEMBER_MAX_PAGES && url; page++) {
105
+ const json = (await req(url)) as GraphMemberPage;
106
+ // A genuine Graph success always carries `value` as an array, even an empty one — an
107
+ // unrecognised shape is read as UNRESOLVABLE, never as "confirmed nobody's in this channel"
108
+ // (the same posture routines/fire-eligibility.ts's liveRoomMembership takes on a malformed
109
+ // `members` field).
110
+ if (!Array.isArray(json.value)) return unresolved("malformed_response");
111
+ if (json.value.some((m) => m.userId === aadObjectId)) return { member: true };
112
+ url = typeof json["@odata.nextLink"] === "string" ? json["@odata.nextLink"] : undefined;
113
+ }
114
+ // `url` still set here means the page bound was hit with roster left unseen — that is NOT the
115
+ // same fact as "scanned every member and they weren't in it", so it stays unresolved rather than
116
+ // becoming a confirmed (and possibly wrong) "removed". Only a loop that ended because pagination
117
+ // itself ran out (`url` fell to `undefined`) has actually seen the WHOLE roster — and THAT is
118
+ // the answer that now skips a fire.
119
+ return url ? unresolved("roster_incomplete") : { member: false };
120
+ } catch {
121
+ return unresolved("read_error"); // token mint failure / network error / a non-ok Graph response
122
+ }
123
+ }
124
+
125
+ /** Live default: {@link teamsRoomMembershipViaGraph} through the real Graph token + transport. */
126
+ export const liveTeamsRoomMembership: TeamsRoomMembershipCheck = (teamId, channelId, aadObjectId, serviceUrl) =>
127
+ teamsRoomMembershipViaGraph(teamId, channelId, aadObjectId, serviceUrl, liveTeamsGraphRequest);
128
+
129
+ interface GraphUser {
130
+ readonly accountEnabled?: boolean;
131
+ }
132
+
133
+ /**
134
+ * Is `aadObjectId`'s Entra account CURRENTLY enabled? `GET /users/{id}?$select=accountEnabled`.
135
+ * Pure but for the injected `req` — same DI shape as {@link teamsRoomMembershipViaGraph}.
136
+ */
137
+ export async function teamsAccountActiveViaGraph(aadObjectId: string, req: TeamsGraphRequester): Promise<boolean | undefined> {
138
+ if (aadObjectId.length === 0) return undefined;
139
+ try {
140
+ const json = (await req(`${GRAPH_BASE}/users/${encodeURIComponent(aadObjectId)}?$select=accountEnabled`)) as GraphUser;
141
+ // Only a POSITIVELY-TYPED boolean field counts — a response Graph returned for a different
142
+ // shape (or a 404/error already caught below) never reads as a fabricated "active" OR
143
+ // "inactive". Never a fabricated "no": the field must be `=== false`, not merely falsy/absent.
144
+ if (typeof json?.accountEnabled !== "boolean") return undefined;
145
+ return json.accountEnabled;
146
+ } catch {
147
+ return undefined; // 404 (unresolvable id) / auth error / transport failure ⇒ unresolvable
148
+ }
149
+ }
150
+
151
+ /** Live default: {@link teamsAccountActiveViaGraph} through the real Graph token + transport. */
152
+ export const liveTeamsAccountActive: TeamsAccountActiveCheck = (aadObjectId) => teamsAccountActiveViaGraph(aadObjectId, liveTeamsGraphRequest);