@open-everywhere/provider-teams 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +46 -2
  4. package/agent/channels/teams-bot-name.ts +162 -0
  5. package/agent/channels/teams-catch-up.ts +244 -0
  6. package/agent/channels/teams-connector.ts +183 -0
  7. package/agent/channels/teams-conversation-ref.ts +398 -0
  8. package/agent/channels/teams-directory.ts +193 -0
  9. package/agent/channels/teams-engagement.ts +391 -0
  10. package/agent/channels/teams-fire-eligibility.ts +152 -0
  11. package/agent/channels/teams-front.ts +1404 -0
  12. package/agent/channels/teams-graph-boundary.ts +178 -0
  13. package/agent/channels/teams-graph-team-id.ts +106 -0
  14. package/agent/channels/teams-id-restore.ts +131 -0
  15. package/agent/channels/teams-identity.ts +395 -0
  16. package/agent/channels/teams-input-response.ts +129 -0
  17. package/agent/channels/teams-message-ops.ts +79 -0
  18. package/agent/channels/teams-origin-codec.ts +104 -0
  19. package/agent/channels/teams-post-arm.ts +323 -0
  20. package/agent/channels/teams-reaction-feedback.ts +127 -0
  21. package/agent/channels/teams-read.ts +510 -0
  22. package/agent/channels/teams-roster.ts +136 -0
  23. package/agent/channels/teams-stop.ts +319 -0
  24. package/agent/channels/teams-supervisor.ts +638 -0
  25. package/agent/channels/teams.ts +270 -0
  26. package/agent/contributions.ts +31 -0
  27. package/agent/docs-generated.ts +15 -0
  28. package/agent/ops.ts +129 -0
  29. package/agent/provider-events.ts +69 -0
  30. package/agent/register.ts +219 -0
  31. package/agent/surface-parity.ts +112 -0
  32. package/dist/agent/channels/teams-bot-name.d.ts +36 -0
  33. package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
  34. package/dist/agent/channels/teams-bot-name.js +141 -0
  35. package/dist/agent/channels/teams-bot-name.js.map +1 -0
  36. package/dist/agent/channels/teams-catch-up.d.ts +53 -0
  37. package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
  38. package/dist/agent/channels/teams-catch-up.js +221 -0
  39. package/dist/agent/channels/teams-catch-up.js.map +1 -0
  40. package/dist/agent/channels/teams-connector.d.ts +57 -0
  41. package/dist/agent/channels/teams-connector.d.ts.map +1 -0
  42. package/dist/agent/channels/teams-connector.js +132 -0
  43. package/dist/agent/channels/teams-connector.js.map +1 -0
  44. package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
  45. package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
  46. package/dist/agent/channels/teams-conversation-ref.js +368 -0
  47. package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
  48. package/dist/agent/channels/teams-directory.d.ts +11 -0
  49. package/dist/agent/channels/teams-directory.d.ts.map +1 -0
  50. package/dist/agent/channels/teams-directory.js +179 -0
  51. package/dist/agent/channels/teams-directory.js.map +1 -0
  52. package/dist/agent/channels/teams-engagement.d.ts +174 -0
  53. package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
  54. package/dist/agent/channels/teams-engagement.js +336 -0
  55. package/dist/agent/channels/teams-engagement.js.map +1 -0
  56. package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
  57. package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
  58. package/dist/agent/channels/teams-fire-eligibility.js +126 -0
  59. package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
  60. package/dist/agent/channels/teams-front.d.ts +249 -0
  61. package/dist/agent/channels/teams-front.d.ts.map +1 -0
  62. package/dist/agent/channels/teams-front.js +1143 -0
  63. package/dist/agent/channels/teams-front.js.map +1 -0
  64. package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
  65. package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
  66. package/dist/agent/channels/teams-graph-boundary.js +168 -0
  67. package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
  68. package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
  69. package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
  70. package/dist/agent/channels/teams-graph-team-id.js +89 -0
  71. package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
  72. package/dist/agent/channels/teams-id-restore.d.ts +34 -0
  73. package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
  74. package/dist/agent/channels/teams-id-restore.js +123 -0
  75. package/dist/agent/channels/teams-id-restore.js.map +1 -0
  76. package/dist/agent/channels/teams-identity.d.ts +148 -0
  77. package/dist/agent/channels/teams-identity.d.ts.map +1 -0
  78. package/dist/agent/channels/teams-identity.js +336 -0
  79. package/dist/agent/channels/teams-identity.js.map +1 -0
  80. package/dist/agent/channels/teams-input-response.d.ts +23 -0
  81. package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
  82. package/dist/agent/channels/teams-input-response.js +102 -0
  83. package/dist/agent/channels/teams-input-response.js.map +1 -0
  84. package/dist/agent/channels/teams-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/teams-message-ops.js +72 -0
  87. package/dist/agent/channels/teams-message-ops.js.map +1 -0
  88. package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
  89. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/teams-origin-codec.js +99 -0
  91. package/dist/agent/channels/teams-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/teams-post-arm.d.ts +37 -0
  93. package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/teams-post-arm.js +280 -0
  95. package/dist/agent/channels/teams-post-arm.js.map +1 -0
  96. package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
  97. package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
  98. package/dist/agent/channels/teams-reaction-feedback.js +108 -0
  99. package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
  100. package/dist/agent/channels/teams-read.d.ts +127 -0
  101. package/dist/agent/channels/teams-read.d.ts.map +1 -0
  102. package/dist/agent/channels/teams-read.js +424 -0
  103. package/dist/agent/channels/teams-read.js.map +1 -0
  104. package/dist/agent/channels/teams-roster.d.ts +54 -0
  105. package/dist/agent/channels/teams-roster.d.ts.map +1 -0
  106. package/dist/agent/channels/teams-roster.js +121 -0
  107. package/dist/agent/channels/teams-roster.js.map +1 -0
  108. package/dist/agent/channels/teams-stop.d.ts +74 -0
  109. package/dist/agent/channels/teams-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/teams-stop.js +252 -0
  111. package/dist/agent/channels/teams-stop.js.map +1 -0
  112. package/dist/agent/channels/teams-supervisor.d.ts +351 -0
  113. package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/teams-supervisor.js +366 -0
  115. package/dist/agent/channels/teams-supervisor.js.map +1 -0
  116. package/dist/agent/channels/teams.d.ts +5 -0
  117. package/dist/agent/channels/teams.d.ts.map +1 -0
  118. package/dist/agent/channels/teams.js +246 -0
  119. package/dist/agent/channels/teams.js.map +1 -0
  120. package/dist/agent/contributions.d.ts +12 -0
  121. package/dist/agent/contributions.d.ts.map +1 -0
  122. package/dist/agent/contributions.js +31 -0
  123. package/dist/agent/contributions.js.map +1 -0
  124. package/dist/agent/docs-generated.d.ts +3 -0
  125. package/dist/agent/docs-generated.d.ts.map +1 -0
  126. package/dist/agent/docs-generated.js +13 -0
  127. package/dist/agent/docs-generated.js.map +1 -0
  128. package/dist/agent/ops.d.ts +14 -0
  129. package/dist/agent/ops.d.ts.map +1 -0
  130. package/dist/agent/ops.js +122 -0
  131. package/dist/agent/ops.js.map +1 -0
  132. package/dist/agent/provider-events.d.ts +3 -0
  133. package/dist/agent/provider-events.d.ts.map +1 -0
  134. package/dist/agent/provider-events.js +67 -0
  135. package/dist/agent/provider-events.js.map +1 -0
  136. package/dist/agent/register.d.ts +16 -0
  137. package/dist/agent/register.d.ts.map +1 -0
  138. package/dist/agent/register.js +198 -0
  139. package/dist/agent/register.js.map +1 -0
  140. package/dist/agent/surface-parity.d.ts +3 -0
  141. package/dist/agent/surface-parity.d.ts.map +1 -0
  142. package/dist/agent/surface-parity.js +106 -0
  143. package/dist/agent/surface-parity.js.map +1 -0
  144. package/package.json +183 -4
  145. package/tsconfig.build.json +22 -0
  146. package/tsconfig.json +15 -0
@@ -0,0 +1,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
+ };