@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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +46 -2
- package/agent/channels/teams-bot-name.ts +162 -0
- package/agent/channels/teams-catch-up.ts +244 -0
- package/agent/channels/teams-connector.ts +183 -0
- package/agent/channels/teams-conversation-ref.ts +398 -0
- package/agent/channels/teams-directory.ts +193 -0
- package/agent/channels/teams-engagement.ts +391 -0
- package/agent/channels/teams-fire-eligibility.ts +152 -0
- package/agent/channels/teams-front.ts +1404 -0
- package/agent/channels/teams-graph-boundary.ts +178 -0
- package/agent/channels/teams-graph-team-id.ts +106 -0
- package/agent/channels/teams-id-restore.ts +131 -0
- package/agent/channels/teams-identity.ts +395 -0
- package/agent/channels/teams-input-response.ts +129 -0
- package/agent/channels/teams-message-ops.ts +79 -0
- package/agent/channels/teams-origin-codec.ts +104 -0
- package/agent/channels/teams-post-arm.ts +323 -0
- package/agent/channels/teams-reaction-feedback.ts +127 -0
- package/agent/channels/teams-read.ts +510 -0
- package/agent/channels/teams-roster.ts +136 -0
- package/agent/channels/teams-stop.ts +319 -0
- package/agent/channels/teams-supervisor.ts +638 -0
- package/agent/channels/teams.ts +270 -0
- package/agent/contributions.ts +31 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +129 -0
- package/agent/provider-events.ts +69 -0
- package/agent/register.ts +219 -0
- package/agent/surface-parity.ts +112 -0
- package/dist/agent/channels/teams-bot-name.d.ts +36 -0
- package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
- package/dist/agent/channels/teams-bot-name.js +141 -0
- package/dist/agent/channels/teams-bot-name.js.map +1 -0
- package/dist/agent/channels/teams-catch-up.d.ts +53 -0
- package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/teams-catch-up.js +221 -0
- package/dist/agent/channels/teams-catch-up.js.map +1 -0
- package/dist/agent/channels/teams-connector.d.ts +57 -0
- package/dist/agent/channels/teams-connector.d.ts.map +1 -0
- package/dist/agent/channels/teams-connector.js +132 -0
- package/dist/agent/channels/teams-connector.js.map +1 -0
- package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
- package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
- package/dist/agent/channels/teams-conversation-ref.js +368 -0
- package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
- package/dist/agent/channels/teams-directory.d.ts +11 -0
- package/dist/agent/channels/teams-directory.d.ts.map +1 -0
- package/dist/agent/channels/teams-directory.js +179 -0
- package/dist/agent/channels/teams-directory.js.map +1 -0
- package/dist/agent/channels/teams-engagement.d.ts +174 -0
- package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
- package/dist/agent/channels/teams-engagement.js +336 -0
- package/dist/agent/channels/teams-engagement.js.map +1 -0
- package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
- package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
- package/dist/agent/channels/teams-fire-eligibility.js +126 -0
- package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
- package/dist/agent/channels/teams-front.d.ts +249 -0
- package/dist/agent/channels/teams-front.d.ts.map +1 -0
- package/dist/agent/channels/teams-front.js +1143 -0
- package/dist/agent/channels/teams-front.js.map +1 -0
- package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
- package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
- package/dist/agent/channels/teams-graph-boundary.js +168 -0
- package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
- package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
- package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
- package/dist/agent/channels/teams-graph-team-id.js +89 -0
- package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
- package/dist/agent/channels/teams-id-restore.d.ts +34 -0
- package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
- package/dist/agent/channels/teams-id-restore.js +123 -0
- package/dist/agent/channels/teams-id-restore.js.map +1 -0
- package/dist/agent/channels/teams-identity.d.ts +148 -0
- package/dist/agent/channels/teams-identity.d.ts.map +1 -0
- package/dist/agent/channels/teams-identity.js +336 -0
- package/dist/agent/channels/teams-identity.js.map +1 -0
- package/dist/agent/channels/teams-input-response.d.ts +23 -0
- package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
- package/dist/agent/channels/teams-input-response.js +102 -0
- package/dist/agent/channels/teams-input-response.js.map +1 -0
- package/dist/agent/channels/teams-message-ops.d.ts +3 -0
- package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/teams-message-ops.js +72 -0
- package/dist/agent/channels/teams-message-ops.js.map +1 -0
- package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
- package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/teams-origin-codec.js +99 -0
- package/dist/agent/channels/teams-origin-codec.js.map +1 -0
- package/dist/agent/channels/teams-post-arm.d.ts +37 -0
- package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/teams-post-arm.js +280 -0
- package/dist/agent/channels/teams-post-arm.js.map +1 -0
- package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
- package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
- package/dist/agent/channels/teams-reaction-feedback.js +108 -0
- package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
- package/dist/agent/channels/teams-read.d.ts +127 -0
- package/dist/agent/channels/teams-read.d.ts.map +1 -0
- package/dist/agent/channels/teams-read.js +424 -0
- package/dist/agent/channels/teams-read.js.map +1 -0
- package/dist/agent/channels/teams-roster.d.ts +54 -0
- package/dist/agent/channels/teams-roster.d.ts.map +1 -0
- package/dist/agent/channels/teams-roster.js +121 -0
- package/dist/agent/channels/teams-roster.js.map +1 -0
- package/dist/agent/channels/teams-stop.d.ts +74 -0
- package/dist/agent/channels/teams-stop.d.ts.map +1 -0
- package/dist/agent/channels/teams-stop.js +252 -0
- package/dist/agent/channels/teams-stop.js.map +1 -0
- package/dist/agent/channels/teams-supervisor.d.ts +351 -0
- package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/teams-supervisor.js +366 -0
- package/dist/agent/channels/teams-supervisor.js.map +1 -0
- package/dist/agent/channels/teams.d.ts +5 -0
- package/dist/agent/channels/teams.d.ts.map +1 -0
- package/dist/agent/channels/teams.js +246 -0
- package/dist/agent/channels/teams.js.map +1 -0
- package/dist/agent/contributions.d.ts +12 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +31 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +14 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +122 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +67 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +16 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +198 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +106 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +183 -4
- package/tsconfig.build.json +22 -0
- 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);
|