@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,1404 @@
|
|
|
1
|
+
// The Teams FRONT route — the single-window legs' live wiring, porting slack-front.ts's proven
|
|
2
|
+
// shape to Teams (build order: single-window-generalisation-strategy.md task #46, Teams arm).
|
|
3
|
+
//
|
|
4
|
+
// WHY A FRONT (same reason as Slack): eve's `teamsChannel` owns the public Bot-Framework webhook
|
|
5
|
+
// and dispatches every message activity to its own conversation-session machinery; it exposes no
|
|
6
|
+
// hook that can DIVERT a wake into another channel (`onMessage` has no cross-channel `to`).
|
|
7
|
+
// So the public URL is fronted: the wrapped `teamsChannel` is mounted at an INTERNAL route
|
|
8
|
+
// ({@link INTERNAL_TEAMS_ROUTE}) and OUR route sits at the public {@link PUBLIC_TEAMS_ROUTE}.
|
|
9
|
+
// Both routes live on the SAME channel object (one file slug → one channel name → one session
|
|
10
|
+
// namespace), exactly the slack-front composition.
|
|
11
|
+
//
|
|
12
|
+
// The front route:
|
|
13
|
+
// 1. Verifies the inbound request (eve's PUBLIC `verifyTeamsRequest` — the Bot Connector JWT
|
|
14
|
+
// against Microsoft's OpenID keys + our app id; fail-closed 401). Unlike the Slack front we
|
|
15
|
+
// don't re-implement the check: eve exports it. The forward path re-verifies inside
|
|
16
|
+
// teamsChannel (a fresh Request), so the native path's auth is unchanged — we only ADD a
|
|
17
|
+
// gate, never weaken one.
|
|
18
|
+
// 2. Parses the activity (eve's public `parseTeamsActivity`). Anything that isn't a message
|
|
19
|
+
// activity — invokes (HITL card clicks), conversationUpdates, unparseable bodies — FORWARDS
|
|
20
|
+
// to teamsChannel untouched (the fail-safe default).
|
|
21
|
+
// 3. For a message activity, resolves the SINGLE-WINDOW plan (teams-supervisor.ts
|
|
22
|
+
// planTeamsSingleWindow: grain singleWindow + the fail-closed Teams privacy floor). NO plan
|
|
23
|
+
// → FORWARD (byte-identical: with the window gates off, every activity takes today's
|
|
24
|
+
// native path). A plan → the activity ITSELF is diverted into the unified window —
|
|
25
|
+
// SINGLE-LEG: Teams fires ONE webhook per message and the floor needs no network, so one
|
|
26
|
+
// synchronous verdict drives both the native suppression (we don't forward) and the
|
|
27
|
+
// addressed/ambient wake (we dispatch) — the Slack build's lost-mention window (two legs
|
|
28
|
+
// disagreeing across webhooks/caches) cannot exist here by construction.
|
|
29
|
+
// 4. Idempotency (two-layer, mirroring slack-front: in-memory + the durable operational store)
|
|
30
|
+
// and per-container FIFO guard the wake.
|
|
31
|
+
//
|
|
32
|
+
// LEG A here is the Teams AMBIENT ingest: a non-mention channel message on a single-window
|
|
33
|
+
// domain becomes a judged channel-watch wake in the shared window. NOTE the platform reality:
|
|
34
|
+
// Teams only delivers non-mention messages to a bot whose app carries the matching RSC permission
|
|
35
|
+
// — `ChannelMessage.Read.Group` for a team channel, `ChatMessage.Read.Chat` for a chat (both are
|
|
36
|
+
// in teams-app/manifest.json). Without the relevant one, only @mention activities arrive and Leg A
|
|
37
|
+
// is simply never exercised (no code path depends on it firing).
|
|
38
|
+
//
|
|
39
|
+
// CHAT SCOPE IS DELIBERATELY NOT LEG A. The plan's privacy floor (teams-supervisor.ts
|
|
40
|
+
// teamsSingleWindowPrivacyOk) admits `scope === "channel"` only, so a group chat never joins the
|
|
41
|
+
// shared cross-surface window. That is the intended posture, not an oversight: a group chat is a
|
|
42
|
+
// per-person surface, not an org container, so admitting one would breach the floor's whole premise
|
|
43
|
+
// (see the floor's docstring). Widening it is a security decision that needs its own review, not a
|
|
44
|
+
// side effect of a manifest line.
|
|
45
|
+
//
|
|
46
|
+
// LEG E is the AMBIENT ENGAGEMENT SEAM (OPE-1500, contract §2.5) and it covers BOTH containers a
|
|
47
|
+
// Teams engagement can be kept per: a channel's reply chain and a group chat. An @mention marks the
|
|
48
|
+
// conversation engaged, a later plain message in it wakes the agent, and `stand_down` stores the
|
|
49
|
+
// stop. Leg E dispatches nothing itself — it marks, and it suppresses a stood-down conversation
|
|
50
|
+
// above every other branch — so no message is ever handled by both legs. The two legs answer
|
|
51
|
+
// different questions: Leg A is a DOMAIN's opted-in ambient posture (a vouched single-window
|
|
52
|
+
// channel), Leg E is a CONVERSATION's own invitation, and it needs no domain opt-in at all.
|
|
53
|
+
//
|
|
54
|
+
// Both legs depend on the SAME platform grant, and Leg E states it out loud: Microsoft delivers
|
|
55
|
+
// non-mention activities only where the tenant consented to `ChannelMessage.Read.Group` (a channel)
|
|
56
|
+
// or `ChatMessage.Read.Chat` (a chat). Nothing can probe that consent, so teams-engagement.ts proves
|
|
57
|
+
// the grant present on the first non-mention message and logs the named reason once until then.
|
|
58
|
+
|
|
59
|
+
import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
|
|
60
|
+
import { verifyTeamsRequest, parseTeamsActivity, teamsThreadRootActivityId, isTeamsInputResponseActivity, type TeamsMessageActivity } from "eve/channels/teams";
|
|
61
|
+
import { sendTeamsActivityBackedOff } from "./teams-connector.js";
|
|
62
|
+
import {
|
|
63
|
+
buildTeamsAuth,
|
|
64
|
+
teamsMembershipType,
|
|
65
|
+
teamsTrustGroup,
|
|
66
|
+
teamsUserTypeFromMember,
|
|
67
|
+
withTeamsUserType,
|
|
68
|
+
type OpenEverywhereTeamsAuth,
|
|
69
|
+
} from "./teams-identity.js";
|
|
70
|
+
import { resolveTeamsUserType, teamsServiceUrlRosterProbe, type TeamsRosterProbe } from "./teams-roster.js";
|
|
71
|
+
import { recordTeamsConversationReference, teamsChannelGrainConversationId, teamsReplyChainRoot } from "./teams-conversation-ref.js";
|
|
72
|
+
import { parseTeamsReaction, routeTeamsStopReaction, type TeamsReactionActivity, type TeamsStopOutcome } from "./teams-stop.js";
|
|
73
|
+
import {
|
|
74
|
+
clearTeamsRoomStop,
|
|
75
|
+
markTeamsEngaged,
|
|
76
|
+
markTeamsRoomWatched,
|
|
77
|
+
resolveTeamsEngagement,
|
|
78
|
+
teamsMachineAuthored,
|
|
79
|
+
teamsRoomWatchStopped,
|
|
80
|
+
type TeamsEngagementVerdict,
|
|
81
|
+
} from "./teams-engagement.js";
|
|
82
|
+
import { teamsGraphTeamId } from "./teams-graph-team-id.js";
|
|
83
|
+
import {
|
|
84
|
+
planTeamsSingleWindow,
|
|
85
|
+
handleTeamsSingleWindowDispatch,
|
|
86
|
+
teamsSupervisorToken,
|
|
87
|
+
buildTeamsDmWake,
|
|
88
|
+
type TeamsSupervisorDeps,
|
|
89
|
+
type TeamsSingleWindowFacts,
|
|
90
|
+
} from "./teams-supervisor.js";
|
|
91
|
+
import {
|
|
92
|
+
shouldRotate,
|
|
93
|
+
EventDedupe,
|
|
94
|
+
ThreadFifo,
|
|
95
|
+
divertToUnifiedWindow,
|
|
96
|
+
divertInboundToUnifiedWindow,
|
|
97
|
+
supervisorGrainFor,
|
|
98
|
+
mintRotationToken,
|
|
99
|
+
claimRuntimeRotation,
|
|
100
|
+
stampMintedRuntime,
|
|
101
|
+
grainUsesUnifiedWindow,
|
|
102
|
+
resolvePartition,
|
|
103
|
+
domainKeyOf,
|
|
104
|
+
engagementTierFor,
|
|
105
|
+
getConfig,
|
|
106
|
+
getOperationalStore,
|
|
107
|
+
isVouchedSurface,
|
|
108
|
+
trustGroupKeyFor,
|
|
109
|
+
resolveWakeTrustFromContext,
|
|
110
|
+
stampNonMemberChatAuth,
|
|
111
|
+
deploymentOrg,
|
|
112
|
+
appendAudit,
|
|
113
|
+
getOrCreateDmSeat,
|
|
114
|
+
touchDmSeat,
|
|
115
|
+
renderWake,
|
|
116
|
+
mintWakeId,
|
|
117
|
+
resolveExternalPolicy,
|
|
118
|
+
externalPolicyOverrideFor,
|
|
119
|
+
externalPolicyIsInert,
|
|
120
|
+
withholdsBody,
|
|
121
|
+
roomIsSilenced,
|
|
122
|
+
silentRoomNotice,
|
|
123
|
+
silentNoticeKey,
|
|
124
|
+
EXTERNAL_SENDER_NOTICE,
|
|
125
|
+
EXTERNAL_MENTION_ACK,
|
|
126
|
+
owesSuppressedMentionAck,
|
|
127
|
+
suppressionRendering,
|
|
128
|
+
type ExternalPolicy,
|
|
129
|
+
type IngestedChatMessage,
|
|
130
|
+
type AuthorTrustQuery,
|
|
131
|
+
type SupervisorGrain,
|
|
132
|
+
type UnifiedWindowWake,
|
|
133
|
+
type UnifiedWakeOrigin,
|
|
134
|
+
type Partition,
|
|
135
|
+
type BindableContext,
|
|
136
|
+
carryTailForward,
|
|
137
|
+
type WakeMessageInput,
|
|
138
|
+
recordWakeTail,
|
|
139
|
+
tailAuthorGrain,
|
|
140
|
+
dmWindowRegistryKey,
|
|
141
|
+
type AttachSessionForCancel,
|
|
142
|
+
RateLimitExhaustedError,
|
|
143
|
+
receiptEventTime,
|
|
144
|
+
spreadVendorTs,
|
|
145
|
+
seatCatchUp,
|
|
146
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
147
|
+
|
|
148
|
+
/** An HTTP route handler — `RouteHandler` isn't a public eve export, so we mirror its shape
|
|
149
|
+
* (the exact slack-front mirror). */
|
|
150
|
+
type RouteHandler = (req: Request, args: RouteHandlerArgs) => Promise<Response>;
|
|
151
|
+
|
|
152
|
+
/** The public URL the Bot Framework POSTs every activity to (the bot registration's endpoint). */
|
|
153
|
+
export const PUBLIC_TEAMS_ROUTE = "/eve/v1/teams";
|
|
154
|
+
/** The internal path the wrapped `teamsChannel` is mounted at (never hit by Teams directly). */
|
|
155
|
+
export const INTERNAL_TEAMS_ROUTE = "/eve/v1/teams/native";
|
|
156
|
+
|
|
157
|
+
function tlog(msg: string): void {
|
|
158
|
+
console.log(`[open-everywhere:teams-front] ${msg}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- the single-window plan for one verified activity (live resolution) ----------------------
|
|
162
|
+
|
|
163
|
+
/** Everything the front resolved for one activity that the window OWNS — null means "forward to
|
|
164
|
+
* the native channel" (the byte-identical default). */
|
|
165
|
+
export interface TeamsFrontPlan {
|
|
166
|
+
readonly window: UnifiedWindowWake;
|
|
167
|
+
/** The DOMAIN-GRAIN surface key (`teams/<tenant>/<channel>` after the binding's segment
|
|
168
|
+
* sanitisation) — the gate/registry/FIFO/audit key. */
|
|
169
|
+
readonly surfaceKey: string;
|
|
170
|
+
readonly partition: Partition;
|
|
171
|
+
readonly grain: SupervisorGrain;
|
|
172
|
+
readonly auth: OpenEverywhereTeamsAuth;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One auth bag's raw attributes, defensively — the bags this file handles arrive in two shapes
|
|
176
|
+
* (`OpenEverywhereTeamsAuth` and the erased attribute-bag the DM divert carries). */
|
|
177
|
+
function teamsAttrs(auth: unknown): Record<string, unknown> {
|
|
178
|
+
const a = (auth as { attributes?: unknown } | null)?.attributes;
|
|
179
|
+
return a && typeof a === "object" ? (a as Record<string, unknown>) : {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Did Teams POSITIVELY refute this author's membership (OPE-489)? Only a resolved `userRole: "guest"`
|
|
184
|
+
* counts. Absent is NOT a refutation: absence of evidence, and this leg fails OPEN.
|
|
185
|
+
*
|
|
186
|
+
* The auth bag carries the verdict once {@link teamsAuthWithUserType} has folded the roster probe's
|
|
187
|
+
* answer in (OPE-493). Both dispatch paths do that unconditionally; the pre-ack
|
|
188
|
+
* {@link liveTeamsExternalGate} does it only when the policy could act on the answer.
|
|
189
|
+
*
|
|
190
|
+
* The verdict routes through `teamsUserTypeFromMember`, the ONE reader of Teams' guest vocabulary,
|
|
191
|
+
* rather than re-spelling the string comparison here. A second reader is how "what counts as a
|
|
192
|
+
* guest" comes to mean two things — and the vocabulary is already subtle (Graph's
|
|
193
|
+
* `conversationMember.roles` gives an OUT-OF-TENANT external the value `owner`).
|
|
194
|
+
*/
|
|
195
|
+
function teamsAuthorRefuted(auth: unknown): boolean {
|
|
196
|
+
const a = teamsAttrs(auth);
|
|
197
|
+
return teamsUserTypeFromMember({ userType: a["user_type"] ?? a["userType"] }) === "guest";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The trust registry's GROUP-rung key for this activity's TEAM (OPE-490), so a `team:<id>`
|
|
201
|
+
* declaration reaches the room leg exactly as it reaches the binding. Absent on a chat with no
|
|
202
|
+
* team, and on a team id that is not a safe key segment (refused, never repaired). */
|
|
203
|
+
function teamsRoomGroupKey(auth: unknown): { readonly groupKey?: string } {
|
|
204
|
+
const a = teamsAttrs(auth);
|
|
205
|
+
const tenant = a["tenant_id"] ?? a["tenantId"];
|
|
206
|
+
const group = teamsTrustGroup(typeof (a["team_id"] ?? a["teamId"]) === "string" ? String(a["team_id"] ?? a["teamId"]) : undefined);
|
|
207
|
+
if (typeof tenant !== "string" || tenant.length === 0 || !group) return {};
|
|
208
|
+
return { groupKey: `teams/${tenant}/${group.trustGroup}` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The auth capture for one inbound activity — the SAME raw-fact stow teams.ts's onMessage builds,
|
|
213
|
+
* so the diverted wake binds identically to a native turn (identity seam unchanged).
|
|
214
|
+
*
|
|
215
|
+
* SYNCHRONOUS, so it carries no `userType`: the roster verdict is a network call. The two dispatch
|
|
216
|
+
* paths fold it in afterwards with {@link teamsAuthWithUserType}.
|
|
217
|
+
*/
|
|
218
|
+
function authForActivity(a: TeamsMessageActivity): OpenEverywhereTeamsAuth {
|
|
219
|
+
return buildTeamsAuth({
|
|
220
|
+
userId: a.from.id,
|
|
221
|
+
aadObjectId: a.from.aadObjectId,
|
|
222
|
+
userName: a.from.name,
|
|
223
|
+
tenantId: a.tenantId ?? a.conversation.tenantId,
|
|
224
|
+
conversationId: a.conversation.id,
|
|
225
|
+
channelId: a.teamsChannelId,
|
|
226
|
+
teamId: a.teamId,
|
|
227
|
+
serviceUrl: a.serviceUrl,
|
|
228
|
+
scope: a.scope,
|
|
229
|
+
membershipType: teamsMembershipType(a.channelData),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* FOLD THE ROSTER VERDICT INTO ONE ACTIVITY'S AUTH CAPTURE (OPE-493) — the ONE step both front
|
|
235
|
+
* dispatch paths take before they stamp, and the reason the front's author leg is no longer inert.
|
|
236
|
+
*
|
|
237
|
+
* Until this existed, `resolveTeamsUserType` ran on NO live path. The front owns channel activities
|
|
238
|
+
* (the shared window) and personal/group chats (the OPE-357 DM divert), and its capture was built
|
|
239
|
+
* without a `userType` — so a measured B2B guest woke the window rendered as a `peer`
|
|
240
|
+
* (session wrun_41KZXE4FGK0GPSJB4JNM4KCMHY, 2026-08-15). The verdict now rides the SAME attribute
|
|
241
|
+
* the native path uses, so `bindTeams` → `teamsTrust` demotes the guest to `external` and the
|
|
242
|
+
* OPE-489 author leg answers, with no change of their own.
|
|
243
|
+
*
|
|
244
|
+
* THREE CALLERS, TWO LATENCY BUDGETS. The two DISPATCH paths run inside the front's
|
|
245
|
+
* `waitUntil(fifo.enqueue(…))`, so their roster GET lands AFTER the 200 ack and costs the Bot
|
|
246
|
+
* Framework nothing — they call this unconditionally. {@link liveTeamsExternalGate} runs BEFORE the
|
|
247
|
+
* ack, so it calls this only when a grain could act on the answer; its own note carries the reasoning.
|
|
248
|
+
*
|
|
249
|
+
* COST: one cached GET per (CHANNEL, user) per `identity.dmTrustTtlMs`, never one per message.
|
|
250
|
+
* `conversation.id` on a CHANNEL activity is the REPLY-CHAIN id (it carries `;messageid=<root>`), and
|
|
251
|
+
* `resolveTeamsUserType` strips that suffix for the CACHE KEY, so every reply chain in one channel
|
|
252
|
+
* shares one entry (OPE-572). The id still reaches the CONNECTOR unstripped — exactly as the native
|
|
253
|
+
* `teamsOnMessage` passes it, which is the shape the 2026-08-15 live probe measured. Whether the
|
|
254
|
+
* connector ALSO accepts a thread-qualified id is NOT established; one live GET settles it, and a
|
|
255
|
+
* refusal fails open like any other non-2xx.
|
|
256
|
+
*
|
|
257
|
+
* FAIL-OPEN, TOTALLY: a missing serviceUrl, an HTTP error, a transport throw, an unrecognised body —
|
|
258
|
+
* every one of them returns the capture UNCHANGED, by reference (see `withTeamsUserType`). The
|
|
259
|
+
* try/catch covers the probe FACTORY as well as the probe, so no roster failure can take a wake off
|
|
260
|
+
* the air.
|
|
261
|
+
*
|
|
262
|
+
* `probeFor` is the test seam: a FACTORY, not a probe, so a test can script "this activity has no
|
|
263
|
+
* probe at all" (`() => undefined`) distinctly from "the probe answers nothing". Live callers omit
|
|
264
|
+
* it and get the activity's own serviceUrl probe.
|
|
265
|
+
*/
|
|
266
|
+
export type TeamsRosterProbeFor = (activity: TeamsMessageActivity) => TeamsRosterProbe | undefined;
|
|
267
|
+
|
|
268
|
+
async function teamsAuthWithUserType<A extends OpenEverywhereTeamsAuth>(
|
|
269
|
+
auth: A,
|
|
270
|
+
a: TeamsMessageActivity,
|
|
271
|
+
probeFor?: TeamsRosterProbeFor,
|
|
272
|
+
): Promise<A> {
|
|
273
|
+
const factory: TeamsRosterProbeFor = probeFor ?? ((activity) => teamsServiceUrlRosterProbe(activity.serviceUrl));
|
|
274
|
+
try {
|
|
275
|
+
return withTeamsUserType(auth, await resolveTeamsUserType(factory(a), a.conversation.id, a.from.id));
|
|
276
|
+
} catch {
|
|
277
|
+
return auth; // no verdict → byte-identical capture
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ---------------
|
|
282
|
+
//
|
|
283
|
+
// Teams is the strongest adopter after Discord and Notion, and it is the one surface that can answer
|
|
284
|
+
// BOTH grains from vendor evidence: the Bot Connector roster reports `userRole: "guest"` for the
|
|
285
|
+
// author leg (teams-roster.ts), and a Teams
|
|
286
|
+
// SHARED CHANNEL is a positive vendor-external verdict for the room leg. The decision plane is the
|
|
287
|
+
// shared, surface-agnostic access/external-policy.ts — this file contributes only the Teams facts and
|
|
288
|
+
// the Teams consequences.
|
|
289
|
+
//
|
|
290
|
+
// 🔴 THE GATE SITS IN THE FRONT, ABOVE BOTH BRANCHES, WHICH IS WHY IT COVERS THE NATIVE PATH TOO.
|
|
291
|
+
// A suppressed activity is never forwarded to the wrapped `teamsChannel`, so a deployment with the
|
|
292
|
+
// window gates OFF is protected by the same verdict as one with them on. Putting the gate inside
|
|
293
|
+
// `liveTeamsDispatch` would have left every non-single-window Teams deployment unwired.
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The resolved external-participant policy for one room: the ORG DEFAULT, with the DOMAIN OVERRIDE
|
|
297
|
+
* applied when the domain registry can be read. An unreadable registry degrades to the org default —
|
|
298
|
+
* today's behaviour — rather than throwing, exactly as discord-gateway.ts's `externalPolicyForRoom`
|
|
299
|
+
* does and for the same reason: the policy plane must never be the thing that takes a surface off
|
|
300
|
+
* the air.
|
|
301
|
+
*/
|
|
302
|
+
function teamsExternalPolicyForRoom(roomKey: string): ExternalPolicy {
|
|
303
|
+
try {
|
|
304
|
+
return resolveExternalPolicy(externalPolicyOverrideFor(roomKey, getConfig().loadDomains()));
|
|
305
|
+
} catch {
|
|
306
|
+
return resolveExternalPolicy();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The AUTHOR-TRUST question for one activity (OPE-516) — the OPERATOR's own declaration of who is
|
|
312
|
+
* internal in this tenant.
|
|
313
|
+
*
|
|
314
|
+
* 🔴 TEAMS NEEDS IT EVEN THOUGH TEAMS CAN REFUTE. The surface refutation (`teamsAuthorRefuted`) reads
|
|
315
|
+
* a resolved roster `userRole`, which every front path now resolves (OPE-493). It still does not
|
|
316
|
+
* answer everywhere: a tenant whose bot credential cannot read the roster gets no verdict, and the
|
|
317
|
+
* probe only ever REFUTES — it never declares anyone internal. The register is the leg that covers
|
|
318
|
+
* those. It is additive: an unarmed tenant declares nobody and the leg contributes nothing, so a
|
|
319
|
+
* deployment that has not armed a register sees no behaviour change at all.
|
|
320
|
+
*
|
|
321
|
+
* Returns `{}` when the tenant is unreadable — no tenant key, no arming key, no question to ask.
|
|
322
|
+
*/
|
|
323
|
+
export function teamsAuthorFacts(auth: unknown, authorId: string | undefined): { readonly authorTrust?: AuthorTrustQuery } {
|
|
324
|
+
const a = teamsAttrs(auth);
|
|
325
|
+
const tenant = a["tenant_id"] ?? a["tenantId"];
|
|
326
|
+
if (typeof tenant !== "string" || tenant.length === 0) return {};
|
|
327
|
+
const group = teamsTrustGroup(typeof (a["team_id"] ?? a["teamId"]) === "string" ? String(a["team_id"] ?? a["teamId"]) : undefined);
|
|
328
|
+
return {
|
|
329
|
+
authorTrust: {
|
|
330
|
+
tenantKey: `teams/${tenant}`,
|
|
331
|
+
...(authorId ? { authorId } : {}),
|
|
332
|
+
// The GROUP-rung key this author holds, in the register's own pattern grammar. Teams gives one
|
|
333
|
+
// rung (the team), not a role list — a member of the team holds `teams/<tenant>/team:<id>`.
|
|
334
|
+
...(group ? { memberships: [`teams/${tenant}/${group.trustGroup}`] } : {}),
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The SURFACE's own vendor-external verdict for one activity: this room spans another organisation.
|
|
341
|
+
*
|
|
342
|
+
* A Teams SHARED CHANNEL (`channelData.channel.membershipType === "shared"`) is exactly that — it can
|
|
343
|
+
* hold members from tenants that are not ours, which is why teams-identity.ts already demotes one to
|
|
344
|
+
* `external` for the memory tier. Absent or any other value means "not reported", which includes "the
|
|
345
|
+
* activity did not carry channelData": absence of evidence, and this leg fails OPEN.
|
|
346
|
+
*/
|
|
347
|
+
export function teamsVendorExternal(a: TeamsMessageActivity): boolean {
|
|
348
|
+
return teamsMembershipType(a.channelData) === "shared";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* THE EXTERNAL-PARTICIPANT VERDICT for one Teams activity — pure, so the four outcomes are tested
|
|
353
|
+
* without a Bot-Framework call, a store or a live tenant. The four values name WHAT THE CALLER OWES;
|
|
354
|
+
* see {@link DiscordExternalVerdict}'s twin in discord-gateway.ts for the full reasoning.
|
|
355
|
+
*
|
|
356
|
+
* 🔴 THE AUTHOR GRAIN IS TESTED FIRST, AND THE ORDER MATTERS — the same ruling Discord's verdict
|
|
357
|
+
* carries. An excluded author in a silenced room is owed a notice they can act on; the room
|
|
358
|
+
* announcement tells the whole room something about the room instead.
|
|
359
|
+
*/
|
|
360
|
+
export type TeamsExternalVerdict = "proceed" | "withheld-addressed" | "withheld-ambient" | "silent-room";
|
|
361
|
+
|
|
362
|
+
export function teamsExternalVerdict(input: {
|
|
363
|
+
readonly policy: ExternalPolicy;
|
|
364
|
+
readonly author: IngestedChatMessage;
|
|
365
|
+
/** The activity @mentions the bot, or arrived in a personal chat — an ADDRESSED message is owed
|
|
366
|
+
* an answer. */
|
|
367
|
+
readonly addressed: boolean;
|
|
368
|
+
/** A personal or group chat. A DM contributes NO room leg: on a DM the trust plane asks about the
|
|
369
|
+
* PARTNER, not about the room. */
|
|
370
|
+
readonly isDm: boolean;
|
|
371
|
+
/** The SURFACE's vendor-external verdict — {@link teamsVendorExternal}. */
|
|
372
|
+
readonly vendorExternal: boolean;
|
|
373
|
+
/** The DOMAIN-grain surface key of the room, for the operator-declared leg. */
|
|
374
|
+
readonly roomKey: string;
|
|
375
|
+
/** The GROUP-rung key of the room's team, when the activity names one. */
|
|
376
|
+
readonly groupKey?: string;
|
|
377
|
+
}): TeamsExternalVerdict {
|
|
378
|
+
if (withholdsBody(input.author, input.policy)) return input.addressed ? "withheld-addressed" : "withheld-ambient";
|
|
379
|
+
const room = input.isDm ? { dm: true } : { surfaceKey: input.roomKey, ...(input.groupKey === undefined ? {} : { groupKey: input.groupKey }) };
|
|
380
|
+
return roomIsSilenced(
|
|
381
|
+
{ room, ...(input.vendorExternal ? { vendorExternal: true } : {}), ingested: [input.author] },
|
|
382
|
+
input.policy,
|
|
383
|
+
)
|
|
384
|
+
? "silent-room"
|
|
385
|
+
: "proceed";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Best-effort Bot-Framework send into the activity's own conversation. Never throws: a notice is
|
|
390
|
+
* an affordance, and a failed one must not turn a suppressed message into a 500 the Bot Framework
|
|
391
|
+
* retries. Answers whether the send was accepted, which the OPE-885 ack fallback reads. The
|
|
392
|
+
* serviceUrl is re-validated by the transport arm's own host allowlist.
|
|
393
|
+
*
|
|
394
|
+
* 🔴 A TIGHTENED RETRY POLICY, NOT THE SHARED DEFAULTS (cold review finding, OPE-1237). This
|
|
395
|
+
* function is reached from `liveTeamsExternalGate`, which `dispatchMessage` (:954) AWAITS before
|
|
396
|
+
* returning `OK()` on the route Microsoft posts activities to — the same "latency-critical path"
|
|
397
|
+
* shape `slack-web-api.ts`'s `PROBE_OPERATIONS` exists for. The Bot Framework redelivers on a slow
|
|
398
|
+
* ack (this file's own dedupe comment states redelivery is FAIL-OPEN), so the shared 60 s budget
|
|
399
|
+
* would turn one Teams rate-limit episode into a redelivery storm instead of a lost notice. Two
|
|
400
|
+
* attempts at a 2 s cap, mirroring `auth.test`'s policy exactly.
|
|
401
|
+
*/
|
|
402
|
+
async function postTeamsNotice(a: TeamsMessageActivity, text: string): Promise<boolean> {
|
|
403
|
+
try {
|
|
404
|
+
await sendTeamsActivityBackedOff(
|
|
405
|
+
{ serviceUrl: a.serviceUrl, conversationId: a.conversation.id, body: { type: "message", text } },
|
|
406
|
+
{ retry: { maxAttempts: 2, capMs: 2_000 } },
|
|
407
|
+
);
|
|
408
|
+
return true;
|
|
409
|
+
} catch (err) {
|
|
410
|
+
// 🔴 LOG A RATE-LIMIT EXHAUSTION BEFORE SWALLOWING IT (OPE-1237), mirroring Slack's
|
|
411
|
+
// slack-events.ts intro fetch. This catch is fail-soft by design, and without this line an
|
|
412
|
+
// exhausted 429 retry would leave NO trace anywhere — the silent drop the ticket exists to
|
|
413
|
+
// prevent. Every field is token-free.
|
|
414
|
+
if (err instanceof RateLimitExhaustedError) {
|
|
415
|
+
console.warn(`[open-everywhere:teams] rate limited: conversations.activities.send gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
|
|
416
|
+
}
|
|
417
|
+
return false; // silence — see the caller's own no-fallback reasoning
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Tell an EXCLUDED sender that the agent will not act on their message (behaviour 1). Answers
|
|
423
|
+
* whether the notice reached them.
|
|
424
|
+
*
|
|
425
|
+
* 🔴 THIS NOTICE NEVER GOES TO A TEAM CHANNEL, AND THAT RULING STANDS — the same one Discord's
|
|
426
|
+
* `postExternalSenderNotice` carries. Its text names the organisation, so posting it into the
|
|
427
|
+
* channel would announce that this person is classified as an outsider: a disclosure about a member
|
|
428
|
+
* of the room, not a notice to the sender. Teams cannot open a 1:1 with a person the bot has never
|
|
429
|
+
* met (the Bot Framework needs a conversation reference captured on a prior inbound turn), so there
|
|
430
|
+
* is no private channel to fall back to. A PERSONAL chat is itself private, so the notice posts
|
|
431
|
+
* there.
|
|
432
|
+
*
|
|
433
|
+
* 🔴 SO A CHANNEL @MENTION ALWAYS ANSWERS `false`, AND THAT IS THE COMMON CASE (OPE-885). The caller
|
|
434
|
+
* then posts the fixed `EXTERNAL_MENTION_ACK` into the channel instead — a different string, which
|
|
435
|
+
* names no person, no organisation and no verdict, and is therefore not the disclosure this notice
|
|
436
|
+
* would have been.
|
|
437
|
+
*/
|
|
438
|
+
async function postTeamsExternalSenderNotice(a: TeamsMessageActivity): Promise<boolean> {
|
|
439
|
+
if (a.scope !== "personal") return false;
|
|
440
|
+
return await postTeamsNotice(a, EXTERNAL_SENDER_NOTICE);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Post the suppressed-wake half of the silent-room announcement pair (OPE-504) — AT MOST ONCE PER
|
|
445
|
+
* ROOM, EVER.
|
|
446
|
+
*
|
|
447
|
+
* The claim is DURABLE, not per-process: `claimNotice` writes a permanent row, where an in-memory
|
|
448
|
+
* marker would re-announce on every cold start — and a cold start happens per serverless invocation,
|
|
449
|
+
* which would be the periodic announcement the design rejects. FAIL-CLOSED on the claim: if the store
|
|
450
|
+
* cannot prove the notice is unsaid, the agent stays quiet.
|
|
451
|
+
*/
|
|
452
|
+
async function announceTeamsSilentRoom(a: TeamsMessageActivity, org: string, surfaceKey: string): Promise<boolean> {
|
|
453
|
+
if (!org) return false;
|
|
454
|
+
try {
|
|
455
|
+
const won = await getOperationalStore().claimNotice(org, silentNoticeKey(surfaceKey, "suppressed-wake"), new Date().toISOString());
|
|
456
|
+
if (!won) return false; // already said, once, forever
|
|
457
|
+
} catch {
|
|
458
|
+
return false; // cannot prove it is unsaid ⇒ stay quiet
|
|
459
|
+
}
|
|
460
|
+
return await postTeamsNotice(a, silentRoomNotice("suppressed-wake"));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The LIVE gate: resolve the verdict for one verified message activity and pay what it owes.
|
|
465
|
+
* `true` means SUPPRESSED — the caller acks and forwards nothing.
|
|
466
|
+
*
|
|
467
|
+
* FAIL-OPEN on an unresolvable identity. If the auth capture or the partition cannot be built there
|
|
468
|
+
* is no room key, no tenant and no classification to act on, so the activity takes today's path and
|
|
469
|
+
* the existing branches apply their own fail-closed rules. Every carriage leg in this plane fails
|
|
470
|
+
* open, and this one has to as well: a config error must not silently take Teams off the air.
|
|
471
|
+
*
|
|
472
|
+
* 🔴 THE ROSTER PROBE IS PAID FOR ONLY WHEN THE POLICY CAN USE IT. This gate runs BEFORE the 200 ack,
|
|
473
|
+
* so a Bot Connector GET here is latency on EVERY activity — including the ones the front only
|
|
474
|
+
* forwards — and a slow ack makes the Bot Framework redeliver. The shipped grains (`taint` / `engage`)
|
|
475
|
+
* can suppress nothing, so `externalPolicyIsInert` skips the probe entirely and an un-opted-in
|
|
476
|
+
* deployment pays exactly what it paid before. An opted-in one pays one cached GET per
|
|
477
|
+
* (conversation, user) per TTL. This is the same trade `slack-front.ts` makes on its native
|
|
478
|
+
* `app_mention` forward, through the same predicate.
|
|
479
|
+
*/
|
|
480
|
+
export async function liveTeamsExternalGate(a: TeamsMessageActivity, probeFor?: TeamsRosterProbeFor): Promise<boolean> {
|
|
481
|
+
const isDm = a.scope === "personal" || a.scope === "groupChat";
|
|
482
|
+
let auth: OpenEverywhereTeamsAuth;
|
|
483
|
+
let partition: Partition;
|
|
484
|
+
let roomKey: string;
|
|
485
|
+
try {
|
|
486
|
+
auth = authForActivity(a);
|
|
487
|
+
partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
|
|
488
|
+
// A DM has no parent container to widen to, so it is keyed on its own surface key — the same
|
|
489
|
+
// split liveTeamsDmDivert makes.
|
|
490
|
+
roomKey = isDm ? partition.surfaceKey : domainKeyOf(partition);
|
|
491
|
+
} catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
const policy = teamsExternalPolicyForRoom(roomKey);
|
|
495
|
+
// OPE-493: resolve the SURFACE's own refutation, but only when a grain could act on it. Folded in
|
|
496
|
+
// BEFORE the trust read below, so an opted-in deployment sees the demoted tier as well.
|
|
497
|
+
if (!externalPolicyIsInert(policy)) auth = await teamsAuthWithUserType(auth, a, probeFor);
|
|
498
|
+
const trust = resolveWakeTrustFromContext(
|
|
499
|
+
{ channel: { kind: "teams" }, session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } } },
|
|
500
|
+
{ id: a.from.id, isBot: false },
|
|
501
|
+
{ principalFallback: true },
|
|
502
|
+
);
|
|
503
|
+
// EXPLICIT ADDRESSING — one fact, read twice: the verdict owes a private notice on it, and the
|
|
504
|
+
// OPE-885 ack keys on it. A personal chat is addressing by construction; `isBotMentioned` is
|
|
505
|
+
// Teams' own mention signal, which a NAME-DROP never sets.
|
|
506
|
+
const addressed = a.isBotMentioned === true || a.scope === "personal";
|
|
507
|
+
const verdict = teamsExternalVerdict({
|
|
508
|
+
policy,
|
|
509
|
+
// The SAME per-message facts the taint author leg reads: the resolved human tier, the SURFACE's
|
|
510
|
+
// roster refutation, and the OPERATOR's register question.
|
|
511
|
+
author: { trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) },
|
|
512
|
+
addressed,
|
|
513
|
+
isDm,
|
|
514
|
+
vendorExternal: teamsVendorExternal(a),
|
|
515
|
+
roomKey,
|
|
516
|
+
...teamsRoomGroupKey(auth),
|
|
517
|
+
});
|
|
518
|
+
if (verdict === "proceed") return false;
|
|
519
|
+
// The private notice and the once-ever room announcement both ANSWER the person who addressed us,
|
|
520
|
+
// and both say more than the ack does — so either suppresses it.
|
|
521
|
+
let alreadyAnswered = false;
|
|
522
|
+
if (verdict === "withheld-addressed") alreadyAnswered = await postTeamsExternalSenderNotice(a).catch(() => false);
|
|
523
|
+
if (verdict === "silent-room") alreadyAnswered = await announceTeamsSilentRoom(a, partition.org, roomKey).catch(() => false);
|
|
524
|
+
// THE MENTION ACK (OPE-885): an explicit @mention or personal-chat message that got no private
|
|
525
|
+
// answer gets the one fixed host line, in the conversation it was said in. An AMBIENT message
|
|
526
|
+
// stays fully dark — `addressed` is false for it, and this is the only gate.
|
|
527
|
+
const acked =
|
|
528
|
+
owesSuppressedMentionAck({ addressed, alreadyAnswered }) && (await postTeamsNotice(a, EXTERNAL_MENTION_ACK).catch(() => false));
|
|
529
|
+
await appendAudit({
|
|
530
|
+
org: partition.org || deploymentOrg(),
|
|
531
|
+
platform: "teams",
|
|
532
|
+
workspace: partition.workspace,
|
|
533
|
+
surface: roomKey,
|
|
534
|
+
principalId: a.from.id,
|
|
535
|
+
action: "teams.external_policy_suppressed",
|
|
536
|
+
detail: { verdict, conversationId: a.conversation.id, ...suppressionRendering(acked) },
|
|
537
|
+
}).catch(() => undefined);
|
|
538
|
+
tlog(`suppressed ${roomKey}: externalPolicy verdict "${verdict}"`);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Resolve the LIVE single-window plan for one verified message activity, fail-closed: any
|
|
544
|
+
* config/partition error, the gates off, the privacy floor failing, or a missing reply target
|
|
545
|
+
* all yield null — the front then FORWARDS to the native channel, byte-identical to today.
|
|
546
|
+
*
|
|
547
|
+
* The GRAIN + VOUCH are keyed on the DOMAIN-GRAIN key (`teams/<tenant>/<channel>` — a Teams
|
|
548
|
+
* conversation id names one REPLY CHAIN, so the listable/vouchable container is the channel;
|
|
549
|
+
* memory/partition.ts's domainKeyOf resolves it via the binding's domainSurface, mirroring the
|
|
550
|
+
* Discord thread→parent-channel precedent). Operators list/vouch `teams/<tenant>` or
|
|
551
|
+
* `teams/<tenant>/<channel-id>` (sanitised segments, same as every stored key).
|
|
552
|
+
*/
|
|
553
|
+
export function liveTeamsFrontPlan(a: TeamsMessageActivity): TeamsFrontPlan | null {
|
|
554
|
+
try {
|
|
555
|
+
const auth = authForActivity(a);
|
|
556
|
+
const partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
|
|
557
|
+
const surfaceKey = domainKeyOf(partition);
|
|
558
|
+
const grain = supervisorGrainFor(surfaceKey, getConfig().loadDomains());
|
|
559
|
+
if (!grainUsesUnifiedWindow(grain)) return null; // cross-surface OR solo; common path off-window (byte-identical flag-off)
|
|
560
|
+
const tenantId = a.tenantId ?? a.conversation.tenantId;
|
|
561
|
+
// The TEAM rung, derived exactly as identity/binding.ts derives it (see the `vouched` note
|
|
562
|
+
// below). Undefined on a personal or group chat, and on a malformed team id — `teamsTrustGroup`
|
|
563
|
+
// validates the charset and `trustGroupKeyFor` validates the `<marker>:<id>` shape, both
|
|
564
|
+
// fail-closed to no rung.
|
|
565
|
+
const groupKey = trustGroupKeyFor(partition.workspaceKey, teamsTrustGroup(a.teamId)?.trustGroup);
|
|
566
|
+
const facts: TeamsSingleWindowFacts = {
|
|
567
|
+
scope: a.scope,
|
|
568
|
+
membershipType: teamsMembershipType(a.channelData),
|
|
569
|
+
tenantId,
|
|
570
|
+
// The operator's vouch. `isVouchedSurface` WALKS THE DECLARATION RUNGS (OPE-1521), so a
|
|
571
|
+
// tenant-grain `teams/<tenant>` declaration vouches every room under it, not just the channel
|
|
572
|
+
// key — and `agent/identity/surfaces.ts` ships such a tenant line. A tenant declaration can't
|
|
573
|
+
// tell a native internal channel from a Teams CONNECT / shared channel spanning other
|
|
574
|
+
// tenants, so a shared room whose inbound activity OMITS membershipType passes this floor on
|
|
575
|
+
// that line. The grain check above already limited us to rooms the domain config enrolls — the
|
|
576
|
+
// vouch admits a member, it never enrolls one — but that is no second bound: a container entry
|
|
577
|
+
// claim is legal at tenant grain, and one beside a tenant declaration enrolls the whole
|
|
578
|
+
// tenant. That is the ruling: a declaration vouches, only a POSITIVE external signal
|
|
579
|
+
// demotes. Declare the channel rung to narrow the reach. OE_TRUST=internal is still the
|
|
580
|
+
// deploy-wide blanket switch, and a shared channel is separately refused by the
|
|
581
|
+
// membershipType guard when the signal IS present.
|
|
582
|
+
//
|
|
583
|
+
// 🔴 EVERY RUNG THE BINDING READS IS PASSED HERE TOO (OPE-1538), and that is the whole point:
|
|
584
|
+
// this floor used to be handed the CHANNEL key alone, so two tiers were invisible to it and
|
|
585
|
+
// both misses were fail-open. A `public` `teams/<tenant>/team:<id>` team carve-out and a
|
|
586
|
+
// `public` declaration on ONE reply chain each demoted on the binding and memory planes
|
|
587
|
+
// (identity/binding.ts passes the conversation key, the channel key and the team rung) while
|
|
588
|
+
// this floor saw only the tenant entry and admitted the room — at the one gate where admitting
|
|
589
|
+
// means joining a private cross-surface window. The three keys below are the same three, in
|
|
590
|
+
// the same preference order, derived through the same helpers: `partition.surfaceKey` is the
|
|
591
|
+
// reply chain, `surfaceKey` is `domainKeyOf`'s channel, and `trustGroupKeyFor` +
|
|
592
|
+
// `teamsTrustGroup` build the team rung exactly as the mapper does. One spelling per rung,
|
|
593
|
+
// across both planes. The team rung is absent on a personal or group chat, which carry no team
|
|
594
|
+
// id and are refused by the scope check anyway.
|
|
595
|
+
vouched:
|
|
596
|
+
isVouchedSurface(partition.surfaceKey, { containerKey: surfaceKey, ...(groupKey === undefined ? {} : { groupKey }) }) ||
|
|
597
|
+
process.env.OE_TRUST === "internal",
|
|
598
|
+
};
|
|
599
|
+
const window = planTeamsSingleWindow(true, a.isBotMentioned, facts, {
|
|
600
|
+
serviceUrl: a.serviceUrl,
|
|
601
|
+
conversationId: a.conversation.id,
|
|
602
|
+
replyToActivityId: teamsThreadRootActivityId(a) ?? undefined,
|
|
603
|
+
tenantId,
|
|
604
|
+
// THE FRONT-DOOR RECEIPT (OPE-1716) — a Bot-Framework activity carries no parsed timestamp on
|
|
605
|
+
// eve's shape (the same fact the trigger's `sentAt` records below), so the ingress row records
|
|
606
|
+
// our own instant and marks it `receipt`, which the split reporter renders as a LOWER BOUND.
|
|
607
|
+
// 🔴 Taken HERE, at the plan, not at the route's first byte — so on Teams the vendor→ingress
|
|
608
|
+
// segment is a floor near zero and says nothing about the vendor's own hop.
|
|
609
|
+
...spreadVendorTs(receiptEventTime(Date.now())),
|
|
610
|
+
});
|
|
611
|
+
if (!window) return null;
|
|
612
|
+
return { window, surfaceKey, partition, grain, auth };
|
|
613
|
+
} catch {
|
|
614
|
+
return null; // fail-closed → the native path keeps ownership
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// --- the live dispatch (session deps + the diversion) -----------------------------------------
|
|
619
|
+
|
|
620
|
+
/** Live supervisor deps over the (org, grain.registryKey) channel_session registry — the exact
|
|
621
|
+
* notion.ts/linear.ts shape. The wake ALWAYS diverts (teams-supervisor requires the window). */
|
|
622
|
+
/**
|
|
623
|
+
* EXPORTED FOR THE INGRESS CONTRACT TEST (OPE-1719), not for another caller. `liveTeamsDispatch` is
|
|
624
|
+
* the only production caller and it is thirty lines below. `scripts/owned-thread-ingress-wiring-test.ts`
|
|
625
|
+
* drives the `wake` member of THIS object — the real Teams ingress divert — because a test that
|
|
626
|
+
* reimplemented the wake dep would stay green after somebody moved this leg back to the raw divert,
|
|
627
|
+
* which is the exact regression the ticket closes.
|
|
628
|
+
*/
|
|
629
|
+
export function liveTeamsSupervisorDeps(plan: TeamsFrontPlan, to: RouteHandlerArgs["to"]): TeamsSupervisorDeps {
|
|
630
|
+
const org = plan.partition.org;
|
|
631
|
+
const store = getOperationalStore();
|
|
632
|
+
const grain = plan.grain;
|
|
633
|
+
return {
|
|
634
|
+
async getOrCreateSession(_surfaceKey, channelId, atIso) {
|
|
635
|
+
// OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the
|
|
636
|
+
// mint arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
|
|
637
|
+
const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
|
|
638
|
+
const existing = await store.getChannelSession(org, grain.registryKey);
|
|
639
|
+
if (!existing) {
|
|
640
|
+
const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => teamsSupervisorToken(channelId, 1)), atIso);
|
|
641
|
+
// OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
|
|
642
|
+
// wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
|
|
643
|
+
await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
|
|
644
|
+
return { token: row.token, generation: row.generation, freshGeneration: true, tail: [] as readonly WakeMessageInput[] };
|
|
645
|
+
}
|
|
646
|
+
if (shouldRotate(existing, atIso, { runtimeChanged })) {
|
|
647
|
+
const gen = existing.generation + 1;
|
|
648
|
+
// OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor. On
|
|
649
|
+
// Teams this is the ONLY continuity there is — the surface exposes no history read.
|
|
650
|
+
const tail = await carryTailForward(org, grain.registryKey, existing.token);
|
|
651
|
+
const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => teamsSupervisorToken(channelId, gen)), atIso);
|
|
652
|
+
return { token: row.token, generation: row.generation, freshGeneration: true, tail };
|
|
653
|
+
}
|
|
654
|
+
// A resume whose first wake never delivered (first_wake_at NULL) is still "fresh" (H7c). Its
|
|
655
|
+
// tail is empty — carried and cleared at the rotation that minted this row.
|
|
656
|
+
// `lastWakeAt` rides out on THIS arm alone: it is the catch-up read's lower bound, and a fresh
|
|
657
|
+
// or just-rotated generation has none (rotation resets the column).
|
|
658
|
+
return {
|
|
659
|
+
token: existing.token,
|
|
660
|
+
generation: existing.generation,
|
|
661
|
+
freshGeneration: existing.firstWakeAt == null,
|
|
662
|
+
tail: [] as readonly WakeMessageInput[],
|
|
663
|
+
lastWakeAt: existing.lastWakeAt ?? null,
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
touchSession: (_surfaceKey, atIso) => store.touchChannelSession(org, grain.registryKey, atIso),
|
|
667
|
+
// THE SINCE-LAST-WAKE CATCH-UP (OPE-1726), through the shared path core holds. Teams' own fetch
|
|
668
|
+
// is registered at bundle registration and reads over Microsoft Graph, not the Bot Connector this
|
|
669
|
+
// wake arrived on. The admission policy is the DOMAIN's, resolved by the shared road itself from
|
|
670
|
+
// the request's own channel key (OPE-1857). No `trustFor`: this surface has no synchronous
|
|
671
|
+
// roster probe for a channel author at wake time, so the shared core's FAIL-CLOSED per-sender
|
|
672
|
+
// default applies.
|
|
673
|
+
catchUp: (req) => seatCatchUp("teams", req),
|
|
674
|
+
// OPE-794 — file this wake on the live generation's tail ring. Same key derivation as the
|
|
675
|
+
// rotation read and the spend counter, so all three target one row.
|
|
676
|
+
recordWake: (entry, token) => recordWakeTail(org, grain.registryKey, token, entry),
|
|
677
|
+
now: () => new Date().toISOString(),
|
|
678
|
+
async wake({ token, auth, message, context, window }) {
|
|
679
|
+
// SINGLE-WINDOW diversion (channels/unified-window.ts): the wake goes through eve's
|
|
680
|
+
// cross-channel `to(...).send(...)` into the synthetic `unified` channel, so its effective token is
|
|
681
|
+
// `unified:<raw>`, shared with the domain's Slack/GitHub/Linear/Notion wakes — ONE context
|
|
682
|
+
// window. The unified channel's receive registers the wakeId→origin map entry (durably,
|
|
683
|
+
// BEFORE the send) and re-checks the gates fail-closed. A failure propagates to the FIFO's
|
|
684
|
+
// loud catch (fail-closed: never delivered without a routable reply target).
|
|
685
|
+
//
|
|
686
|
+
// THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's front
|
|
687
|
+
// route has always used. A wake landing in a reply chain a live task worker or thread seat
|
|
688
|
+
// OWNS is dispatched to that owner's token instead of the standing seat's. On Teams the reply
|
|
689
|
+
// chain IS the thread (`replyToActivityId`), so a plain conversation send stays top-level and
|
|
690
|
+
// never consults ownership. Flag-off, and on every unowned chain, this is a byte-identical
|
|
691
|
+
// passthrough to `divertToUnifiedWindow`.
|
|
692
|
+
await divertInboundToUnifiedWindow(to, {
|
|
693
|
+
message,
|
|
694
|
+
...(context.length > 0 ? { context } : {}),
|
|
695
|
+
token,
|
|
696
|
+
window,
|
|
697
|
+
surfaceKey: plan.surfaceKey,
|
|
698
|
+
auth: auth as unknown as { attributes?: Record<string, unknown> },
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// --- the composed front handler ----------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
/** Injectable seams so the front is testable without live JWT verification / a Bot registration. */
|
|
707
|
+
export interface TeamsFrontOptions {
|
|
708
|
+
/** Verify one inbound request and return the raw body; throw to reject (401). Defaults to
|
|
709
|
+
* eve's `verifyTeamsRequest` with env credentials (MICROSOFT_APP_ID / TEAMS_APP_ID) — the
|
|
710
|
+
* same fallback the credential-less teamsChannel uses. */
|
|
711
|
+
readonly verify?: (req: Request) => Promise<string>;
|
|
712
|
+
readonly dedupe?: EventDedupe;
|
|
713
|
+
readonly fifo?: ThreadFifo;
|
|
714
|
+
readonly now?: () => number;
|
|
715
|
+
/** Durable first-sighting check (true = proceed). Defaults to the operational store. */
|
|
716
|
+
readonly markEventSeen?: (org: string, key: string, atIso: string) => Promise<boolean>;
|
|
717
|
+
/** The single-window plan for one verified activity. Defaults to {@link liveTeamsFrontPlan}
|
|
718
|
+
* (grain + fail-closed privacy floor); fail-closed null ⇒ forward, byte-identical. */
|
|
719
|
+
readonly planFor?: (a: TeamsMessageActivity) => TeamsFrontPlan | null;
|
|
720
|
+
/** The dispatch executor (defaults to the real supervisor dispatch over the live deps). */
|
|
721
|
+
readonly dispatch?: (a: TeamsMessageActivity, plan: TeamsFrontPlan, to: RouteHandlerArgs["to"]) => Promise<void>;
|
|
722
|
+
/** The DM window divert (OPE-357) — defaults to {@link liveTeamsDmDivert}. Injected so the front's
|
|
723
|
+
* DM-routing decision (predicate + fail-closed to native) is unit-testable without a live store. */
|
|
724
|
+
readonly dmDivert?: (a: TeamsMessageActivity, auth: OpenEverywhereTeamsAuth, partition: Partition, to: RouteHandlerArgs["to"]) => Promise<void>;
|
|
725
|
+
/** The external-participant gate (OPE-507/OPE-504) — `true` suppresses the activity. Defaults to
|
|
726
|
+
* {@link liveTeamsExternalGate}; injected so the front's ORDERING (above both branches, so the
|
|
727
|
+
* native forward is covered too) is unit-testable without a store or a Bot registration. */
|
|
728
|
+
readonly externalGate?: (a: TeamsMessageActivity) => Promise<boolean>;
|
|
729
|
+
/** The stop-reaction arm (OPE-898) — defaults to {@link routeTeamsStopReaction} over eve's cancel
|
|
730
|
+
* door. Injected so the front's ORDERING (above `parseTeamsActivity`, which never sees a reaction
|
|
731
|
+
* activity) is unit-testable without a live session. */
|
|
732
|
+
readonly stopReaction?: (rx: TeamsReactionActivity, attachSession: AttachSessionForCancel) => Promise<TeamsStopOutcome>;
|
|
733
|
+
/** The ambient engagement seam's READ (OPE-1500) — defaults to {@link resolveTeamsEngagement}.
|
|
734
|
+
* Injected so the front's ORDERING (above both the DM divert and Leg A, so one message is never
|
|
735
|
+
* handled by two legs) is unit-testable without an operational store. */
|
|
736
|
+
readonly engagementSeam?: (a: TeamsMessageActivity, machineAuthored: boolean) => Promise<TeamsEngagementVerdict>;
|
|
737
|
+
/** The ambient engagement seam's WRITE — defaults to {@link markTeamsEngaged}. Separate from the
|
|
738
|
+
* read so a test can assert that exactly one mention marks exactly one row. */
|
|
739
|
+
readonly markEngaged?: (a: TeamsMessageActivity) => Promise<string | null>;
|
|
740
|
+
/** Leg A's ROOM-grain stop READ (OPE-1511) — defaults to {@link teamsRoomWatchStopped}. Injected
|
|
741
|
+
* so the front's ORDERING (below the mention re-invitation, above the dispatch) is unit-testable
|
|
742
|
+
* without an operational store. */
|
|
743
|
+
readonly roomStopped?: (partition: Partition) => Promise<boolean>;
|
|
744
|
+
/** Leg A's ROOM-grain wake row WRITE — defaults to {@link markTeamsRoomWatched}. */
|
|
745
|
+
readonly markRoomWatched?: (partition: Partition, atIso: string) => Promise<void>;
|
|
746
|
+
/** The @mention's ROOM-grain re-invitation — defaults to {@link clearTeamsRoomStop}. Separate
|
|
747
|
+
* from the read so a test can prove a mention is the way back out of a room stop. */
|
|
748
|
+
readonly clearRoomStop?: (partition: Partition, atIso: string, engagedBy: string) => Promise<void>;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const OK = () => new Response("ok");
|
|
752
|
+
|
|
753
|
+
// Durable half of the retry dedupe (Bot Framework redelivers on non-200): mirrors slack-front's
|
|
754
|
+
// liveMarkEventSeen — FAIL-OPEN by design (dedupe prevents a double reply, not a security hole).
|
|
755
|
+
const DEDUPE_TTL_MS = 10 * 60_000;
|
|
756
|
+
let warnedDedupeStore = false;
|
|
757
|
+
async function liveMarkEventSeen(org: string, key: string, atIso: string): Promise<boolean> {
|
|
758
|
+
try {
|
|
759
|
+
return await getOperationalStore().markEventSeen(org, key, atIso, DEDUPE_TTL_MS);
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (!warnedDedupeStore) {
|
|
762
|
+
warnedDedupeStore = true;
|
|
763
|
+
console.warn(`[open-everywhere:teams-front] durable dedupe unavailable — in-memory only: ${String(err)}`);
|
|
764
|
+
}
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** A swallowed FIFO wake failure, made durable (mirrors slack-front's recordWakeFailure posture:
|
|
770
|
+
* the console line rolls in ~1h; the audit row is the queryable signal). Never throws. */
|
|
771
|
+
function recordTeamsWakeFailure(key: string, err: unknown): void {
|
|
772
|
+
void appendAudit({
|
|
773
|
+
org: deploymentOrg(),
|
|
774
|
+
platform: "teams",
|
|
775
|
+
workspace: key.split("/")[1] ?? "unknown",
|
|
776
|
+
surface: key,
|
|
777
|
+
action: "surface.wake_failed",
|
|
778
|
+
detail: { fifoKey: key, message: String(err instanceof Error ? err.message : err).slice(0, 500) },
|
|
779
|
+
}).catch(() => undefined);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** The live dispatch for one owned activity: per-message trust off the SAME auth capture the
|
|
783
|
+
* session binds with, then the teams-supervisor core over the live registry deps.
|
|
784
|
+
*
|
|
785
|
+
* `probeFor` is the roster test seam — see {@link teamsAuthWithUserType}. */
|
|
786
|
+
export async function liveTeamsDispatch(
|
|
787
|
+
a: TeamsMessageActivity,
|
|
788
|
+
plan: TeamsFrontPlan,
|
|
789
|
+
to: RouteHandlerArgs["to"],
|
|
790
|
+
probeFor?: TeamsRosterProbeFor,
|
|
791
|
+
): Promise<void> {
|
|
792
|
+
// OPE-493: the roster verdict, resolved ONCE and folded into the capture that everything below
|
|
793
|
+
// reads — the per-message trust, the author-refutation leg and the auth the session binds with.
|
|
794
|
+
// Fail-open: no verdict leaves `auth` byte-identical to `plan.auth`.
|
|
795
|
+
const auth = await teamsAuthWithUserType(plan.auth, a, probeFor);
|
|
796
|
+
const wakeCtx: BindableContext = { channel: { kind: "teams" }, session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } } };
|
|
797
|
+
// isBot:false is safe here: the front's author gate already dropped self/bot-authored
|
|
798
|
+
// activities, so a dispatched trigger is a positively-human author (the Slack analog drops
|
|
799
|
+
// authorIsBot in shouldSupervisorWake).
|
|
800
|
+
const trust = resolveWakeTrustFromContext(wakeCtx, { id: a.from.id, isBot: false }, { principalFallback: true });
|
|
801
|
+
let engagementTier: ReturnType<typeof engagementTierFor> | undefined;
|
|
802
|
+
try {
|
|
803
|
+
engagementTier = engagementTierFor(plan.surfaceKey, getConfig().loadDomains());
|
|
804
|
+
} catch {
|
|
805
|
+
engagementTier = undefined; // fail-soft to neutral, same as every other surface
|
|
806
|
+
}
|
|
807
|
+
// 🔴 THE GRAPH TEAM ID, NOT THE ACTIVITY'S OWN (OPE-1808). `a.teamId` is the BOT CONNECTOR's team
|
|
808
|
+
// id (`19:…@thread.tacv2`); every Graph URL the catch-up builds takes the team's Microsoft 365
|
|
809
|
+
// GROUP id instead. Resolved through the Connector and cached per team — see
|
|
810
|
+
// channels/teams-graph-team-id.ts. Unresolvable ⇒ absent, and the dispatch then STATES that it
|
|
811
|
+
// cannot read this surface's history rather than reading an empty window.
|
|
812
|
+
const graphTeamId = await teamsGraphTeamId(a.teamId, a.serviceUrl);
|
|
813
|
+
const replyChainRootId = teamsReplyChainRoot(a.conversation.id);
|
|
814
|
+
// 🔴 ONE EXPRESSION, READ TWICE (OPE-1747): the taint join below and the dispatch's `ingested`
|
|
815
|
+
// carriage, which is what the TAIL ROW records. Re-deriving it at the second reader would be a
|
|
816
|
+
// second opinion that can disagree with the first about one author.
|
|
817
|
+
const triggerFacts: IngestedChatMessage = { trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) };
|
|
818
|
+
await handleTeamsSingleWindowDispatch(
|
|
819
|
+
{
|
|
820
|
+
surfaceKey: plan.surfaceKey,
|
|
821
|
+
// OPE-1528 — the wake row's `container` is the CONVERSATION grain, the same key the post that
|
|
822
|
+
// answers it files under (`partition.surfaceKey` → session-tail-record.ts `tailWindowFor`).
|
|
823
|
+
// `plan.surfaceKey` is the DOMAIN grain and belongs to the gate, the registry row and the
|
|
824
|
+
// audit surface, never to the ring.
|
|
825
|
+
containerKey: plan.partition.surfaceKey,
|
|
826
|
+
// 🔴 THE CHANNEL GRAIN, NEVER THE REPLY CHAIN (OPE-1817). Graph addresses a history read as
|
|
827
|
+
// `/teams/{team}/channels/{channel}`, and a CHANNEL activity's `conversation.id` carries
|
|
828
|
+
// `;messageid=<root>` — a reply-chain id Graph has no channel by that name for, so the
|
|
829
|
+
// unstripped fallback was a 404 the catch-up then read as an empty room. `teamsChannelId`
|
|
830
|
+
// (`channelData.channel.id`) is already at channel grain; the fallback is now stripped to it.
|
|
831
|
+
channelId: a.teamsChannelId ?? teamsChannelGrainConversationId(a.conversation.id),
|
|
832
|
+
// The TEAM the channel sits in, in GRAPH's own spelling — present only on a CHANNEL activity
|
|
833
|
+
// whose Connector team id resolved, and a channel activity is the only shape that has a
|
|
834
|
+
// Graph-readable history at all. Absent ⇒ the catch-up is skipped, never guessed.
|
|
835
|
+
...(graphTeamId ? { teamId: graphTeamId } : {}),
|
|
836
|
+
// The activity NAMED a team and its Graph id would not resolve — a transient bound on this
|
|
837
|
+
// wake, which the dispatch states differently from a chat's structural one.
|
|
838
|
+
...(!graphTeamId && a.teamId ? { teamAddressUnresolved: true } : {}),
|
|
839
|
+
// OPE-466 CARRIAGE, over the TRIGGER ONLY (this leg carries no per-message-trust backscroll).
|
|
840
|
+
// OPE-489: the author leg reads the POSITIVE refutation — a resolved `userRole: "guest"`.
|
|
841
|
+
//
|
|
842
|
+
// OPE-493: THE FRONT NOW RUNS THE PROBE. `teamsAuthWithUserType` above resolved the Bot
|
|
843
|
+
// Connector roster verdict and folded it into `auth`, so the SURFACE refutation answers on this
|
|
844
|
+
// path exactly as it does on the native one. The roster probe IS live — a real B2B guest returns
|
|
845
|
+
// `userRole: "guest"` and a tenant member returns `"user"` (measured 2026-08-15). An
|
|
846
|
+
// unresolved verdict is still absent, and this leg fails OPEN on absence.
|
|
847
|
+
//
|
|
848
|
+
// OPE-516/OPE-525: the OPERATOR's register (`teamsAuthorFacts`) is the SECOND source of
|
|
849
|
+
// refutation, and answers where the roster cannot (a guest the connector will not report, a
|
|
850
|
+
// tenant whose bot credential cannot read the roster). Unarmed ⇒ it contributes nothing.
|
|
851
|
+
auth: stampNonMemberChatAuth(auth, [triggerFacts], {
|
|
852
|
+
surfaceKey: plan.surfaceKey,
|
|
853
|
+
...teamsRoomGroupKey(auth),
|
|
854
|
+
}),
|
|
855
|
+
// THE SAME ONE ENTRY the join above reads — carried so the dispatch's tail row records it.
|
|
856
|
+
ingested: [triggerFacts],
|
|
857
|
+
trigger: {
|
|
858
|
+
id: a.id,
|
|
859
|
+
authorId: a.from.id,
|
|
860
|
+
authorName: a.from.name,
|
|
861
|
+
// Bot-Framework activities carry no parsed timestamp on eve's shape — the arrival time
|
|
862
|
+
// is the honest stamp (the same "now" the envelope's current-time uses).
|
|
863
|
+
sentAt: new Date().toISOString(),
|
|
864
|
+
text: a.text || "(the message had no text)",
|
|
865
|
+
trust,
|
|
866
|
+
},
|
|
867
|
+
// Thread meta ⇔ the trigger sat inside an EXISTING reply chain (its replyToId); a
|
|
868
|
+
// top-level channel post starts its own chain and renders container-scoped, mirroring
|
|
869
|
+
// Slack's threadTs-presence semantics.
|
|
870
|
+
...(a.replyToId ? { threadRootId: teamsThreadRootActivityId(a) ?? a.replyToId } : {}),
|
|
871
|
+
// 🔴 THE READ'S OWN ROOT, AND IT IS NOT `threadRootId` (OPE-1817). The two answer different
|
|
872
|
+
// questions and only one of them is about Graph:
|
|
873
|
+
// • `threadRootId` is the ENVELOPE's thread meta. It is deliberately absent on a top-level
|
|
874
|
+
// post, because that post renders container-scoped — Slack's semantics, kept.
|
|
875
|
+
// • this one is the CONTAINER THE SEAT IS BOUND TO. `plan.partition.surfaceKey` is the
|
|
876
|
+
// CONVERSATION grain (`bindTeams`'s `surface`), and on a channel activity that is always
|
|
877
|
+
// one reply chain, named by the `;messageid=<root>` suffix — present on a top-level post
|
|
878
|
+
// too, where it is that post's own id.
|
|
879
|
+
// The catch-up must read THAT chain TOO. `/channels/{c}/messages` returns the channel's
|
|
880
|
+
// top-level messages, which by Graph's contract carry no replies at all, so a chain that did
|
|
881
|
+
// carry the message came back empty (ship #206/#207). `wakeAddress` in `teams-read.ts`
|
|
882
|
+
// already resolves the root this way for `read_thread`; this is the same resolution, brought
|
|
883
|
+
// to the read the seat's own wake depends on.
|
|
884
|
+
...(replyChainRootId ? { replyChainRootId } : {}),
|
|
885
|
+
engagementTier,
|
|
886
|
+
window: plan.window,
|
|
887
|
+
},
|
|
888
|
+
liveTeamsSupervisorDeps(plan, to),
|
|
889
|
+
);
|
|
890
|
+
await appendAudit({
|
|
891
|
+
org: plan.partition.org,
|
|
892
|
+
platform: "teams",
|
|
893
|
+
workspace: plan.partition.workspace,
|
|
894
|
+
surface: plan.surfaceKey,
|
|
895
|
+
principalId: a.from.id,
|
|
896
|
+
action: "teams.dispatched",
|
|
897
|
+
detail: { addressing: plan.window.addressing ?? "channel-watch", conversationId: a.conversation.id },
|
|
898
|
+
}).catch(() => undefined);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* THE DM WINDOW DIVERT (OPE-357) — a Teams personal/group chat lands in its OWN first-class standing
|
|
903
|
+
* window (channels/dm-seat.ts), the Teams parity of divertDiscordDmToWindow / slack-front's
|
|
904
|
+
* scheduleDmWake, and the last DM-family surface to first-class (Slack: OPE-351 stages 2+4; Discord:
|
|
905
|
+
* OPE-356).
|
|
906
|
+
*
|
|
907
|
+
* WHAT IT FIXES. Until now a Teams DM ran on eve's native per-conversation token (teams.ts's
|
|
908
|
+
* onMessage): a session with no window row, and therefore NO DISPATCHABLE ADDRESS. A DM that delegated
|
|
909
|
+
* could not be told the answer had arrived — `resolveCallerSeat` took the OPE-306 ANCHOR arm and the
|
|
910
|
+
* worker posted its own report into the DM (the OPE-358 double-post). A window token IS an address, so
|
|
911
|
+
* the DM seat now gets the whole standing-seat apparatus for free: woken, redriven, rotated (on the DM
|
|
912
|
+
* thresholds — `OE_DM_SEAT_ROTATE_*`, never the supervisor's), spend-counted and audited through the
|
|
913
|
+
* seams every other seat already uses. The worker's direct report-back stops the moment the seat
|
|
914
|
+
* becomes a real window (its caller now resolves via `ownWindowSeat`, not the anchor) — no two voices
|
|
915
|
+
* in one DM per delegated turn.
|
|
916
|
+
*
|
|
917
|
+
* SEPARATE ADMIT SPACE, NOT A FLOOR BYPASS. This never touches `teamsSingleWindowPrivacyOk`: that floor
|
|
918
|
+
* refuses `scope !== "channel"` outright because a personal/group chat may never join the SHARED
|
|
919
|
+
* cross-surface window, and it stays exactly that strict. A DM window is a STRUCTURALLY DIFFERENT,
|
|
920
|
+
* non-shared address space keyed on the DM's own surface key (`getOrCreateDmSeat`), deliverable to its
|
|
921
|
+
* own DM and nowhere else — which is precisely why it is safe to admit a DM here without weakening the
|
|
922
|
+
* channel floor (the Discord `isDm`-before-the-single-window-block precedent).
|
|
923
|
+
*
|
|
924
|
+
* 🔴 IT BYPASSES THE ENGAGEMENT GATE — a DM is always answered (see {@link buildTeamsDmWake}). The
|
|
925
|
+
* SPEND gate is NOT bypassed: `divertToUnifiedWindow` runs its own window-door gate (channels/unified.ts
|
|
926
|
+
* windowSpendGate), which is exactly the choke point every Teams single-window divert already leans on
|
|
927
|
+
* (Teams has never front-positioned a pre-gate, unlike Discord).
|
|
928
|
+
*
|
|
929
|
+
* FAIL-CLOSED: a seat-resolution or dispatch failure PROPAGATES to the FIFO's loud catch. There is
|
|
930
|
+
* deliberately NO fall-back to the native send — that would answer the human from a second session with
|
|
931
|
+
* no window row behind it, the very topology this replaces (and the OPE-351 stranding hazard).
|
|
932
|
+
*/
|
|
933
|
+
export async function liveTeamsDmDivert(
|
|
934
|
+
a: TeamsMessageActivity,
|
|
935
|
+
capturedAuth: OpenEverywhereTeamsAuth,
|
|
936
|
+
partition: Partition,
|
|
937
|
+
to: RouteHandlerArgs["to"],
|
|
938
|
+
probeFor?: TeamsRosterProbeFor,
|
|
939
|
+
): Promise<void> {
|
|
940
|
+
// OPE-493: the roster verdict for the DM PARTNER, folded into the capture before anything reads it.
|
|
941
|
+
// A Teams DM used to bind `internal` unconditionally (teamsTrust's personal-silo branch), so a B2B
|
|
942
|
+
// guest DMing the bot lit the `dm` shared memory layer. A resolved guest now demotes it to
|
|
943
|
+
// `external`, which is what the Slack DM-partner probe has always done. Fail-open: no verdict
|
|
944
|
+
// leaves the capture byte-identical.
|
|
945
|
+
const auth = await teamsAuthWithUserType(capturedAuth, a, probeFor);
|
|
946
|
+
const org = partition.org;
|
|
947
|
+
const atIso = new Date().toISOString();
|
|
948
|
+
// A DM has no parent container to widen to, so the seat is keyed on the DM's OWN surface key —
|
|
949
|
+
// NOT domainKeyOf (which resolves a channel's parent). For a personal/group chat the mapper leaves
|
|
950
|
+
// domainSurface unset, so partition.surfaceKey already IS the conversation-grain key.
|
|
951
|
+
const surfaceKey = partition.surfaceKey;
|
|
952
|
+
const conversationId = a.conversation.id;
|
|
953
|
+
|
|
954
|
+
// The seat: create / rotate / resume the DM's own standing window row.
|
|
955
|
+
const seat = await getOrCreateDmSeat(org, { surfaceKey }, atIso);
|
|
956
|
+
|
|
957
|
+
// PER-WAKE TRUST off the SAME auth capture the session binds with; `principalFallback` because a DM
|
|
958
|
+
// partner is the seat's principal. `isBot:false` is safe — the front's author gate already dropped
|
|
959
|
+
// self/bot-authored activities before this runs.
|
|
960
|
+
const wakeCtx: BindableContext = {
|
|
961
|
+
channel: { kind: "teams" },
|
|
962
|
+
session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } },
|
|
963
|
+
};
|
|
964
|
+
const trust = resolveWakeTrustFromContext(wakeCtx, { id: a.from.id, isBot: false }, { principalFallback: true });
|
|
965
|
+
const wakeId = mintWakeId();
|
|
966
|
+
|
|
967
|
+
// The frozen reply target: a PLAIN conversation send into the DM — no `replyToActivityId` (a personal
|
|
968
|
+
// chat has no reply chain; a group chat's chain anchor is not a divert fact) — the shape
|
|
969
|
+
// teamsPostArm.dispatchUnified already posts back through (serviceUrl + conversationId, no reply
|
|
970
|
+
// anchor ⇒ plain send). This IS the mint now: the codec's `dmOrigin` arm that used to produce the
|
|
971
|
+
// same minimal origin for a DM delegation anchor went with the anchor (OPE-368).
|
|
972
|
+
const origin: UnifiedWakeOrigin = { platform: "teams", serviceUrl: a.serviceUrl, conversationId };
|
|
973
|
+
|
|
974
|
+
const rendered = renderWake(
|
|
975
|
+
buildTeamsDmWake({
|
|
976
|
+
conversationId,
|
|
977
|
+
wakeId,
|
|
978
|
+
...(seat.freshGeneration && seat.tail.length > 0 ? { tail: seat.tail } : {}),
|
|
979
|
+
trigger: {
|
|
980
|
+
id: a.id || "unknown",
|
|
981
|
+
author: a.from.name ?? a.from.id,
|
|
982
|
+
authorId: a.from.id,
|
|
983
|
+
sentAt: atIso,
|
|
984
|
+
text: a.text || "(the message had no text)",
|
|
985
|
+
trust,
|
|
986
|
+
},
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
await divertToUnifiedWindow(to, {
|
|
990
|
+
message: rendered.message,
|
|
991
|
+
...(rendered.context && rendered.context.length > 0 ? { context: rendered.context } : {}),
|
|
992
|
+
token: seat.token,
|
|
993
|
+
// `vendorTs` (OPE-1716): the DM leg's own FRONT-DOOR RECEIPT, for the same reason and with the
|
|
994
|
+
// same limits as the channel leg's — a Bot-Framework activity carries no parsed timestamp. A DM
|
|
995
|
+
// wake without it would leave its ingress row with no first anchor at all.
|
|
996
|
+
window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(receiptEventTime(Date.now())) } satisfies UnifiedWindowWake,
|
|
997
|
+
// Byte-exactly the surface named inside the token — the self-consistency both admission gates match
|
|
998
|
+
// on, and the whole privacy fence (a DM window is deliverable to ITS OWN DM and nowhere else).
|
|
999
|
+
surfaceKey,
|
|
1000
|
+
// OPE-466 CARRIAGE: one ingested message, so the join runs over the trigger's own tier.
|
|
1001
|
+
// OPE-489: the author leg reads a positively-resolved `userRole: "guest"`, which the roster probe
|
|
1002
|
+
// above put on this capture (OPE-493); a DM has no room leg (`dm: true` — on a DM the trust plane
|
|
1003
|
+
// asks about the PARTNER, not about the room).
|
|
1004
|
+
auth: stampNonMemberChatAuth(
|
|
1005
|
+
auth as unknown as { attributes?: Record<string, unknown> },
|
|
1006
|
+
[{ trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) }],
|
|
1007
|
+
{ dm: true },
|
|
1008
|
+
),
|
|
1009
|
+
});
|
|
1010
|
+
// Record the wake on the seat's own row (turnCount/lastWakeAt feed the rotation thresholds),
|
|
1011
|
+
// best-effort — a touch failure must never affect the reply.
|
|
1012
|
+
await touchDmSeat(org, surfaceKey, atIso).catch(() => undefined);
|
|
1013
|
+
// OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
|
|
1014
|
+
// the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
|
|
1015
|
+
await recordWakeTail(org, dmWindowRegistryKey(surfaceKey), seat.token, {
|
|
1016
|
+
kind: "wake",
|
|
1017
|
+
at: atIso,
|
|
1018
|
+
from: "human", // the front's author gate already dropped self/bot-authored activities
|
|
1019
|
+
trust,
|
|
1020
|
+
// THE AUTHOR GRAIN (OPE-1747) — the same triple the DM's `stampNonMemberChatAuth` join above
|
|
1021
|
+
// reads: the resolved tier, Entra's `userType: "guest"` refutation, and the operator's register.
|
|
1022
|
+
...tailAuthorGrain({ trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) }),
|
|
1023
|
+
author: a.from.name ?? a.from.id,
|
|
1024
|
+
authorId: a.from.id,
|
|
1025
|
+
id: a.id || "unknown",
|
|
1026
|
+
platform: "teams",
|
|
1027
|
+
container: surfaceKey,
|
|
1028
|
+
text: a.text ?? "",
|
|
1029
|
+
});
|
|
1030
|
+
await appendAudit({
|
|
1031
|
+
org,
|
|
1032
|
+
platform: "teams",
|
|
1033
|
+
workspace: partition.workspace,
|
|
1034
|
+
surface: surfaceKey,
|
|
1035
|
+
principalId: a.from.id,
|
|
1036
|
+
action: "teams.dispatched",
|
|
1037
|
+
detail: { addressing: "direct-mention", dmWindow: true, conversationId },
|
|
1038
|
+
}).catch(() => undefined);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Build the front route handler. Verifies (fail-closed 401), classifies, then forwards or
|
|
1043
|
+
* diverts. `innerHandler` is teamsChannel's own POST handler, invoked with a FRESH Request
|
|
1044
|
+
* (re-readable body) + the same {@link RouteHandlerArgs}, so teamsChannel re-verifies and
|
|
1045
|
+
* dispatches exactly as if it owned the URL — byte-identical whenever no plan resolves.
|
|
1046
|
+
*/
|
|
1047
|
+
export function buildTeamsFrontHandler(innerHandler: RouteHandler, opts: TeamsFrontOptions = {}): RouteHandler {
|
|
1048
|
+
// TODO(teams-front-credentials): the default verify uses env app id only (`{}`). If the wrapped
|
|
1049
|
+
// native teamsChannel is ever built with `credentials.appId` or a custom webhookVerifier, the
|
|
1050
|
+
// front would verify with a DIFFERENT config than the inner re-verify (front could 401 a request
|
|
1051
|
+
// the inner would accept, or vice-versa). Harmless today (teams.ts passes no credentials, so both
|
|
1052
|
+
// sides use the env app id), but if teams.ts starts passing credentials, thread them here via
|
|
1053
|
+
// opts.verify. Needs eve's Channel to expose the inner verifier config to plumb cleanly.
|
|
1054
|
+
const verify = opts.verify ?? ((req: Request) => verifyTeamsRequest(req, {}));
|
|
1055
|
+
const dedupe = opts.dedupe ?? new EventDedupe();
|
|
1056
|
+
const fifo = opts.fifo ?? new ThreadFifo(recordTeamsWakeFailure);
|
|
1057
|
+
const now = opts.now ?? (() => Date.now());
|
|
1058
|
+
const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
|
|
1059
|
+
const planFor = opts.planFor ?? liveTeamsFrontPlan;
|
|
1060
|
+
const dispatch = opts.dispatch ?? liveTeamsDispatch;
|
|
1061
|
+
const dmDivert = opts.dmDivert ?? liveTeamsDmDivert;
|
|
1062
|
+
const externalGate = opts.externalGate ?? liveTeamsExternalGate;
|
|
1063
|
+
const stopReaction = opts.stopReaction ?? ((rx: TeamsReactionActivity, attachSession: AttachSessionForCancel) => routeTeamsStopReaction(rx, { attachSession }));
|
|
1064
|
+
const engagementSeam = opts.engagementSeam ?? resolveTeamsEngagement;
|
|
1065
|
+
const markEngaged = opts.markEngaged ?? ((a: TeamsMessageActivity) => markTeamsEngaged(a));
|
|
1066
|
+
const roomStopped = opts.roomStopped ?? teamsRoomWatchStopped;
|
|
1067
|
+
const markRoomWatched = opts.markRoomWatched ?? markTeamsRoomWatched;
|
|
1068
|
+
const clearRoomStop = opts.clearRoomStop ?? clearTeamsRoomStop;
|
|
1069
|
+
|
|
1070
|
+
return async (req: Request, args: RouteHandlerArgs): Promise<Response> => {
|
|
1071
|
+
let rawBody: string;
|
|
1072
|
+
try {
|
|
1073
|
+
rawBody = await verify(req);
|
|
1074
|
+
} catch {
|
|
1075
|
+
return new Response("unauthorized", { status: 401 });
|
|
1076
|
+
}
|
|
1077
|
+
const forward = (): Promise<Response> => {
|
|
1078
|
+
const forwarded = new Request(req.url, { method: "POST", headers: req.headers, body: rawBody });
|
|
1079
|
+
return innerHandler(forwarded, args);
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
let parsed: unknown;
|
|
1083
|
+
try {
|
|
1084
|
+
parsed = JSON.parse(rawBody);
|
|
1085
|
+
} catch {
|
|
1086
|
+
return forward(); // unparseable — let teamsChannel ack it exactly as today
|
|
1087
|
+
}
|
|
1088
|
+
// ── THE STOP REACTION (OPE-898) ──────────────────────────────────────────────────────────────
|
|
1089
|
+
//
|
|
1090
|
+
// POSITION IS LOAD-BEARING. It sits ABOVE `parseTeamsActivity`, because that parser answers only
|
|
1091
|
+
// for `message` activities — a `messageReaction` comes back as null and would fall straight into
|
|
1092
|
+
// the forward below, which is why this affordance did nothing before. It is also the whole point
|
|
1093
|
+
// that this runs in the INGRESS: `cancelAnchoredTurn` mints no session and calls no model, so a
|
|
1094
|
+
// stop stays free and keeps working for a scope that is already over its spend cap.
|
|
1095
|
+
//
|
|
1096
|
+
// ACK, NEVER FORWARD. The native channel has nothing to do with a reaction activity, and the arm
|
|
1097
|
+
// fails closed on every unknown — an unrecognised body simply is not a reaction and returns null.
|
|
1098
|
+
const reaction = parseTeamsReaction(parsed);
|
|
1099
|
+
if (reaction) {
|
|
1100
|
+
args.waitUntil(
|
|
1101
|
+
stopReaction(reaction, args.attachSession).then(
|
|
1102
|
+
(o) => tlog(`reaction in ${reaction.conversationId ?? "?"}: ${o.stopped ? o.outcome : o.reason}`),
|
|
1103
|
+
(err) => tlog(`stop leg failed: ${String(err)}`),
|
|
1104
|
+
),
|
|
1105
|
+
);
|
|
1106
|
+
return OK();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const activity = parseTeamsActivity(parsed);
|
|
1110
|
+
if (!activity || activity.type !== "message") {
|
|
1111
|
+
return forward(); // invokes / conversationUpdates / unknown — the native channel owns them
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// The author gate, resolved ONCE for every branch below: a self/bot-authored activity never
|
|
1115
|
+
// wakes anything here. It is also the reason the policy gate below skips them — a machine author
|
|
1116
|
+
// is below peer standing but it is not a person outside the organisation (the `relay` ruling in
|
|
1117
|
+
// access/external-policy.ts), so it has no author-grain verdict to receive.
|
|
1118
|
+
// ONE PREDICATE, SHARED WITH THE SEAM (channels/teams-engagement.ts). The native gate needs the
|
|
1119
|
+
// same answer — a machine-authored CHANNEL activity is still forwarded to it — and two spellings
|
|
1120
|
+
// of "is this a bot" is how one of them comes to miss a case the other catches.
|
|
1121
|
+
const machineAuthored = teamsMachineAuthored(activity);
|
|
1122
|
+
|
|
1123
|
+
// ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ────────────────
|
|
1124
|
+
//
|
|
1125
|
+
// POSITION IS LOAD-BEARING, and for TWO reasons on Teams. It sits above the DM divert and above
|
|
1126
|
+
// `planFor`, so (a) an activity the agent may not act on costs nothing — no seat resolution, no
|
|
1127
|
+
// session mint, no spend read — and (b) a SUPPRESSED activity is never FORWARDED, which is what
|
|
1128
|
+
// extends the policy to a deployment running the native path with the window gates off.
|
|
1129
|
+
//
|
|
1130
|
+
// Both grains are inert under their shipped values (`taint` / `engage`), so this is a no-op on
|
|
1131
|
+
// every deployment that has not opted in.
|
|
1132
|
+
if (!machineAuthored && (await externalGate(activity))) return OK();
|
|
1133
|
+
|
|
1134
|
+
// ── CAPTURE THE CONVERSATION REFERENCE (OPE-887) ─────────────────────────────────────────────
|
|
1135
|
+
//
|
|
1136
|
+
// THE ONE PLACE A PROACTIVE TEAMS ADDRESS EVER COMES FROM. A Bot Framework send needs a regional
|
|
1137
|
+
// `serviceUrl` and a Microsoft-minted conversation id, and neither exists anywhere but on an
|
|
1138
|
+
// inbound activity — so an activity that passes here without being recorded is an address this
|
|
1139
|
+
// deployment can never hold again. Both Teams arms that address a person or a team with no live
|
|
1140
|
+
// turn (the DM arm, the account directory) read what this writes.
|
|
1141
|
+
//
|
|
1142
|
+
// POSITION IS LOAD-BEARING, in both directions. It sits BELOW the external-participant gate, so a
|
|
1143
|
+
// suppressed author never contributes an address the agent could later message; and it sits ABOVE
|
|
1144
|
+
// the DM divert and `planFor`, so it captures EVERY activity this deployment acts on — including
|
|
1145
|
+
// the ones that fall through to the native path when the window gates are off, which no branch
|
|
1146
|
+
// below would have covered.
|
|
1147
|
+
//
|
|
1148
|
+
// AFTER THE ACK, and fail-soft inside: `recordTeamsConversationReference` never throws, and a lost
|
|
1149
|
+
// capture costs a future delivery, never this wake. Machine-authored activities record nothing —
|
|
1150
|
+
// there is no person or team to reach behind a bot.
|
|
1151
|
+
if (!machineAuthored) {
|
|
1152
|
+
args.waitUntil(
|
|
1153
|
+
recordTeamsConversationReference({
|
|
1154
|
+
scope: activity.scope,
|
|
1155
|
+
tenantId: activity.tenantId ?? activity.conversation.tenantId,
|
|
1156
|
+
conversationId: activity.conversation.id,
|
|
1157
|
+
serviceUrl: activity.serviceUrl,
|
|
1158
|
+
userId: activity.from.id,
|
|
1159
|
+
teamId: activity.teamId,
|
|
1160
|
+
}),
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ── LEG E: THE AMBIENT ENGAGEMENT SEAM (OPE-1500, contract §2.5) ─────────────────────────────
|
|
1165
|
+
//
|
|
1166
|
+
// The middle posture, and the one Teams had no form of: an @mention marks the conversation
|
|
1167
|
+
// ENGAGED, a later PLAIN message in it wakes the agent, and `stand_down` stores the stop. The
|
|
1168
|
+
// policy is core's (`channels/engagement.ts` — `markThreadEngaged` / `shouldDeliverReply` / the
|
|
1169
|
+
// durable `stopped` flag); `channels/teams-engagement.ts` translates Teams facts into it.
|
|
1170
|
+
//
|
|
1171
|
+
// POSITION IS LOAD-BEARING, and it is what keeps LEG A AND LEG E FROM DOUBLE-HANDLING ONE
|
|
1172
|
+
// MESSAGE. This block runs ABOVE both the DM divert and `planFor`, and it dispatches NOTHING of
|
|
1173
|
+
// its own. It contributes exactly two decisions, and every wake below stays the property of the
|
|
1174
|
+
// leg that already owned it:
|
|
1175
|
+
// • THE MARK, on a mention, on EVERY path — including a single-window channel, where Leg A
|
|
1176
|
+
// answers the mention. Without the mark on that path `stand_down` would have no row to flag
|
|
1177
|
+
// and would refuse on a surface the verb is supposed to work on.
|
|
1178
|
+
// • THE SUPPRESSION, on a stood-down conversation, ABOVE EVERYTHING — never diverted, never
|
|
1179
|
+
// forwarded, so both legs go quiet. This is the half §7.6 of the contract records as
|
|
1180
|
+
// missing: Leg A's ambient wake reads no registry of its own.
|
|
1181
|
+
//
|
|
1182
|
+
// 🔴 WHAT THIS SUPPRESSION COVERS, AND WHERE THE OTHER HALF LIVES. The `stopped` flag read here
|
|
1183
|
+
// sits on a CONVERSATION row, and only an @mention writes one — so this block closes exactly the
|
|
1184
|
+
// case where somebody invited the agent into a conversation and then asked it to stop. Leg A's
|
|
1185
|
+
// UNDIRECTED ambient wake is a room-grain wake with no conversation row behind it, so it has its
|
|
1186
|
+
// own room-grain stop further down this handler (OPE-1511, search LEG A'S OWN STOP).
|
|
1187
|
+
//
|
|
1188
|
+
// THE TWO ARE NOT INDEPENDENT, and the overlap runs one way. A ROOM stop suppresses an ENGAGED
|
|
1189
|
+
// conversation's ambient follow-up too: that activity resolves `deliver` here, falls through
|
|
1190
|
+
// this block, and is dropped by the room gate below. So a stop asked for in one reply chain
|
|
1191
|
+
// silences a chain whose people did invite the agent. It is the honest reading of a room-grain
|
|
1192
|
+
// ask — `stand_down`'s room copy says "anywhere in this room" — and it errs toward silence,
|
|
1193
|
+
// which is the safe direction for a wake gate. It does NOT run the other way: a CONVERSATION
|
|
1194
|
+
// stop leaves the room row alone, so the rest of the room keeps waking.
|
|
1195
|
+
//
|
|
1196
|
+
// THE DELIVERY HALF IS NOT DECIDED HERE, and deliberately so. The front holds no primitive that
|
|
1197
|
+
// could wake an existing native session, so admitting the activity through the NATIVE mention
|
|
1198
|
+
// gate IS the wake (channels/teams.ts, which asks the same seam). A `deliver` AND a plain `hold`
|
|
1199
|
+
// therefore both fall through to the ordinary forward, and that gate answers. The cost is one
|
|
1200
|
+
// extra registry read on a forwarded non-mention message; the alternative — dropping a `hold`
|
|
1201
|
+
// here — would skip everything eve's own channel does with an activity before `onMessage`, for
|
|
1202
|
+
// no change in outcome. A read is the cheaper mistake to be wrong about.
|
|
1203
|
+
//
|
|
1204
|
+
// Machine-authored activities skip the seam entirely: they wake nothing on any path, so the
|
|
1205
|
+
// registry read would be paid for a message with no decision behind it.
|
|
1206
|
+
const seam: TeamsEngagementVerdict | null = machineAuthored ? null : await engagementSeam(activity, machineAuthored);
|
|
1207
|
+
if (seam?.kind === "engage") {
|
|
1208
|
+
// 🔴 THE MARK IS DEDUPED, AND THE STOP IS WHY. `markThreadEngaged({ participating: true })` is
|
|
1209
|
+
// the re-invitation, so it CLEARS a `stopped` flag — correct for a person's second @mention,
|
|
1210
|
+
// wrong for the Bot Framework REDELIVERING the first one. Redelivery is routine here (a slow
|
|
1211
|
+
// ack is enough, and this mark is itself awaited before the ack). Both layers, exactly as the
|
|
1212
|
+
// wake dedupe below uses them, under a SEPARATE key prefix so neither consumes the other's
|
|
1213
|
+
// first sighting. Fail-open on a store error, same posture as that dedupe.
|
|
1214
|
+
//
|
|
1215
|
+
// 🔴 THIS IS THE CHEAP LAYER, NOT THE GUARANTEE (OPE-1527). Both layers EXPIRE — the in-memory
|
|
1216
|
+
// one with the process, the durable one after DEDUPE_TTL_MS — and a stand-down does not expire
|
|
1217
|
+
// at all, so a redelivery on a fresh process after the TTL sailed through here and lifted the
|
|
1218
|
+
// stop. What actually holds is in the store: the promotion carries the activity id as
|
|
1219
|
+
// `engagedBy`, and the row refuses to clear `stopped` for an id it already recorded
|
|
1220
|
+
// (channels/teams-engagement.ts `markTeamsEngaged`). This layer stays because it saves the
|
|
1221
|
+
// round-trip on the common retry, never because it bounds the replay.
|
|
1222
|
+
const engageKey = `teams-engage:${activity.conversation.id}:${activity.id}`;
|
|
1223
|
+
if (!dedupe.seen(engageKey, now())) {
|
|
1224
|
+
dedupe.mark(engageKey, now());
|
|
1225
|
+
// AWAITED, not deferred: the mark must land before the next message in this conversation is
|
|
1226
|
+
// decided against it, and `markThreadEngaged` already swallows a store failure.
|
|
1227
|
+
if (await markEventSeen(seam.org, engageKey, new Date(now()).toISOString())) await markEngaged(activity);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (seam?.kind === "hold" && seam.stopped) {
|
|
1231
|
+
// 🔴 A PARKED QUESTION'S ANSWER IS NEVER SUPPRESSED. eve routes a text HITL reply as an
|
|
1232
|
+
// ordinary `message` activity and branches on it BEFORE `onMessage`, so a blanket early return
|
|
1233
|
+
// here would silently drop the answer to a question the agent itself asked — the exact
|
|
1234
|
+
// silent-HITL-drop shape channels/teams-input-response.ts exists to prevent (OPE-574). Standing
|
|
1235
|
+
// down means "stop following the room", never "stop listening to what I asked you".
|
|
1236
|
+
if (isTeamsInputResponseActivity(activity)) {
|
|
1237
|
+
tlog(`activity ${activity.id} in ${activity.conversation.id}: stood down, but this is an answer to a parked question — forwarded`);
|
|
1238
|
+
return forward();
|
|
1239
|
+
}
|
|
1240
|
+
tlog(`activity ${activity.id} in ${activity.conversation.id}: stood down here — suppressed on BOTH legs until an @mention`);
|
|
1241
|
+
return OK();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// FIRST-CLASS DM WINDOW DIVERT (OPE-357). A personal (1:1) or group chat is diverted into its OWN
|
|
1245
|
+
// dedicated DM window — a STRUCTURALLY SEPARATE admit space from the shared channel window. This
|
|
1246
|
+
// sits BEFORE `planFor` deliberately: the single-window privacy floor (teamsSingleWindowPrivacyOk)
|
|
1247
|
+
// refuses `scope !== "channel"` UNCONDITIONALLY and must stay that strict; a DM window never joins
|
|
1248
|
+
// the shared one, so bypassing that floor here is correct, not a weakening (the Discord
|
|
1249
|
+
// `isDm`-before-the-single-window-block precedent). It is UNCONDITIONAL since OPE-368: the
|
|
1250
|
+
// `OE_DM_SEAT_ROTATION=0` hatch that used to sit in this condition (falling through to planFor's
|
|
1251
|
+
// floor, which forwards the DM to the native path) was the last supported way to run a Teams DM on
|
|
1252
|
+
// an un-wakeable native token — the seat class the retired OPE-306 delegation anchor served.
|
|
1253
|
+
if (activity.scope === "personal" || activity.scope === "groupChat") {
|
|
1254
|
+
// Author gate (mirrors the channel branch below): a machine-authored DM activity never wakes a
|
|
1255
|
+
// seat — dropped, not forwarded (a DM window owns this surface; the native path is retired here).
|
|
1256
|
+
if (machineAuthored) return OK();
|
|
1257
|
+
// 🔴 LEG E GATES A GROUP CHAT, AND NEVER A 1:1 (OPE-1500). A personal chat resolves
|
|
1258
|
+
// `unanchored`, so this line cannot fire on one: "a DM is always answered" is unchanged.
|
|
1259
|
+
//
|
|
1260
|
+
// A GROUP CHAT is different, and this narrows what it used to do. The `ChatMessage.Read.Chat`
|
|
1261
|
+
// RSC grant makes Microsoft deliver EVERY group-chat message here, and this divert is
|
|
1262
|
+
// unconditional (OPE-368) — so a granted tenant ran a full agent session on every message any
|
|
1263
|
+
// member typed. The native mention gate (channels/teams.ts) never covered it: a group chat is
|
|
1264
|
+
// diverted here and never forwarded. The seam is the posture that belongs in between — the
|
|
1265
|
+
// chat is answered once it @mentions the agent, and goes quiet again on `stand_down`.
|
|
1266
|
+
//
|
|
1267
|
+
// FAIL-CLOSED, and that is a real second narrowing: `getThreadEngagement` reads a store failure
|
|
1268
|
+
// as "no row" (channels/engagement.ts), so an operational-store outage holds every group chat
|
|
1269
|
+
// rather than answering it. That is the right direction for a wake gate — an unreadable
|
|
1270
|
+
// registry cannot prove the agent was invited — but it means a chat can go quiet for a reason
|
|
1271
|
+
// nobody in it said out loud.
|
|
1272
|
+
if (seam?.kind === "hold") {
|
|
1273
|
+
// 🔴 THE SAME PARKED-QUESTION CARVE-OUT THE STOOD-DOWN BRANCH CARRIES, and this branch needs it
|
|
1274
|
+
// for a WIDER set of holds: a group chat holds whenever it is un-engaged, which includes the
|
|
1275
|
+
// fail-closed hold a store outage produces. A routine can park a question in a chat that holds
|
|
1276
|
+
// for either reason, and dropping the person's answer here would be the silent-HITL-drop shape
|
|
1277
|
+
// channels/teams-input-response.ts exists to prevent (OPE-574). Forwarded, so eve's own HITL
|
|
1278
|
+
// path answers it.
|
|
1279
|
+
if (isTeamsInputResponseActivity(activity)) {
|
|
1280
|
+
tlog(`activity ${activity.id} in ${activity.conversation.id}: group chat not engaged, but this is an answer to a parked question — forwarded`);
|
|
1281
|
+
return forward();
|
|
1282
|
+
}
|
|
1283
|
+
tlog(`activity ${activity.id} in ${activity.conversation.id}: group chat not engaged — ${seam.reason}`);
|
|
1284
|
+
return OK();
|
|
1285
|
+
}
|
|
1286
|
+
// Two-layer idempotency, same as the channel path: an in-memory guard first (no org needed), then
|
|
1287
|
+
// the durable store once the partition is resolved. A Bot-Framework redelivery must never
|
|
1288
|
+
// double-wake; fail-open on a store error (dedupe prevents a double reply, not a security hole).
|
|
1289
|
+
const dmKey = `teams:${activity.conversation.id}:${activity.id}`;
|
|
1290
|
+
if (dedupe.seen(dmKey, now())) return OK();
|
|
1291
|
+
dedupe.mark(dmKey, now());
|
|
1292
|
+
// Resolve the DM's identity ONCE (auth + partition) — fail-closed to the native path on any
|
|
1293
|
+
// binding/partition error, exactly as liveTeamsFrontPlan's own try/catch does.
|
|
1294
|
+
let dm: { auth: OpenEverywhereTeamsAuth; partition: Partition } | null;
|
|
1295
|
+
try {
|
|
1296
|
+
const auth = authForActivity(activity);
|
|
1297
|
+
const partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
|
|
1298
|
+
dm = { auth, partition };
|
|
1299
|
+
} catch {
|
|
1300
|
+
dm = null;
|
|
1301
|
+
}
|
|
1302
|
+
if (!dm) return forward(); // can't anchor the DM to an org ⇒ let the native path own it
|
|
1303
|
+
const { auth: dmAuth, partition: dmPartition } = dm; // const-capture for the deferred FIFO closure
|
|
1304
|
+
if (!(await markEventSeen(dmPartition.org, dmKey, new Date(now()).toISOString()))) return OK();
|
|
1305
|
+
tlog(`activity ${activity.id} on ${dmPartition.surfaceKey}: DM — diverting to its own first-class DM window`);
|
|
1306
|
+
// Per-container FIFO (the same discipline as the channel path): one DM's wakes deliver strictly in
|
|
1307
|
+
// arrival order; a failure lands in the FIFO's loud catch + a durable audit row.
|
|
1308
|
+
args.waitUntil(fifo.enqueue(dmPartition.surfaceKey, () => dmDivert(activity, dmAuth, dmPartition, args.to)));
|
|
1309
|
+
return OK();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// THE one single-window decision (single-leg: one webhook, one synchronous verdict). No plan
|
|
1313
|
+
// — gates off, floor failed, config/partition error — ⇒ forward, byte-identical to today.
|
|
1314
|
+
const plan = planFor(activity);
|
|
1315
|
+
if (!plan) return forward();
|
|
1316
|
+
|
|
1317
|
+
// Author gate (mirrors Slack's authorIsBot disqualifier on a kept mention): the window owns
|
|
1318
|
+
// this surface's traffic, and machine-authored activities never wake the shared session —
|
|
1319
|
+
// dropped, not forwarded (forwarding would hand a suppressed surface back to the native
|
|
1320
|
+
// session for bots only, splitting the context the window exists to unify).
|
|
1321
|
+
if (machineAuthored) {
|
|
1322
|
+
tlog(`activity ${activity.id} on ${plan.surfaceKey}: bot/self-authored — dropped (single-window surface, relay is Phase 2)`);
|
|
1323
|
+
return OK();
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ── LEG A'S OWN STOP (OPE-1511) ─────────────────────────────────────────────────────────────
|
|
1327
|
+
//
|
|
1328
|
+
// The half the block above could not cover, and now does. Leg A wakes on ANY message in a
|
|
1329
|
+
// vouched single-window channel, including a reply chain nobody ever @mentioned the agent in.
|
|
1330
|
+
// A `stopped` flag lives on an engagement row and only an @mention wrote one, so a person in
|
|
1331
|
+
// such a room could not stop the wake and `stand_down` refused for want of a row. The rule is
|
|
1332
|
+
// that a stored stop exists exactly where a stored wake exists: this leg now stores a ROOM-grain
|
|
1333
|
+
// row on every ambient wake, reads that row's flag here, and `tools/stand_down.ts` marks it when
|
|
1334
|
+
// the conversation grain holds nothing (channels/teams-engagement.ts carries the whole shape).
|
|
1335
|
+
//
|
|
1336
|
+
// AN @MENTION IS THE WAY BACK, and it must run BEFORE the gate or the stop would be a trap: the
|
|
1337
|
+
// mention re-invites the ROOM, and Leg E's own mark re-invites only the conversation. The clear
|
|
1338
|
+
// carries the activity id, so a redelivery of the mention the row records lifts nothing
|
|
1339
|
+
// (OPE-1527; one slot — OPE-1562).
|
|
1340
|
+
// A FAILED clear leaves the room stopped and still answers this mention — the person's next
|
|
1341
|
+
// @mention retries it, which is the safe direction for a write that only ever removes a stop.
|
|
1342
|
+
//
|
|
1343
|
+
// 🔴 NO `.catch()` ON THE GATE, and that asymmetry is deliberate. `teamsRoomWatchStopped` owns
|
|
1344
|
+
// its own failure direction and picks FAIL-CLOSED (hold the wake, its doc says why). A swallow
|
|
1345
|
+
// here would silently invert that to fail-open and hand a stood-down room back to the ambient
|
|
1346
|
+
// leg — the exact defect the read was written to prevent. It never throws.
|
|
1347
|
+
const addressedHere = plan.window.addressing === "direct-mention";
|
|
1348
|
+
if (addressedHere) {
|
|
1349
|
+
await clearRoomStop(plan.partition, new Date(now()).toISOString(), activity.id).catch(() => undefined);
|
|
1350
|
+
} else if (await roomStopped(plan.partition)) {
|
|
1351
|
+
// 🔴 THE SAME PARKED-QUESTION CARVE-OUT the stood-down conversation branch carries. eve routes
|
|
1352
|
+
// a text HITL answer as an ordinary message activity, so dropping one here would silently lose
|
|
1353
|
+
// the answer to a question the agent itself asked (OPE-574).
|
|
1354
|
+
if (isTeamsInputResponseActivity(activity)) {
|
|
1355
|
+
tlog(`activity ${activity.id} on ${plan.surfaceKey}: room stood down, but this is an answer to a parked question — forwarded`);
|
|
1356
|
+
return forward();
|
|
1357
|
+
}
|
|
1358
|
+
tlog(`activity ${activity.id} on ${plan.surfaceKey}: this room was stood down — ambient wake suppressed until an @mention`);
|
|
1359
|
+
return OK();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Two-layer idempotency (in-memory + the durable cross-instance store): a Bot-Framework
|
|
1363
|
+
// redelivery (non-200 → retry, same activity id) must never double-wake. Fail-open on a
|
|
1364
|
+
// store error — dedupe prevents a double reply, not a security hole.
|
|
1365
|
+
const key = `teams:${activity.conversation.id}:${activity.id}`;
|
|
1366
|
+
if (dedupe.seen(key, now())) return OK();
|
|
1367
|
+
dedupe.mark(key, now());
|
|
1368
|
+
if (!(await markEventSeen(plan.partition.org, key, new Date(now()).toISOString()))) return OK();
|
|
1369
|
+
|
|
1370
|
+
tlog(
|
|
1371
|
+
`activity ${activity.id} on ${plan.surfaceKey}: single-window — diverting to the unified supervisor ` +
|
|
1372
|
+
`(${plan.window.addressing === "direct-mention" ? "addressed @mention; native forward suppressed" : "ambient channel-watch"})`,
|
|
1373
|
+
);
|
|
1374
|
+
// THE STORED WAKE THE STORED STOP NEEDS (OPE-1511). Written for the AMBIENT leg only: an
|
|
1375
|
+
// addressed @mention already engages its conversation through Leg E, and it has just re-invited
|
|
1376
|
+
// the room above. Deferred, because it decides nothing about this wake — a lost row costs a
|
|
1377
|
+
// later stop somewhere to land, never a turn.
|
|
1378
|
+
if (!addressedHere) args.waitUntil(markRoomWatched(plan.partition, new Date(now()).toISOString()).catch(() => undefined));
|
|
1379
|
+
// Per-container FIFO (the slack-front supervisor-key discipline): one channel's wakes deliver
|
|
1380
|
+
// strictly in arrival order; a failure lands in the FIFO's loud catch + a durable audit row.
|
|
1381
|
+
args.waitUntil(fifo.enqueue(plan.surfaceKey, () => dispatch(activity, plan, args.to)));
|
|
1382
|
+
return OK();
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Compose the front route with a wrapped `teamsChannel`. `inner` must have been built with
|
|
1388
|
+
* `route: INTERNAL_TEAMS_ROUTE`. Returns the SAME channel value (preserving eve's internal
|
|
1389
|
+
* adapter/receive) with the public front route prepended — so the Bot Framework's URL hits us,
|
|
1390
|
+
* and both routes share one channel name → one session namespace (the frontSlackChannel shape).
|
|
1391
|
+
*/
|
|
1392
|
+
export function frontTeamsChannel<C extends Channel<any, any, any>>(inner: C, opts: TeamsFrontOptions = {}): C {
|
|
1393
|
+
const innerPost = inner.routes.find(
|
|
1394
|
+
(r): r is HttpRouteDefinition<any> => r.method === "POST" && r.path === INTERNAL_TEAMS_ROUTE,
|
|
1395
|
+
);
|
|
1396
|
+
if (!innerPost) {
|
|
1397
|
+
throw new Error(
|
|
1398
|
+
`frontTeamsChannel: wrapped teamsChannel must be built with route: "${INTERNAL_TEAMS_ROUTE}" ` +
|
|
1399
|
+
`(found paths: ${inner.routes.map((r) => r.path).join(", ")})`,
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
const front = buildTeamsFrontHandler(innerPost.handler, opts);
|
|
1403
|
+
return { ...inner, routes: [POST(PUBLIC_TEAMS_ROUTE, front), ...inner.routes] };
|
|
1404
|
+
}
|