@open-everywhere/provider-teams 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,395 @@
|
|
|
1
|
+
// Pure Microsoft Teams→OpenEverywhere identity capture. The Teams channel (teams.ts) calls
|
|
2
|
+
// these to stow RAW Teams facts (tenant/conversation/channel/team ids + scope +
|
|
3
|
+
// membershipType + actor) into the session auth context's `attributes`. The
|
|
4
|
+
// INTERPRETATION of those facts into mode/visibility lives here too (teamsMode/
|
|
5
|
+
// teamsVisibility) — kept beside the capture so the mapper in
|
|
6
|
+
// agent/identity/binding.ts (bindTeams) just calls them, exactly as Slack splits
|
|
7
|
+
// capture (slack-identity.ts) from interpretation (binding.ts). This file imports no
|
|
8
|
+
// eve types — only OpenEverywhere's `SessionMode` string-union — so it's trivially
|
|
9
|
+
// unit-testable. The security-critical decisions are ONE tested place each and both
|
|
10
|
+
// fail closed: teamsVisibility ("is this a team-wide (public) channel?") and
|
|
11
|
+
// teamsTrust ("is this surface positively in-tenant?" — unknown → no verdict, so
|
|
12
|
+
// bindFrom binds it external).
|
|
13
|
+
|
|
14
|
+
import type { SessionMode, BindableContext, IdentityMapper, RawIdentity } from "@open-everywhere/core/agent/adapter-api.js";
|
|
15
|
+
import { rootDmGrainAttrs } from "@open-everywhere/core/agent/adapter-api.js";
|
|
16
|
+
|
|
17
|
+
/** Structural match for eve's SessionAuthContext (attributes + principal). */
|
|
18
|
+
export interface OpenEverywhereTeamsAuth {
|
|
19
|
+
readonly authenticator: string;
|
|
20
|
+
readonly principalId: string;
|
|
21
|
+
readonly principalType: string;
|
|
22
|
+
readonly attributes: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TeamsIdentityInput {
|
|
26
|
+
/** The Teams user (from.id) — the OpenEverywhere principal. */
|
|
27
|
+
readonly userId: string;
|
|
28
|
+
/** Azure AD object id of the actor, when carried — a stable cross-tenant user key. */
|
|
29
|
+
readonly aadObjectId?: string;
|
|
30
|
+
readonly userName?: string;
|
|
31
|
+
/** The tenant (org) — becomes the OpenEverywhere `workspace`, the org boundary. */
|
|
32
|
+
readonly tenantId?: string;
|
|
33
|
+
/** The conversation — becomes the OpenEverywhere `surface`. */
|
|
34
|
+
readonly conversationId: string;
|
|
35
|
+
/** Teams team/channel id (channel conversations only). */
|
|
36
|
+
readonly channelId?: string;
|
|
37
|
+
/** Teams team id (channel conversations only). */
|
|
38
|
+
readonly teamId?: string;
|
|
39
|
+
/** Bot Connector base URL for proactive post-back. */
|
|
40
|
+
readonly serviceUrl?: string;
|
|
41
|
+
/** Normalized conversation scope: "personal" | "groupChat" | "channel" | "unknown". */
|
|
42
|
+
readonly scope?: string;
|
|
43
|
+
/** Channel membership type ("standard" | "private" | "shared"), when positively known. */
|
|
44
|
+
readonly membershipType?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The acting user's tenant membership type ("guest" | "member"), positively resolved via the
|
|
47
|
+
* cached Bot Connector roster probe (teams-roster.ts — the analog of Slack's users.info). A GUEST
|
|
48
|
+
* is a cross-org / B2B-collaboration account inside the tenant, so it demotes the surface's
|
|
49
|
+
* trust to `external` exactly as a restricted Slack DM partner does. `undefined` = never
|
|
50
|
+
* determined (no lookup / lookup failed / field absent) → teamsTrust leaves the existing
|
|
51
|
+
* channel/vouch verdict intact (fail-closed unless the operator vouches), mirroring Slack.
|
|
52
|
+
*/
|
|
53
|
+
readonly userType?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The attribute the roster verdict is stowed under — ONE spelling, read by `bindTeams` and by the
|
|
57
|
+
* front's `teamsAuthorRefuted`. */
|
|
58
|
+
const TEAMS_USER_TYPE_ATTR = "user_type";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fold a resolved tenant membership type into an ALREADY-BUILT auth capture (OPE-493).
|
|
62
|
+
*
|
|
63
|
+
* The front resolves the roster verdict AFTER it has built the capture, because the probe is a
|
|
64
|
+
* network call and the capture is built synchronously. This is the seam that carries the answer in,
|
|
65
|
+
* so the attribute name still has exactly one spelling.
|
|
66
|
+
*
|
|
67
|
+
* 🔴 NO VERDICT RETURNS THE SAME OBJECT, by reference. That is the fail-open guarantee stated as
|
|
68
|
+
* code: when the probe cannot answer, nothing downstream can tell the probe ran.
|
|
69
|
+
*/
|
|
70
|
+
export function withTeamsUserType<A extends OpenEverywhereTeamsAuth>(auth: A, userType: string | undefined): A {
|
|
71
|
+
if (!userType) return auth;
|
|
72
|
+
return { ...auth, attributes: { ...auth.attributes, [TEAMS_USER_TYPE_ATTR]: userType } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build the auth context OpenEverywhere dispatches with — raw facts as string attributes. */
|
|
76
|
+
export function buildTeamsAuth(input: TeamsIdentityInput): OpenEverywhereTeamsAuth {
|
|
77
|
+
const attributes: Record<string, string> = {
|
|
78
|
+
conversation_id: input.conversationId,
|
|
79
|
+
user_id: input.userId,
|
|
80
|
+
};
|
|
81
|
+
if (input.aadObjectId) attributes.aad_object_id = input.aadObjectId;
|
|
82
|
+
if (input.userName) attributes.user_name = input.userName;
|
|
83
|
+
if (input.tenantId) attributes.tenant_id = input.tenantId;
|
|
84
|
+
if (input.channelId) attributes.channel_id = input.channelId;
|
|
85
|
+
if (input.teamId) attributes.team_id = input.teamId;
|
|
86
|
+
if (input.serviceUrl) attributes.service_url = input.serviceUrl;
|
|
87
|
+
if (input.scope) attributes.scope = input.scope;
|
|
88
|
+
if (input.membershipType) attributes.membership_type = input.membershipType;
|
|
89
|
+
if (input.userType) attributes[TEAMS_USER_TYPE_ATTR] = input.userType;
|
|
90
|
+
// Root-DM grain stamp (window-tool-gating §D-DM, widened to group DMs): a 1:1 (scope "personal")
|
|
91
|
+
// chat OR a groupChat (⇒ "mpim") is a STANDING seat ⇒ narrowed hands — both are closed multi-party
|
|
92
|
+
// conversations with no channel identity. teamsMode's "channel" branch (scope "channel"/unknown) is
|
|
93
|
+
// unambiguously excluded, so a real team channel is never over-narrowed.
|
|
94
|
+
const teamsScopeMode = teamsMode(input.scope);
|
|
95
|
+
Object.assign(attributes, rootDmGrainAttrs(teamsScopeMode === "dm" || teamsScopeMode === "mpim"));
|
|
96
|
+
return { authenticator: "teams", principalId: input.userId, principalType: "teams-user", attributes };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pull the channel membershipType out of a Bot Framework activity's `channelData`,
|
|
101
|
+
* which (for channel conversations) may carry `{ channel: { membershipType } }`.
|
|
102
|
+
* Defensive: channelData is raw JSON, so everything is probed, never assumed. Note
|
|
103
|
+
* that inbound message activities frequently OMIT membershipType (it's a Graph/Teams
|
|
104
|
+
* API property, not a guaranteed Activity field) — absence is the common case, and
|
|
105
|
+
* teamsVisibility fails closed to private on it.
|
|
106
|
+
*/
|
|
107
|
+
export function teamsMembershipType(channelData: Record<string, unknown> | undefined): string | undefined {
|
|
108
|
+
const ch = (channelData as { channel?: Record<string, unknown> } | undefined)?.channel;
|
|
109
|
+
if (!ch || typeof ch !== "object") return undefined;
|
|
110
|
+
const mt = ch["membershipType"];
|
|
111
|
+
return typeof mt === "string" && mt.length > 0 ? mt : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The acting user's tenant membership type from a Bot Connector roster member record (or a
|
|
116
|
+
* Microsoft Graph `/users/{id}` object) — the authoritative "is this a GUEST?" signal, the
|
|
117
|
+
* Teams analog of Slack's `partnerFactsFromInfo`. Probes `userType` (Graph: "Guest"/"Member")
|
|
118
|
+
* then `userRole` (Bot roster: "guest" for a guest, else a member role), case-insensitively.
|
|
119
|
+
* Pure + defensive (raw JSON): a missing/failed lookup, an empty role, or an unrecognised value
|
|
120
|
+
* all yield `undefined` (no verdict → the existing channel/vouch trust stands, fail-closed
|
|
121
|
+
* unless vouched). Only a positive "guest" flag → "guest" (→ external demotion); a positively
|
|
122
|
+
* recognised member/owner/admin role → "member".
|
|
123
|
+
*
|
|
124
|
+
* What it PROVES: the tenant classifies this account as a guest (external B2B collaborator) —
|
|
125
|
+
* demote. What it does NOT prove: that a "member" is inside YOUR org boundary if the deployment
|
|
126
|
+
* spans multiple tenants; org-internal trust still requires the channel verdict or an operator
|
|
127
|
+
* vouch. It is a demotion-only signal, never an upgrade.
|
|
128
|
+
*/
|
|
129
|
+
export function teamsUserTypeFromMember(info: unknown): "guest" | "member" | undefined {
|
|
130
|
+
if (!info || typeof info !== "object") return undefined; // lookup failed / absent → no verdict
|
|
131
|
+
const o = info as Record<string, unknown>;
|
|
132
|
+
const raw = o["userType"] ?? o["userRole"]; // Graph userType | Bot Connector roster userRole
|
|
133
|
+
if (typeof raw !== "string" || raw.length === 0) return undefined; // field absent → no verdict
|
|
134
|
+
const v = raw.toLowerCase();
|
|
135
|
+
if (v === "guest") return "guest";
|
|
136
|
+
if (v === "member" || v === "user" || v === "owner" || v === "admin" || v === "organizer") return "member";
|
|
137
|
+
return undefined; // unrecognised value → no verdict (fail toward the existing lower/fail-closed tier)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Teams conversation scope → session mode, or UNDEFINED when the scope classifies nothing.
|
|
142
|
+
*
|
|
143
|
+
* 🔴 THE UNDEFINED IS THE POINT (OPE-1867). An inbound activity that carries no `conversationType`
|
|
144
|
+
* — or one Teams spells in a way this mapper does not know — tells us nothing about whether the
|
|
145
|
+
* conversation is a room or a 1:1 chat with one person. Answering "channel" there is a DEFAULT, not
|
|
146
|
+
* a reading, and a reader that publishes the surface must be able to tell the two apart: a personal
|
|
147
|
+
* chat reported as a room names that individual's conversation id in a report posted into a room.
|
|
148
|
+
* {@link teamsMode} applies the default for every reader that needs a usable value; this function
|
|
149
|
+
* is what the identity mapper hands the binding, so the provenance survives.
|
|
150
|
+
*/
|
|
151
|
+
export function teamsResolvedMode(scope: string | undefined): SessionMode | undefined {
|
|
152
|
+
if (scope === "personal") return "dm"; // a 1:1 chat with the bot
|
|
153
|
+
if (scope === "groupChat") return "mpim"; // an ad-hoc group chat
|
|
154
|
+
if (scope === "channel") return "channel"; // a team channel
|
|
155
|
+
return undefined; // "unknown" / unrecognised → nothing classified this conversation
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Teams conversation scope → session mode, GAP-FILLED to `channel` when nothing classifies it.
|
|
159
|
+
* Visibility, trust and the seat-grain stamps all need a definite value and all fail closed on
|
|
160
|
+
* their own terms, so the default is right for them. A reader that PUBLISHES the surface wants
|
|
161
|
+
* {@link teamsResolvedMode} instead. */
|
|
162
|
+
export function teamsMode(scope: string | undefined): SessionMode {
|
|
163
|
+
return teamsResolvedMode(scope) ?? "channel";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Teams facts → visibility. FAIL CLOSED, mirroring slackVisibility: anything we
|
|
168
|
+
* can't positively confirm as a team-wide (standard) channel is treated as
|
|
169
|
+
* `private`, so a surface's learnings never leak up to shared memory on an
|
|
170
|
+
* under-specified event. Personal/group chats are never public surfaces. For a team
|
|
171
|
+
* channel, only `membershipType === "standard"` (the org/team-wide channel everyone
|
|
172
|
+
* on the team can see) yields `public`; `private`/`shared`/unknown → `private`.
|
|
173
|
+
* (Inbound activities usually DON'T carry membershipType, so the fail-closed branch
|
|
174
|
+
* is the normal case for channels — private is the safe default.)
|
|
175
|
+
*/
|
|
176
|
+
export function teamsVisibility(
|
|
177
|
+
scope: string | undefined,
|
|
178
|
+
membershipType: string | undefined,
|
|
179
|
+
mode: SessionMode,
|
|
180
|
+
): Visibility {
|
|
181
|
+
// 1:1 and group chats are never "public" surfaces.
|
|
182
|
+
if (mode === "dm" || mode === "mpim") return "private";
|
|
183
|
+
if (scope === "personal" || scope === "groupChat") return "private";
|
|
184
|
+
if (membershipType === "standard") return "public"; // an explicit team-wide channel
|
|
185
|
+
return "private"; // private/shared/unknown channel → fail closed
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Teams membership → trust (memory gate). FAIL CLOSED — only POSITIVE facts yield a
|
|
190
|
+
* verdict. A `shared` channel is a Teams Shared Channel — it can include people from
|
|
191
|
+
* OTHER tenants/orgs (the Teams analog of Slack Connect) — so it's `external` and gets
|
|
192
|
+
* no shared memory. A channel positively identified as `standard`/`private` AND carrying
|
|
193
|
+
* its tenant id is in-tenant → `internal`. Personal/group chats are personal silos
|
|
194
|
+
* (gate N/A → internal; bindFrom's DM-partner gate still fails an unverified DM partner
|
|
195
|
+
* closed). ANYTHING ELSE — absent membershipType (the common case: inbound activities
|
|
196
|
+
* routinely omit it), an unrecognised value, or a missing tenant id — returns
|
|
197
|
+
* `undefined`, so bindFrom FAILS CLOSED to `external` (shared/org memory gated off)
|
|
198
|
+
* unless the operator vouches for the deployment with OE_TRUST=internal — the same
|
|
199
|
+
* layer Discord/Linear use. An ambiguous Teams event must never reach the
|
|
200
|
+
* shared org brain.
|
|
201
|
+
*
|
|
202
|
+
* GUEST DETECTION (OPE-60): a `userType` positively resolved to "guest" (a cross-org / B2B
|
|
203
|
+
* collaboration account inside the tenant, via the cached roster lookup) DEMOTES to `external`
|
|
204
|
+
* FIRST — ahead of every other branch, including the personal-silo and standard-channel
|
|
205
|
+
* `internal` verdicts and the operator vouch (bindFrom's precedence gives a mapper's own
|
|
206
|
+
* `external` verdict priority over the vouch). This is exactly how Slack demotes a restricted
|
|
207
|
+
* DM partner. An absent/unknown `userType` (the common case — no lookup, or a lookup that
|
|
208
|
+
* couldn't confirm) leaves the existing verdict intact, so behaviour is unchanged unless a
|
|
209
|
+
* guest is positively detected.
|
|
210
|
+
*/
|
|
211
|
+
export function teamsTrust(
|
|
212
|
+
scope: string | undefined,
|
|
213
|
+
membershipType: string | undefined,
|
|
214
|
+
mode: SessionMode,
|
|
215
|
+
tenantId?: string,
|
|
216
|
+
userType?: string,
|
|
217
|
+
): Trust | undefined {
|
|
218
|
+
if (userType === "guest") return "external"; // positively-detected guest → demote (wins over vouch)
|
|
219
|
+
if (mode === "dm" || mode === "mpim") return "internal"; // personal silo; gate N/A
|
|
220
|
+
if (scope === "personal" || scope === "groupChat") return "internal";
|
|
221
|
+
if (membershipType === "shared") return "external"; // cross-tenant Shared Channel
|
|
222
|
+
if (tenantId && (membershipType === "standard" || membershipType === "private")) {
|
|
223
|
+
return "internal"; // positively-identified in-tenant channel
|
|
224
|
+
}
|
|
225
|
+
return undefined; // unverifiable membership/tenant → bindFrom fails closed to external
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Local copies of the binding's unions (kept eve-free + import-cycle-free). */
|
|
229
|
+
type Visibility = "public" | "private";
|
|
230
|
+
type Trust = "internal" | "external";
|
|
231
|
+
|
|
232
|
+
// --- the identity MAPPER (moved out of core identity/binding.ts in OPE-114 stage 4) ---------
|
|
233
|
+
// Interprets the raw facts the Teams channel stowed in auth attributes:
|
|
234
|
+
// tenant_id, conversation_id, channel_id, team_id, service_url,
|
|
235
|
+
// scope ("personal"|"groupChat"|"channel"|"unknown"),
|
|
236
|
+
// membership_type ("standard"|"private"|"shared", when known), user_id.
|
|
237
|
+
// workspace = tenant (the org boundary); surface = conversation. mode/visibility/trust
|
|
238
|
+
// interpretation lives above (teamsMode/teamsVisibility/teamsTrust — the one tested place), same
|
|
239
|
+
// split as Slack. Trust FAILS CLOSED: teamsTrust returns a verdict only on POSITIVE facts (a shared
|
|
240
|
+
// channel → external; a standard/private channel with a tenant id → internal) and `undefined`
|
|
241
|
+
// otherwise, so bindFrom binds an ambiguous Teams event `external` (shared/org memory gated off)
|
|
242
|
+
// unless the operator vouches with OE_TRUST=internal — the same layer Discord/Linear use.
|
|
243
|
+
//
|
|
244
|
+
// The two attribute helpers are LOCAL COPIES of core binding.ts's module-private probes (attr /
|
|
245
|
+
// authPrincipalId) — deliberately not barrel ports (the same posture notion's bindNotion took): they
|
|
246
|
+
// are three-line structural reads over the public BindableContext shape, and a mapper author outside
|
|
247
|
+
// core reads its own platform's attributes itself.
|
|
248
|
+
|
|
249
|
+
type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
|
|
250
|
+
|
|
251
|
+
function teamsAuthOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
|
|
252
|
+
return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function teamsAttr(a: AuthAttrs, keys: readonly string[]): string | undefined {
|
|
256
|
+
if (!a) return undefined;
|
|
257
|
+
for (const k of keys) {
|
|
258
|
+
const v = a[k];
|
|
259
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
260
|
+
if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function teamsAuthPrincipalId(ctx: BindableContext): string | undefined {
|
|
266
|
+
const pid = teamsAuthOf(ctx)?.principalId;
|
|
267
|
+
return pid && pid.length > 0 ? pid : undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The trust-registry GROUP rung for a Teams TEAM (OPE-490), or `undefined` when this activity has
|
|
272
|
+
* no team (a personal or group chat) or the id is not a safe key segment.
|
|
273
|
+
*
|
|
274
|
+
* FAIL-CLOSED and REFUSING, never repairing: an id that would need sanitising contributes no rung
|
|
275
|
+
* at all. Repairing it would risk composing a segment that collides with a real key, and a missing
|
|
276
|
+
* rung is simply the pre-OPE-490 behaviour.
|
|
277
|
+
*
|
|
278
|
+
* Exported for the identity test, which pins the marker spelling — the spelling is a config
|
|
279
|
+
* contract an operator types into `agent/identity/surfaces.ts`, so it must not drift silently.
|
|
280
|
+
*/
|
|
281
|
+
export function teamsTrustGroup(teamId: string | undefined): { readonly trustGroup: string } | undefined {
|
|
282
|
+
if (teamId === undefined || !/^[a-zA-Z0-9._-]+$/.test(teamId)) return undefined;
|
|
283
|
+
return { trustGroup: `team:${teamId}` };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export const bindTeams: IdentityMapper = (ctx): RawIdentity => {
|
|
287
|
+
const a = teamsAuthOf(ctx)?.attributes;
|
|
288
|
+
const scope = teamsAttr(a, ["scope"]);
|
|
289
|
+
const membershipType = teamsAttr(a, ["membership_type", "membershipType"]);
|
|
290
|
+
const userType = teamsAttr(a, ["user_type", "userType"]); // "guest" | "member" — cached roster probe (teams-roster.ts)
|
|
291
|
+
const mode = teamsMode(scope);
|
|
292
|
+
const conversationId = teamsAttr(a, ["conversation_id", "conversationId"]);
|
|
293
|
+
const serviceUrl = teamsAttr(a, ["service_url", "serviceUrl"]);
|
|
294
|
+
const tenantId = teamsAttr(a, ["tenant_id", "tenantId"]);
|
|
295
|
+
return {
|
|
296
|
+
workspace: tenantId,
|
|
297
|
+
surface: conversationId,
|
|
298
|
+
// A channel-scoped Teams CONVERSATION names one reply chain (the conversation id carries
|
|
299
|
+
// `;messageid=<root>`), so the listable/vouchable/spend-cappable container is the Teams
|
|
300
|
+
// CHANNEL — domain membership resolves at the channel grain, mirroring the Discord
|
|
301
|
+
// thread→parent-channel precedent (bindDiscord.domainSurface). Absent (personal/groupChat,
|
|
302
|
+
// or an activity with no channel id) → fall back to the conversation grain, today's exact
|
|
303
|
+
// behaviour (bindFrom only surfaces domainSurface when it differs from surface).
|
|
304
|
+
domainSurface: teamsAttr(a, ["channel_id", "channelId"]),
|
|
305
|
+
// 🔴 THE RESOLVED mode, not the gap-filled one (OPE-1867). An activity with no recognised
|
|
306
|
+
// `conversationType` contributes NO mode here; bindFrom still gap-fills `channel`, so every
|
|
307
|
+
// reader below and downstream is unchanged — but the binding now says the value was a default,
|
|
308
|
+
// and the one reader that publishes a surface refuses it. `visibility` and `trust` keep taking
|
|
309
|
+
// the gap-filled `mode` local above, which is what their own fail-closed rules are written for.
|
|
310
|
+
mode: teamsResolvedMode(scope),
|
|
311
|
+
// 🔴 THE TEAM RUNG (OPE-490). A Teams channel keys as `teams/<tenant>/<conversationId>` and the
|
|
312
|
+
// TEAM is nowhere in that key, so an operator could not declare a whole team internal or public
|
|
313
|
+
// — only enumerate its channels. This contributes the team as a MARKED group segment, which the
|
|
314
|
+
// trust registry matches as a rung BETWEEN the tenant and the surface
|
|
315
|
+
// (identity/surface-key-grammar.ts). An operator then writes:
|
|
316
|
+
//
|
|
317
|
+
// defineSurface({ key: "teams/<tenantId>/team:<teamId>", trust: "internal" })
|
|
318
|
+
//
|
|
319
|
+
// The vendor word "team" lives HERE, in the Teams bundle, never in core — core composes
|
|
320
|
+
// `<platform>/<tenant>/<segment>` and validates only the `<marker>:<id>` shape.
|
|
321
|
+
//
|
|
322
|
+
// It changes NO domain semantics: the surface key, the domain key and every memory/spend/audit
|
|
323
|
+
// grain are byte-identical. `teamId` is present only on a CHANNEL activity, so a personal or
|
|
324
|
+
// group chat contributes no rung and resolves exactly as before.
|
|
325
|
+
...(teamsTrustGroup(teamsAttr(a, ["team_id", "teamId"])) ?? {}),
|
|
326
|
+
visibility: teamsVisibility(scope, membershipType, mode),
|
|
327
|
+
trust: teamsTrust(scope, membershipType, mode, tenantId, userType),
|
|
328
|
+
principalId: teamsAuthPrincipalId(ctx) ?? teamsAttr(a, ["user_id", "userId"]),
|
|
329
|
+
// 🔴 THE ENGAGEMENT-REGISTRY KEY (OPE-1500), the Discord precedent (`bindDiscord.engagementKey`)
|
|
330
|
+
// in Teams' own vocabulary. It is the CONVERSATION id, which names the REPLY CHAIN in a team
|
|
331
|
+
// channel (the id carries `;messageid=<root>`) and the CHAT in a group chat — the two containers
|
|
332
|
+
// an engagement can be kept per on this surface. The same id is this binding's `surface`, so
|
|
333
|
+
// `teams/<tenant>/<conversationId>` + `<conversationId>` addresses the exact row the seam's wake
|
|
334
|
+
// gate reads back (channels/teams-engagement.ts). Publishing it here is what lets `stand_down`
|
|
335
|
+
// mark that row; without it the verb found no anchor and refused on every Teams turn.
|
|
336
|
+
//
|
|
337
|
+
// NEVER ON A 1:1 CHAT, and the omission is the honest half — Discord's DM reasoning exactly. A
|
|
338
|
+
// personal chat is answered unconditionally (the front's DM divert bypasses the engagement gate
|
|
339
|
+
// by design), so a mark on it would suppress nothing, and `stand_down` says it could not stop
|
|
340
|
+
// rather than writing a row that changes nothing.
|
|
341
|
+
...(teamsMode(scope) !== "dm" && conversationId ? { engagementKey: conversationId } : {}),
|
|
342
|
+
// 🔴 THE ROOM-GRAIN STOP ANCHOR (OPE-1511), and Teams is the only surface that has one. The
|
|
343
|
+
// single-window front's Leg A wakes on ANY message in a vouched CHANNEL, including a reply chain
|
|
344
|
+
// no @mention ever engaged — a room-grain wake, which the conversation-grain key above cannot
|
|
345
|
+
// address. Publishing the CHANNEL id here is what lets `stand_down` reach the room row that wake
|
|
346
|
+
// stores (channels/teams-engagement.ts), instead of refusing on a wake the person can plainly
|
|
347
|
+
// see. The verb marks it only when the conversation grain held no row, so an ordinary
|
|
348
|
+
// thread-grain stop is unchanged.
|
|
349
|
+
//
|
|
350
|
+
// CHANNEL SCOPE ONLY. A personal or group chat has no Leg A — the front diverts both into their
|
|
351
|
+
// own DM window above `planFor` — so there is no room wake to stop and a mark here would be a
|
|
352
|
+
// write nobody reads. The value is the raw channel id, for the audit trail; the row's address is
|
|
353
|
+
// derived from the partition on both sides, so no spelling can drift.
|
|
354
|
+
...(mode === "channel" && teamsAttr(a, ["channel_id", "channelId"])
|
|
355
|
+
? { roomEngagementKey: teamsAttr(a, ["channel_id", "channelId"]) }
|
|
356
|
+
: {}),
|
|
357
|
+
// THE STOP REGISTRY'S ADDRESS FOR THIS TURN (OPE-898) — the CONVERSATION, which on a Teams
|
|
358
|
+
// channel means the REPLY CHAIN, not the channel. A `messageReaction` activity carries
|
|
359
|
+
// `conversation.id`, so the reply chain is the only container a stop trigger can name.
|
|
360
|
+
//
|
|
361
|
+
// 🔴 IT IS NOT `target.channelId`, even though this binding has one. That field holds the Teams
|
|
362
|
+
// CHANNEL id, which is a coarser room than the turn ran in and is absent on a personal chat —
|
|
363
|
+
// so keying the registry on it would both over-reach and leave every DM turn unstoppable.
|
|
364
|
+
// `anchorChannelId` wins over `target.channelId` in hooks/turn-anchor.ts for exactly this case.
|
|
365
|
+
...(conversationId ? { anchorChannelId: conversationId } : {}),
|
|
366
|
+
// Proactive post-back needs the Bot Connector CONVERSATION REFERENCE (serviceUrl +
|
|
367
|
+
// conversation id), not a thread anchor — Teams can't cold-address a chat by AAD
|
|
368
|
+
// user id (Bot Framework requires a reference captured on a prior inbound turn,
|
|
369
|
+
// which is exactly what this is: every field comes off THIS inbound activity).
|
|
370
|
+
// `conversationType` (the activity's scope: personal/groupChat/channel) rides along
|
|
371
|
+
// so a routine FIRE can re-derive mode without guessing from id shapes — the
|
|
372
|
+
// dispatcher (@open-everywhere/provider-teams register.ts resolveTeamsTarget) stamps it
|
|
373
|
+
// back as the `scope` attribute this very mapper reads.
|
|
374
|
+
target:
|
|
375
|
+
conversationId && serviceUrl
|
|
376
|
+
? {
|
|
377
|
+
serviceUrl,
|
|
378
|
+
conversationId,
|
|
379
|
+
tenantId,
|
|
380
|
+
channelId: teamsAttr(a, ["channel_id", "channelId"]),
|
|
381
|
+
teamId: teamsAttr(a, ["team_id", "teamId"]),
|
|
382
|
+
conversationType: scope,
|
|
383
|
+
// The creator's Azure AD object id (OPE-522) — carried on the STORED target so a routine
|
|
384
|
+
// created from this turn can be re-checked at fire time (routines/fire-eligibility.ts's
|
|
385
|
+
// Teams leg): Graph's `/users/{id}` and a channel roster's `userId` are both keyed on
|
|
386
|
+
// this value, a DIFFERENT identifier from the Bot Framework `from.id` this mapper's own
|
|
387
|
+
// `principalId` carries (see buildTeamsAuth's `aad_object_id` attribute, captured above).
|
|
388
|
+
// Absent when the inbound activity didn't carry one (e.g. no Teams AAD linkage) — a
|
|
389
|
+
// routine created from such a turn simply has no Teams eligibility leg to run, matching
|
|
390
|
+
// every other "cannot check" case in this module family.
|
|
391
|
+
aadObjectId: teamsAttr(a, ["aad_object_id", "aadObjectId"]),
|
|
392
|
+
}
|
|
393
|
+
: undefined,
|
|
394
|
+
};
|
|
395
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// THE TEAMS ARM OF THE HITL-ANSWER ADMISSION (OPE-574).
|
|
2
|
+
//
|
|
3
|
+
// 🔴 THE TEAMS PREMISE IS THE OPPOSITE OF SLACK'S, AND THE TICKET SAID OTHERWISE. eve's Teams
|
|
4
|
+
// channel resolves the hook as `onInputResponse ?? (onMessage === undefined ? defaultOnInputResponse
|
|
5
|
+
// : rejectInput)` and `rejectInput` returns `null` for every submission. OE authors `onMessage`
|
|
6
|
+
// (channels/teams.ts), so on Teams NOBODY could answer a HITL card — every Adaptive-Card submit and
|
|
7
|
+
// every card-valued message was dropped, with no post, no row and no log line. The Slack hole
|
|
8
|
+
// (anyone may answer) does not exist here; a silent dead end did. Authoring this hook closes both
|
|
9
|
+
// at once: it opens the path AND governs it. The two eve behaviours are pinned against the
|
|
10
|
+
// installed dist by `packages/core/scripts/eve-install-integrity-test.ts`.
|
|
11
|
+
//
|
|
12
|
+
// LEG B HAS TEETH HERE. `resolveTeamsUserType` reads the Bot Connector's conversation-member
|
|
13
|
+
// record and returns a POSITIVE `guest`/`member` verdict per person — the same probe the inbound
|
|
14
|
+
// message path already runs on every activity — so a guest answering from an ordinary internal
|
|
15
|
+
// channel is refused. That was the residual approvals/clicker-floor.ts named before OPE-589; Slack
|
|
16
|
+
// closed the same gap with its own probe (`slackClickerIsGuest`), so this is no longer a
|
|
17
|
+
// Teams-only capability. An unresolvable probe returns `undefined` and the leg is INERT: "we could
|
|
18
|
+
// not ask" never becomes a refusal.
|
|
19
|
+
//
|
|
20
|
+
// 🔴 FAIL-OPEN ON A THROW. eve logs a thrown hook and drops the answer, which would put Teams back
|
|
21
|
+
// where it started. Anything unexpected therefore returns the default auth and says so in the log.
|
|
22
|
+
//
|
|
23
|
+
// 🔴 SPEND GATE (OPE-588), ahead of the room/guest admission below. This card action RESUMES a
|
|
24
|
+
// parked turn: a decision to spend more, taken through a button, on a scope nobody asked whether it
|
|
25
|
+
// could afford. Every ordinary ingress door consults the ONE shared spend gate before it dispatches
|
|
26
|
+
// a turn (spend/ingress-gate.ts); eve's own Teams invoke route calls `onInputResponse` and then
|
|
27
|
+
// `.respond()` directly, with no call site of that gate anywhere on the seam — verified against the
|
|
28
|
+
// installed eve/dist. A decline returns `null`, the same reject-the-answer outcome a room/guest
|
|
29
|
+
// refusal already leaves — the request stays PENDING.
|
|
30
|
+
|
|
31
|
+
import type { TeamsContext, TeamsInputResponseResult, TeamsInvokeActivity, TeamsMessageActivity } from "eve/channels/teams";
|
|
32
|
+
import {
|
|
33
|
+
admitInputResponse,
|
|
34
|
+
ingressSpendGate,
|
|
35
|
+
resolvePartition,
|
|
36
|
+
type BindableContext,
|
|
37
|
+
type IngressSpendGateInput,
|
|
38
|
+
type InputResponseAdmission,
|
|
39
|
+
type InputResponseScope,
|
|
40
|
+
type SpendGateDecision,
|
|
41
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
42
|
+
import { buildTeamsAuth, teamsMembershipType } from "./teams-identity.js";
|
|
43
|
+
import { resolveTeamsUserType, teamsHandleRosterProbe } from "./teams-roster.js";
|
|
44
|
+
|
|
45
|
+
export interface TeamsInputResponseDeps {
|
|
46
|
+
/** The core admission. Injected in tests; the live default is the shared decision. */
|
|
47
|
+
readonly admit?: (scope: InputResponseScope) => Promise<InputResponseAdmission>;
|
|
48
|
+
/** Positive per-member verdict for leg B. Live default is the Bot Connector member probe. */
|
|
49
|
+
readonly userType?: (activity: TeamsInvokeActivity | TeamsMessageActivity) => Promise<"guest" | "member" | undefined>;
|
|
50
|
+
/** Render the refusal. Teams has no ephemeral primitive on this handle, so it is a thread post. */
|
|
51
|
+
readonly notify?: (text: string) => Promise<void>;
|
|
52
|
+
/** The shared spend-admission decision (OPE-588). Injected in tests; the live default is
|
|
53
|
+
* `ingressSpendGate` (spend/ingress-gate.ts) — the SAME function every ordinary ingress door
|
|
54
|
+
* consults. */
|
|
55
|
+
readonly spendGate?: (input: IngressSpendGateInput) => Promise<SpendGateDecision>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Admit or refuse one eve-owned HITL answer submitted through Teams.
|
|
60
|
+
*
|
|
61
|
+
* Returns `{ auth }` on an allow — the same auth shape `onMessage` builds, so an admitted answer
|
|
62
|
+
* resumes the parked turn under OE's real Teams identity rather than eve's thinner default.
|
|
63
|
+
* Returns `null` on a refusal, which leaves the request PENDING (eve has no cancel on this seam).
|
|
64
|
+
*/
|
|
65
|
+
export async function admitTeamsInputResponse(
|
|
66
|
+
ctx: TeamsContext,
|
|
67
|
+
activity: TeamsInvokeActivity | TeamsMessageActivity,
|
|
68
|
+
deps: TeamsInputResponseDeps = {},
|
|
69
|
+
): Promise<TeamsInputResponseResult> {
|
|
70
|
+
const probe = deps.userType ?? ((a) => resolveTeamsUserType(teamsHandleRosterProbe(ctx.teams), a.conversation.id, a.from.id));
|
|
71
|
+
let auth: ReturnType<typeof buildTeamsAuth> | undefined;
|
|
72
|
+
try {
|
|
73
|
+
const userType = await probe(activity);
|
|
74
|
+
auth = buildTeamsAuth({
|
|
75
|
+
userId: activity.from.id,
|
|
76
|
+
aadObjectId: activity.from.aadObjectId,
|
|
77
|
+
userName: activity.from.name,
|
|
78
|
+
tenantId: activity.tenantId ?? activity.conversation.tenantId,
|
|
79
|
+
conversationId: activity.conversation.id,
|
|
80
|
+
channelId: activity.teamsChannelId,
|
|
81
|
+
teamId: activity.teamId,
|
|
82
|
+
serviceUrl: activity.serviceUrl,
|
|
83
|
+
scope: activity.scope,
|
|
84
|
+
membershipType: teamsMembershipType(activity.channelData),
|
|
85
|
+
userType,
|
|
86
|
+
});
|
|
87
|
+
const bindable = {
|
|
88
|
+
channel: { kind: "teams" },
|
|
89
|
+
session: { auth: { current: auth } },
|
|
90
|
+
} as BindableContext;
|
|
91
|
+
const p = resolvePartition(bindable);
|
|
92
|
+
|
|
93
|
+
// SPEND GATE (OPE-588) — ahead of the room/guest admission below (see the header note). A
|
|
94
|
+
// decline returns `null` immediately, before the room/guest floor runs: over budget outranks
|
|
95
|
+
// identity, the same ordering `channels/unified.ts`'s `windowSpendGate` takes ahead of its own
|
|
96
|
+
// `resolveInboundAnswer`.
|
|
97
|
+
const spendGate = deps.spendGate ?? ingressSpendGate;
|
|
98
|
+
const spendVerdict = await spendGate({
|
|
99
|
+
bindable,
|
|
100
|
+
door: "answer",
|
|
101
|
+
principalId: activity.from.id,
|
|
102
|
+
post: async (text) => { await ctx.thread.post(text); },
|
|
103
|
+
});
|
|
104
|
+
if (spendVerdict === "declined") return null;
|
|
105
|
+
|
|
106
|
+
const verdict = await (deps.admit ?? admitInputResponse)({
|
|
107
|
+
org: p.org,
|
|
108
|
+
platform: p.platform,
|
|
109
|
+
workspace: p.workspace,
|
|
110
|
+
surface: p.surface,
|
|
111
|
+
surfaceKey: p.surfaceKey,
|
|
112
|
+
principalId: activity.from.id,
|
|
113
|
+
// `responses` is deliberately ABSENT: eve decodes the submission AFTER this hook returns, so
|
|
114
|
+
// Teams cannot know the count. A zero here would read as a measured value in the audit row.
|
|
115
|
+
...(userType === undefined ? {} : { answererIsGuest: userType === "guest" }),
|
|
116
|
+
});
|
|
117
|
+
if (verdict.ok) return { auth };
|
|
118
|
+
const notify = deps.notify ?? (async (text: string): Promise<void> => { await ctx.thread.post(text); });
|
|
119
|
+
await notify(verdict.notice).catch((err: unknown) => {
|
|
120
|
+
console.error(`[open-everywhere:teams] HITL refusal notice failed to post: ${String(err)}`);
|
|
121
|
+
});
|
|
122
|
+
return null;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`[open-everywhere:teams] HITL answer admission failed softly — accepting the answer: ${String(err)}`);
|
|
125
|
+
// FAIL-OPEN with whatever auth we managed to build. `null` here would restore eve's silent
|
|
126
|
+
// `rejectInput`, which is the bug this module exists to close.
|
|
127
|
+
return { auth: auth ?? null };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// teams-message-ops — Teams's SurfaceMessageOps (OPE-899): the ancillary message ops core features
|
|
2
|
+
// dispatch through channels/surface-message-ops.ts. Before this file, Teams registered none, so the
|
|
3
|
+
// stop acknowledgement (turn-anchor.ts's `postSurfaceText`) resolved nothing on a Teams turn — the
|
|
4
|
+
// stop control worked (teams-stop.ts, the `angry` reaction), it was just invisible: a person who
|
|
5
|
+
// reacted saw the agent go quiet and read nothing.
|
|
6
|
+
//
|
|
7
|
+
// - postText → the SAME Bot-Framework send/reply the mid-turn post arm uses
|
|
8
|
+
// (teams-post-arm.ts's `dispatchTeamsReply`, exported for this file — one
|
|
9
|
+
// mint/send implementation, never a second copy).
|
|
10
|
+
// - editMessage → the SAME update-activity call `edit_reply` performs (`editTeamsMessage`).
|
|
11
|
+
// - NO `postFiles` (register.ts already records: "NO attachment resolver — the Teams surface
|
|
12
|
+
// carries no attachment plane today"). The optional tier exists exactly for this shape.
|
|
13
|
+
// - NO `setReaction` (register.ts: "NO reaction surface — Teams has no integration reaction
|
|
14
|
+
// primitive"). The `angry` reaction (teams-stop.ts) IS Teams's stop affordance; there is no
|
|
15
|
+
// second glyph to place beside it.
|
|
16
|
+
//
|
|
17
|
+
// THE PROBLEM THIS FILE SOLVES, IN ONE SENTENCE: `SurfaceMessageOps.postText`/`editMessage` are
|
|
18
|
+
// handed only a bare `channelId` (core's `channelId` is Teams's conversation id — see
|
|
19
|
+
// `turn-anchor.ts`'s `turnAnchorAddress`), never a `serviceUrl`, and a Bot-Framework write needs
|
|
20
|
+
// BOTH. `teams-conversation-ref.ts`'s `conversation`-grain address (OPE-899's addition to that
|
|
21
|
+
// module) is the lookup that answers it: every inbound activity, on every scope, stamps a row keyed
|
|
22
|
+
// on its EXACT conversation id, so the reply chain a channel turn anchored on is resolvable here by
|
|
23
|
+
// that same id.
|
|
24
|
+
//
|
|
25
|
+
// `opts.replyToMessageId` on `postText` threads the post via `replyToActivityId` — the SAME field
|
|
26
|
+
// the mid-turn reply arm anchors on. `turn-anchor.ts`'s own cancel-note call passes no such option
|
|
27
|
+
// (a top-level post, like every other surface's cancel note today), so this is exercised by other
|
|
28
|
+
// SurfaceMessageOps consumers (the approval decision notice), not by the stop path itself.
|
|
29
|
+
|
|
30
|
+
import type { SurfaceMessageOps } from "@open-everywhere/core/agent/adapter-api.js";
|
|
31
|
+
import { dispatchTeamsReply, editTeamsMessage, type TeamsTarget } from "./teams-post-arm.js";
|
|
32
|
+
import { teamsConversationRef } from "./teams-conversation-ref.js";
|
|
33
|
+
import { restoreTeamsConversationId } from "./teams-id-restore.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The stored `conversation`-grain reference for `channelId`, or a loud, honest refusal — never a
|
|
37
|
+
* guessed target. Mirrors `github-message-ops.ts`'s `requireContainer`.
|
|
38
|
+
*
|
|
39
|
+
* 🔴 IT RESTORES THE RAW ID FIRST (OPE-1078). `channelId` reaches here in one of two spellings: raw,
|
|
40
|
+
* from a live turn's own anchor, or the SURFACE-KEY spelling, from a granted `teams:channel/<id>`
|
|
41
|
+
* address — the only spelling the resource-grant grammar admits. `restoreTeamsConversationId` is the
|
|
42
|
+
* identity on the first and the index lookup on the second, so both land on the same reference. The
|
|
43
|
+
* refusal below still names the id the CALLER gave, which is the one an operator can act on.
|
|
44
|
+
*/
|
|
45
|
+
async function requireConversationRef(channelId: string): Promise<{ readonly serviceUrl: string; readonly conversationId: string }> {
|
|
46
|
+
const ref = await teamsConversationRef(await restoreTeamsConversationId(channelId));
|
|
47
|
+
if (!ref) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Teams surface message ops: no conversation reference for "${channelId}" — this deployment has never seen an inbound activity on that id.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return ref;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const teamsMessageOps: SurfaceMessageOps = {
|
|
56
|
+
platform: "teams",
|
|
57
|
+
|
|
58
|
+
/** Post through the mid-turn reply transport, resolving `serviceUrl` from the stored conversation
|
|
59
|
+
* reference. `opts?.replyToMessageId` threads the post — see the header. */
|
|
60
|
+
async postText(channelId, text, opts) {
|
|
61
|
+
const { serviceUrl, conversationId } = await requireConversationRef(channelId);
|
|
62
|
+
const target: TeamsTarget = {
|
|
63
|
+
platform: "teams",
|
|
64
|
+
serviceUrl,
|
|
65
|
+
conversationId,
|
|
66
|
+
...(opts?.replyToMessageId ? { replyToActivityId: opts.replyToMessageId } : {}),
|
|
67
|
+
};
|
|
68
|
+
return dispatchTeamsReply(target, text);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/** Update the given activity in place — the same transport `edit_reply` performs on the reply
|
|
72
|
+
* spine, resolving `serviceUrl` the same way `postText` does. It sends the RESOLVED conversation
|
|
73
|
+
* id, never the caller's, for the reason `requireConversationRef` states: the caller's may be the
|
|
74
|
+
* surface-key spelling, and the Connector accepts only the raw id. */
|
|
75
|
+
async editMessage(channelId, messageId, text) {
|
|
76
|
+
const { serviceUrl, conversationId } = await requireConversationRef(channelId);
|
|
77
|
+
await editTeamsMessage({ platform: "teams", channelId: conversationId, serviceUrl }, messageId, text);
|
|
78
|
+
},
|
|
79
|
+
};
|