@open-everywhere/provider-teams 0.4.0 → 0.6.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 (45) hide show
  1. package/agent/app-manifest.ts +4 -2
  2. package/agent/app-package.ts +532 -0
  3. package/agent/approvals/teams-card.ts +189 -0
  4. package/agent/approvals/teams-flow.ts +467 -0
  5. package/agent/channels/teams-declared-target.ts +108 -0
  6. package/agent/channels/teams-front.ts +25 -2
  7. package/agent/docs-generated.ts +1 -1
  8. package/agent/ops.ts +36 -0
  9. package/agent/register.ts +28 -1
  10. package/agent/surface-parity.ts +19 -11
  11. package/dist/agent/app-manifest.d.ts.map +1 -1
  12. package/dist/agent/app-manifest.js +4 -2
  13. package/dist/agent/app-manifest.js.map +1 -1
  14. package/dist/agent/app-package.d.ts +74 -0
  15. package/dist/agent/app-package.d.ts.map +1 -0
  16. package/dist/agent/app-package.js +449 -0
  17. package/dist/agent/app-package.js.map +1 -0
  18. package/dist/agent/approvals/teams-card.d.ts +65 -0
  19. package/dist/agent/approvals/teams-card.d.ts.map +1 -0
  20. package/dist/agent/approvals/teams-card.js +135 -0
  21. package/dist/agent/approvals/teams-card.js.map +1 -0
  22. package/dist/agent/approvals/teams-flow.d.ts +144 -0
  23. package/dist/agent/approvals/teams-flow.d.ts.map +1 -0
  24. package/dist/agent/approvals/teams-flow.js +366 -0
  25. package/dist/agent/approvals/teams-flow.js.map +1 -0
  26. package/dist/agent/channels/teams-declared-target.d.ts +12 -0
  27. package/dist/agent/channels/teams-declared-target.d.ts.map +1 -0
  28. package/dist/agent/channels/teams-declared-target.js +96 -0
  29. package/dist/agent/channels/teams-declared-target.js.map +1 -0
  30. package/dist/agent/channels/teams-front.d.ts +5 -0
  31. package/dist/agent/channels/teams-front.d.ts.map +1 -1
  32. package/dist/agent/channels/teams-front.js +19 -2
  33. package/dist/agent/channels/teams-front.js.map +1 -1
  34. package/dist/agent/docs-generated.js +1 -1
  35. package/dist/agent/docs-generated.js.map +1 -1
  36. package/dist/agent/ops.d.ts.map +1 -1
  37. package/dist/agent/ops.js +31 -1
  38. package/dist/agent/ops.js.map +1 -1
  39. package/dist/agent/register.d.ts.map +1 -1
  40. package/dist/agent/register.js +26 -2
  41. package/dist/agent/register.js.map +1 -1
  42. package/dist/agent/surface-parity.d.ts.map +1 -1
  43. package/dist/agent/surface-parity.js +18 -11
  44. package/dist/agent/surface-parity.js.map +1 -1
  45. package/package.json +21 -3
@@ -0,0 +1,189 @@
1
+ // Pure Teams renderers + the action-data codec for the approval card (OPE-2424) — the Teams sibling
2
+ // of core's card.ts (Slack Block Kit) and the Discord bundle's discord-card.ts. PURE by the same
3
+ // doctrine: no I/O, nothing async. The card is a CODE-RENDERED VIEW of the FROZEN args, never model
4
+ // text. The I/O half (the Connector poster and the invoke handler) lives in teams-flow.ts.
5
+ //
6
+ // Teams differs from Slack and Discord in three ways, and all three are handled here:
7
+ // - The card is an ADAPTIVE CARD attachment on a message activity. The body is TextBlocks, and the
8
+ // buttons are `Action.Execute` actions.
9
+ // - `Action.Execute` (Universal Actions), not `Action.Submit`. A tap arrives as an `invoke` activity
10
+ // named `adaptiveCard/action`, and the HTTP response to that invoke can carry a REPLACEMENT card
11
+ // (the decided render) or a message only the clicker sees (a refusal). That is the same response
12
+ // shape Discord's interaction reply has, so the click path maps one resolution to one response.
13
+ // - The payload rides the action's `data` object, not an id string. The org rides there too, for
14
+ // the reason Slack puts it in the button `value`: the click handler needs the org BEFORE it can
15
+ // look the row up. The data key is distinct from eve's own HITL key (`eve_input`), so the front
16
+ // route can tell an OE approval tap from an eve HITL answer without a second check.
17
+
18
+ import type { CardOptionTone, PendingActionRow } from "@open-everywhere/core/agent/adapter-api.js";
19
+ import { approvalLabel, optionsWithoutConfirm, pendingCardLayout, plainSummaryForRow } from "@open-everywhere/core/agent/adapter-api.js";
20
+
21
+ /** The `invoke` activity name Teams gives an `Action.Execute` tap. */
22
+ export const TEAMS_ADAPTIVE_CARD_ACTION_INVOKE = "adaptiveCard/action";
23
+
24
+ /** The `verb` every OE approval action carries. The decision rides `data`, so one verb covers all. */
25
+ export const OE_TEAMS_APPROVAL_VERB = "oe-approval";
26
+
27
+ /** The `data` key every OE approval action carries. Distinct from eve's HITL key (`eve_input`). */
28
+ export const OE_TEAMS_APPROVAL_DATA_KEY = "oe_teams_approval";
29
+
30
+ /** The Adaptive Card attachment content type. */
31
+ export const TEAMS_ADAPTIVE_CARD_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive";
32
+
33
+ /** Universal Actions (`Action.Execute`) need schema 1.4 or later. */
34
+ const ADAPTIVE_CARD_VERSION = "1.4";
35
+
36
+ /**
37
+ * Every verb a Teams approval action can carry — the same closed set the Discord bundle uses, and a
38
+ * SUBSET of core's `ApprovalDecision`. `contradict` is the judge-lift notice's one button: it decides
39
+ * nothing, it records an overrule of a verdict whose act has already run.
40
+ */
41
+ export type TeamsApprovalDecision = "approve" | "reject" | "approve_session" | "contradict";
42
+
43
+ /** The closed verb set as a VALUE, so build and parse cannot drift apart. */
44
+ const TEAMS_APPROVAL_DECISIONS: readonly TeamsApprovalDecision[] = ["approve", "reject", "approve_session", "contradict"];
45
+
46
+ /** One decoded OE approval action payload. */
47
+ export interface TeamsApprovalAction {
48
+ readonly id: string;
49
+ readonly decision: TeamsApprovalDecision;
50
+ readonly org: string;
51
+ }
52
+
53
+ /** One Adaptive Card element or action. Loosely typed on purpose, as card.ts's SlackBlock is. */
54
+ export interface AdaptiveElement {
55
+ readonly type: string;
56
+ readonly [key: string]: unknown;
57
+ }
58
+
59
+ /** One rendered Adaptive Card. */
60
+ export interface TeamsAdaptiveCard {
61
+ readonly type: "AdaptiveCard";
62
+ readonly $schema: string;
63
+ readonly version: string;
64
+ readonly body: readonly AdaptiveElement[];
65
+ readonly actions?: readonly AdaptiveElement[];
66
+ }
67
+
68
+ /** The `data` object of one OE approval action. */
69
+ export function buildTeamsApprovalData(row: Pick<PendingActionRow, "id" | "org">, decision: TeamsApprovalDecision): Record<string, unknown> {
70
+ return { [OE_TEAMS_APPROVAL_DATA_KEY]: { id: row.id, decision, org: row.org } };
71
+ }
72
+
73
+ function isObject(v: unknown): v is Record<string, unknown> {
74
+ return typeof v === "object" && v !== null && !Array.isArray(v);
75
+ }
76
+
77
+ function nonEmpty(v: unknown): string | undefined {
78
+ return typeof v === "string" && v.length > 0 ? v : undefined;
79
+ }
80
+
81
+ /**
82
+ * Decode an OE approval payload from an action's `data` object. Returns null for anything else —
83
+ * a foreign data object, a missing id or org, or a decision outside the closed set — so a caller
84
+ * reads "not parseable" as "not ours" with no second check (core's parseApprovalAction does the same).
85
+ */
86
+ export function parseTeamsApprovalData(data: unknown): TeamsApprovalAction | null {
87
+ if (!isObject(data)) return null;
88
+ const payload = data[OE_TEAMS_APPROVAL_DATA_KEY];
89
+ if (!isObject(payload)) return null;
90
+ const id = nonEmpty(payload["id"]);
91
+ const org = nonEmpty(payload["org"]);
92
+ const decision = payload["decision"];
93
+ if (!id || !org || typeof decision !== "string") return null;
94
+ if (!TEAMS_APPROVAL_DECISIONS.includes(decision as TeamsApprovalDecision)) return null;
95
+ return { id, decision: decision as TeamsApprovalDecision, org };
96
+ }
97
+
98
+ function textBlock(text: string, extra: Record<string, unknown> = {}): AdaptiveElement {
99
+ return { type: "TextBlock", text, wrap: true, ...extra };
100
+ }
101
+
102
+ function card(body: readonly AdaptiveElement[], actions?: readonly AdaptiveElement[]): TeamsAdaptiveCard {
103
+ return {
104
+ type: "AdaptiveCard",
105
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
106
+ version: ADAPTIVE_CARD_VERSION,
107
+ body,
108
+ ...(actions && actions.length > 0 ? { actions } : {}),
109
+ };
110
+ }
111
+
112
+ function execute(row: Pick<PendingActionRow, "id" | "org">, title: string, decision: TeamsApprovalDecision, style?: "positive" | "destructive"): AdaptiveElement {
113
+ return {
114
+ type: "Action.Execute",
115
+ title,
116
+ verb: OE_TEAMS_APPROVAL_VERB,
117
+ data: buildTeamsApprovalData(row, decision),
118
+ ...(style ? { style } : {}),
119
+ };
120
+ }
121
+
122
+ /** The shared card body: header, then one TextBlock per summary line, then the provenance line. */
123
+ function cardBody(header: string, summaryLines: readonly string[], footer: string): AdaptiveElement[] {
124
+ return [
125
+ textBlock(header, { weight: "Bolder", size: "Medium" }),
126
+ ...summaryLines.map((line) => textBlock(line)),
127
+ textBlock(footer, { isSubtle: true, size: "Small" }),
128
+ ];
129
+ }
130
+
131
+ /** An approval card's body: the PLAIN SENTENCE first (OPE-2434), then the `Approval requested: <kind>` label, the summary lines and the provenance line. The sentence went through core's `plainValue`, so it carries no markdown a TextBlock would render. */
132
+ function approvalCardBody(row: PendingActionRow, summaryLines: readonly string[], footer: string): AdaptiveElement[] {
133
+ return [
134
+ textBlock(plainSummaryForRow(row), { weight: "Bolder", size: "Medium" }),
135
+ textBlock(approvalLabel(row.kind), { isSubtle: true, size: "Small", spacing: "None" }),
136
+ ...summaryLines.map((line) => textBlock(line)),
137
+ textBlock(footer, { isSubtle: true, size: "Small" }),
138
+ ];
139
+ }
140
+
141
+ const OPTION_STYLE: Record<CardOptionTone, "positive" | "destructive" | undefined> = { primary: "positive", danger: "destructive", neutral: undefined };
142
+
143
+ /**
144
+ * The card for a still-pending action: the plain sentence, the summary, "requested by / expires", and one `Action.Execute` per layout option. Mirrors card.ts's pendingCardBlocks. The requester is the Bot Framework principal id, because an Adaptive Card mention needs the person's display name, and the frozen row carries only the id.
145
+ * The OPTIONS come from core's card layout (OPE-53, OPE-2363: only a ROUTINE's card gets the middle grant option), so Slack, Discord and Teams cannot disagree. OPE-2456's rule option needs a confirmation before the click. This renderer sends an `Action.Execute` tap with none, so it leaves that option out.
146
+ */
147
+ export function pendingTeamsCard(row: PendingActionRow, summaryLines: readonly string[]): TeamsAdaptiveCard {
148
+ const layout = pendingCardLayout(row, summaryLines);
149
+ // This card's `Action.Execute` tap is sent with no confirmation first, so the rule option (OPE-2456) is left out here.
150
+ const actions = optionsWithoutConfirm(layout.options).map((o) => execute(row, o.label, o.verb, OPTION_STYLE[o.tone]));
151
+ return card(approvalCardBody(row, summaryLines, `Requested by ${layout.requestedBy} · ${layout.expiry}`), actions);
152
+ }
153
+
154
+ /**
155
+ * The card once a decision has landed: the same sentence and summary, no actions, one outcome line. It decides
156
+ * nothing — it renders what the caller already CAS-decided. `deciderLabel` is the name the click
157
+ * activity carried, when the caller has one; the row's principal id otherwise.
158
+ */
159
+ export function decidedTeamsCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string, deciderLabel?: string): TeamsAdaptiveCard {
160
+ const who = deciderLabel ?? row.decidedBy ?? "unknown";
161
+ const decided =
162
+ row.status === "approved"
163
+ ? `✅ Approved by ${who} · ${outcomeLine}`
164
+ : row.status === "rejected"
165
+ ? `❌ Rejected by ${who}${outcomeLine === "rejected" ? "" : ` · ${outcomeLine}`}`
166
+ : "⏰ Expired";
167
+ return card(approvalCardBody(row, summaryLines, decided));
168
+ }
169
+
170
+ /**
171
+ * The judge-lift notice (OPE-1103 on Discord, OPE-2424 here): the trace of an act the armed judge
172
+ * lifted, with the one "This should not have run" button. NOT an approval card — no Approve, no
173
+ * Reject, no expiry line — because the act has already run.
174
+ */
175
+ export function judgeLiftNoticeTeamsCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string): TeamsAdaptiveCard {
176
+ return card(
177
+ cardBody(
178
+ `Ran without asking: ${row.kind}`,
179
+ summaryLines,
180
+ `⚡ Auto-approved by the approval judge — no card was posted and this has already run · ${outcomeLine}`,
181
+ ),
182
+ [execute(row, "This should not have run", "contradict", "destructive")],
183
+ );
184
+ }
185
+
186
+ /** One Adaptive Card as a message-activity attachment. */
187
+ export function teamsCardAttachment(c: TeamsAdaptiveCard): { readonly contentType: string; readonly content: TeamsAdaptiveCard } {
188
+ return { contentType: TEAMS_ADAPTIVE_CARD_CONTENT_TYPE, content: c };
189
+ }
@@ -0,0 +1,467 @@
1
+ // The Teams I/O half of the approval flow (OPE-2424) — the sibling of core's Slack wiring and the
2
+ // Discord bundle's discord-flow.ts, and it funnels through the SAME platform-neutral decide core
3
+ // (`resolveApprovalClick`). Nothing here decides: the CAS, the approver policy, the clicker gate, the
4
+ // halt arrow, the execute and every audit row stay in core. This file renders and transports.
5
+ //
6
+ // postTeamsApprovalCard() — the CardPoster this bundle registers. It renders the pure card
7
+ // (teams-card.ts) and sends it through the Bot Connector into the
8
+ // card's conversation. NEVER throws (CardPoster contract): a failure
9
+ // resolves undefined, and requestApproval expires the pending.
10
+ // postTeamsJudgeLiftNotice() — the JudgeLiftNoticePoster: the trace of an act the armed judge
11
+ // lifted, with the one "This should not have run" button. Same
12
+ // transport, and it is best-effort, because the act has already run.
13
+ // handleTeamsApprovalInvoke() — called by the front route (channels/teams-front.ts) for one
14
+ // VERIFIED `adaptiveCard/action` invoke that carries an OE approval
15
+ // payload. It owns the tap end to end and returns the invoke
16
+ // response body. NEVER throws: Teams shows an error over a tap whose
17
+ // invoke does not answer.
18
+ // teamsDecisionFeedback — the two decision-feedback legs core's sweep and session-grant
19
+ // release call for a Teams card (core approvals/flow.ts
20
+ // `registerDecisionFeedbackSurface`).
21
+ //
22
+ // 🔴 THE TAP IS AUTHENTICATED BEFORE IT REACHES THIS FILE. The front route verifies the Bot Framework
23
+ // JWT (eve's `verifyTeamsRequest`) on EVERY request and answers 401 before it parses the body, so an
24
+ // unsigned or forged invoke never reaches `handleTeamsApprovalInvoke`. It is the same verifier, on
25
+ // the same handler, that admits a message activity.
26
+ //
27
+ // 🔴 WHAT THE TOKEN PROVES, AND WHAT IT DOES NOT. The token proves that the Bot Framework service
28
+ // relayed the request to this app id. It does not bind the body, and it is the same token for EVERY
29
+ // channel on the bot registration (`teams-origin-codec.ts` records the same limit). So this file
30
+ // accepts a tap ONLY when the activity's own `channelId` is `msteams`: a Web Chat or Direct Line
31
+ // client sets `from` and `channelData` itself, and a tap from there must never reach the decide core
32
+ // as a Teams person. The clicker, the tenant and the room are then read off a Teams activity that
33
+ // Microsoft relayed; the card's data payload names only the org and the pending id. A tap with no
34
+ // tenant is refused rather than decided with the clicker gate switched off.
35
+ //
36
+ // WHY THE RESPONSE IS THE UI (Discord's shape, not Slack's): an `Action.Execute` tap is answered by
37
+ // the invoke response itself. A card body REPLACES the card (the decided render, no buttons), and a
38
+ // message body shows a note to the clicker alone (a refusal, with the card left live for someone who
39
+ // can decide it). The decision FEEDBACK — the fresh notice beside the card and the decision wake —
40
+ // runs under the route's `waitUntil` when there is one, because nothing in the response depends on it.
41
+
42
+ import type { TeamsInvokeActivity } from "eve/channels/teams";
43
+ import {
44
+ resolveApprovalClick,
45
+ deliverDecisionFeedback,
46
+ clickerFloorRefusalNotice,
47
+ judgeContradictionNotice,
48
+ getOperationalStore,
49
+ surfaceKeySegment,
50
+ type ApprovalClickResolution,
51
+ type ApprovalOutcome,
52
+ type DecisionFeedbackSurface,
53
+ type DecisionWakeDeps,
54
+ type PendingActionRow,
55
+ type UnifiedWakeOrigin,
56
+ } from "@open-everywhere/core/agent/adapter-api.js";
57
+ import {
58
+ TEAMS_ADAPTIVE_CARD_ACTION_INVOKE,
59
+ TEAMS_ADAPTIVE_CARD_CONTENT_TYPE,
60
+ decidedTeamsCard,
61
+ judgeLiftNoticeTeamsCard,
62
+ parseTeamsApprovalData,
63
+ pendingTeamsCard,
64
+ teamsCardAttachment,
65
+ type TeamsAdaptiveCard,
66
+ type TeamsApprovalAction,
67
+ } from "./teams-card.js";
68
+ import { sendTeamsActivityBackedOff, updateTeamsActivityBackedOff } from "../channels/teams-connector.js";
69
+ import { teamsChannelGrainConversationId, teamsConversationRef, type TeamsConversationRef } from "../channels/teams-conversation-ref.js";
70
+ import { restoreTeamsConversationId } from "../channels/teams-id-restore.js";
71
+ import { isTrustedTeamsServiceUrl } from "../channels/teams-origin-codec.js";
72
+ import { resolveTeamsUserType, teamsServiceUrlRosterProbe } from "../channels/teams-roster.js";
73
+
74
+ function log(msg: string): void {
75
+ console.log(`[open-everywhere:teams-approvals] ${msg}`);
76
+ }
77
+
78
+ // --- the transport seams ------------------------------------------------------------------------
79
+
80
+ /** The Connector send this file uses, injectable so a test drives it without Teams. */
81
+ export type TeamsSend = (input: { readonly serviceUrl: string; readonly conversationId: string; readonly body: unknown }) => Promise<{ id: string }>;
82
+ /** The Connector update this file uses, injectable for the same reason. */
83
+ export type TeamsUpdate = (input: {
84
+ readonly serviceUrl: string;
85
+ readonly conversationId: string;
86
+ readonly activityId: string;
87
+ readonly body: unknown;
88
+ }) => Promise<{ id: string }>;
89
+
90
+ /** Injectable seams for the poster and the row-addressed notice. Live callers pass none. */
91
+ export interface TeamsPostDeps {
92
+ readonly send?: TeamsSend;
93
+ /** The stored conversation reference for a card's `channelId`. Live default: the raw-id restore
94
+ * (`teams-id-restore.ts`) and then the `conversation`-grain address (`teams-conversation-ref.ts`). */
95
+ readonly resolveRef?: (channelId: string) => Promise<TeamsConversationRef | undefined>;
96
+ }
97
+
98
+ async function liveResolveRef(channelId: string): Promise<TeamsConversationRef | undefined> {
99
+ return teamsConversationRef(await restoreTeamsConversationId(channelId));
100
+ }
101
+
102
+ /**
103
+ * The conversation reference a card's `channelId` resolves to, or undefined. The read half of the
104
+ * address store already refuses an untrusted `serviceUrl`; the check here is the same allowlist again,
105
+ * so an injected resolver cannot hand this file a host the store would refuse.
106
+ */
107
+ async function cardConversation(channelId: string, deps: TeamsPostDeps): Promise<TeamsConversationRef | undefined> {
108
+ const ref = await (deps.resolveRef ?? liveResolveRef)(channelId);
109
+ return ref && isTrustedTeamsServiceUrl(ref.serviceUrl) ? ref : undefined;
110
+ }
111
+
112
+ // --- the posters ----------------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Render the approval card and send it into `row.channelId` through the Bot Connector. Resolves the
116
+ * posted activity id, or undefined on ANY failure: no channel id, no stored conversation reference
117
+ * (this deployment never saw an inbound activity there), a missing Bot credential, a Connector error.
118
+ * NEVER throws.
119
+ *
120
+ * WHERE IT LANDS. `row.channelId` is the Teams CHANNEL id (bindTeams' `target.channelId`), so the card
121
+ * posts as a NEW post in the channel, not inside the reply chain that asked. bindTeams sets no
122
+ * `threadTs` on purpose (about twenty consumers read that field as a Slack thread_ts), so the row
123
+ * carries no chain to thread under. The tool result names the card, and the decision notice threads
124
+ * under the card itself.
125
+ */
126
+ export async function postTeamsApprovalCard(row: PendingActionRow, summaryLines: readonly string[], deps: TeamsPostDeps = {}): Promise<string | undefined> {
127
+ return postTeamsCardMessage(row, deps, "approval card", () => pendingTeamsCard(row, summaryLines));
128
+ }
129
+
130
+ /**
131
+ * THE JUDGE-LIFT NOTICE POSTER: the room's trace of an act the armed judge lifted, with the overrule
132
+ * button. Best-effort by contract — the frozen args have already run, so a failed post is logged and
133
+ * resolves void. The audit rows (`approval.autoapproved`, `approval.judge.verdict`) land either way.
134
+ */
135
+ export async function postTeamsJudgeLiftNotice(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string, deps: TeamsPostDeps = {}): Promise<void> {
136
+ await postTeamsCardMessage(row, deps, "judge-lift notice", () => judgeLiftNoticeTeamsCard(row, summaryLines, outcomeLine));
137
+ }
138
+
139
+ async function postTeamsCardMessage(row: PendingActionRow, deps: TeamsPostDeps, what: string, render: () => TeamsAdaptiveCard): Promise<string | undefined> {
140
+ if (!row.channelId) {
141
+ log(`no channel id on the pending row — nothing to post the ${what} to`);
142
+ return undefined;
143
+ }
144
+ try {
145
+ const ref = await cardConversation(row.channelId, deps);
146
+ if (!ref) {
147
+ log(`no conversation reference for the ${what}'s channel — this deployment has never seen an inbound activity there`);
148
+ return undefined;
149
+ }
150
+ const posted = await (deps.send ?? sendTeamsActivityBackedOff)({
151
+ serviceUrl: ref.serviceUrl,
152
+ conversationId: ref.conversationId,
153
+ body: { type: "message", attachments: [teamsCardAttachment(render())] },
154
+ });
155
+ return posted.id.length > 0 ? posted.id : undefined;
156
+ } catch (err) {
157
+ // Token-free: the Connector seam's errors carry a status and ids, never a bearer token.
158
+ log(`Teams ${what} post failed: ${(err as Error)?.message ?? String(err)}`);
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ // --- the row-addressed feedback legs (the sweep and the session-grant release) -------------------
164
+
165
+ /**
166
+ * The decision notice for a row with NO click in hand — a TTL expiry or a session-grant release. It
167
+ * posts into the card's own conversation at the grain the card was sent to. It does not try to thread
168
+ * under the card: a reply-chain address needs a vendor id shape this file would have to guess, and the
169
+ * click path below threads exactly, off the invoke's own conversation id. Throws on failure; core's
170
+ * `deliverDecisionFeedback` swallows a notice failure.
171
+ */
172
+ export async function postTeamsRowDecisionNotice(row: PendingActionRow, text: string, deps: TeamsPostDeps = {}): Promise<unknown> {
173
+ const ref = await cardConversation(row.channelId, deps);
174
+ if (!ref) throw new Error("no Teams conversation reference for the card's channel");
175
+ return (deps.send ?? sendTeamsActivityBackedOff)({ serviceUrl: ref.serviceUrl, conversationId: ref.conversationId, body: { type: "message", text } });
176
+ }
177
+
178
+ /** The decision-wake target for a row with no click in hand. Null when the card's conversation has no
179
+ * stored reference or the row has no `postedTo`. The authority frozen at proposal still wins over it. */
180
+ export async function teamsRowWakeTarget(row: PendingActionRow, deps: TeamsPostDeps = {}): Promise<{ surfaceKey: string; origin: UnifiedWakeOrigin } | null> {
181
+ if (!row.postedTo) return null;
182
+ try {
183
+ const ref = await cardConversation(row.channelId, deps);
184
+ if (!ref) return null;
185
+ return { surfaceKey: row.postedTo, origin: { platform: "teams", serviceUrl: ref.serviceUrl, conversationId: ref.conversationId, tenantId: ref.tenantId } };
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /** The legs this bundle registers with core (`registerDecisionFeedbackSurface`). */
192
+ export const teamsDecisionFeedback: DecisionFeedbackSurface = {
193
+ postNotice: (row, text) => postTeamsRowDecisionNotice(row, text),
194
+ wakeTarget: (row) => teamsRowWakeTarget(row),
195
+ };
196
+
197
+ // --- the invoke (the tap) -------------------------------------------------------------------------
198
+
199
+ /** What one OE approval tap carries. Every field but `action` comes off the relayed Teams activity
200
+ * (`channelId === "msteams"`, see the header), never out of the card's data payload. The JWT proves
201
+ * the relay, not the body. */
202
+ export interface TeamsApprovalInvoke {
203
+ readonly action: TeamsApprovalAction;
204
+ /** The Bot Framework user id of the person who tapped (`from.id`) — the principal id bindTeams uses. */
205
+ readonly clickerId?: string;
206
+ /** The display name Teams sent with the tap, for the decided card only. Never an authority input. */
207
+ readonly clickerName?: string;
208
+ readonly tenantId?: string;
209
+ /** The Teams channel the tap happened in (`channelData.channel.id`), when it was a channel. */
210
+ readonly teamsChannelId?: string;
211
+ /** The conversation the card lives in, at the grain Teams reports it (a channel card's own reply chain). */
212
+ readonly conversationId: string;
213
+ readonly serviceUrl: string;
214
+ /** The card's own activity id, when Teams carries it on the invoke. */
215
+ readonly replyToId?: string;
216
+ }
217
+
218
+ function isObject(v: unknown): v is Record<string, unknown> {
219
+ return typeof v === "object" && v !== null && !Array.isArray(v);
220
+ }
221
+
222
+ function nonEmpty(v: unknown): string | undefined {
223
+ return typeof v === "string" && v.length > 0 ? v : undefined;
224
+ }
225
+
226
+ /**
227
+ * Probe one parsed activity for an OE approval tap: an `invoke` named `adaptiveCard/action` whose
228
+ * `value.action.data` carries the OE payload. Returns null for anything else — an eve HITL answer, a
229
+ * different invoke, a message — which the front forwards to the native channel untouched. NEVER throws.
230
+ */
231
+ export function parseTeamsApprovalInvoke(activity: unknown): TeamsApprovalInvoke | null {
232
+ if (!isObject(activity) || activity["type"] !== "invoke") return null;
233
+ const invoke = activity as unknown as TeamsInvokeActivity;
234
+ if (invoke.name !== TEAMS_ADAPTIVE_CARD_ACTION_INVOKE) return null;
235
+ // The Teams channel only — see the header. Read off the raw activity: eve's parsed shape drops it.
236
+ const raw = isObject(invoke.raw) ? invoke.raw : (activity as Record<string, unknown>);
237
+ if (raw["channelId"] !== "msteams") return null;
238
+ const value = isObject(invoke.value) ? invoke.value : undefined;
239
+ const actionObj = value && isObject(value["action"]) ? value["action"] : undefined;
240
+ const action = parseTeamsApprovalData(actionObj?.["data"]);
241
+ if (!action) return null;
242
+ const conversationId = nonEmpty(invoke.conversation?.id);
243
+ const serviceUrl = nonEmpty(invoke.serviceUrl);
244
+ if (!conversationId || !serviceUrl) return null;
245
+ const clickerId = nonEmpty(invoke.from?.id);
246
+ const clickerName = nonEmpty(invoke.from?.name);
247
+ const tenantId = nonEmpty(invoke.tenantId) ?? nonEmpty(invoke.conversation?.tenantId);
248
+ const teamsChannelId = nonEmpty(invoke.teamsChannelId);
249
+ const replyToId = nonEmpty(invoke.replyToId);
250
+ return {
251
+ action,
252
+ conversationId,
253
+ serviceUrl,
254
+ ...(clickerId ? { clickerId } : {}),
255
+ ...(clickerName ? { clickerName } : {}),
256
+ ...(tenantId ? { tenantId } : {}),
257
+ ...(teamsChannelId ? { teamsChannelId } : {}),
258
+ ...(replyToId ? { replyToId } : {}),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * The room the tap PHYSICALLY happened in, as `teams/<tenant>/<channel>` — core's clicker gate reads
264
+ * it (leg C compares its channel segment with the row's `channelId`). It comes from the relayed
265
+ * Teams activity: the channel id, else the channel grain of the conversation id. Undefined without a
266
+ * tenant or a room. 🔴 The handler REFUSES such a tap rather than passing no click context: core
267
+ * reserves "no click context" for the headless control plane, and it switches every clicker-gate leg
268
+ * off.
269
+ *
270
+ * TWO SPELLINGS OF ONE ROOM. A card the venue walk posted in its own room carries the RAW channel id;
271
+ * a card redirected to a domain's roster entry carries the roster's SURFACE-KEY spelling. Both
272
+ * spellings below are derived from the TAP's own channel id, so the row only chooses which spelling
273
+ * of the same click room to compare. `teams-id-restore.ts` states the bound: two raw ids that share a
274
+ * spelling are already one id to this deployment.
275
+ */
276
+ export function teamsClickSurfaceKey(invoke: TeamsApprovalInvoke, row: Pick<PendingActionRow, "channelId"> | null): string | undefined {
277
+ const room = invoke.teamsChannelId ?? teamsChannelGrainConversationId(invoke.conversationId);
278
+ if (!invoke.tenantId || !room) return undefined;
279
+ const spelling = surfaceKeySegment(room);
280
+ const channel = row && row.channelId !== room && row.channelId === spelling ? spelling : room;
281
+ return `teams/${invoke.tenantId}/${channel}`;
282
+ }
283
+
284
+ /** One `adaptiveCard/action` invoke response body. */
285
+ export interface TeamsInvokeResponseBody {
286
+ readonly statusCode: 200;
287
+ readonly type: string;
288
+ readonly value: unknown;
289
+ }
290
+
291
+ const TEAMS_MESSAGE_RESPONSE_TYPE = "application/vnd.microsoft.activity.message";
292
+
293
+ function messageResponse(text: string): TeamsInvokeResponseBody {
294
+ return { statusCode: 200, type: TEAMS_MESSAGE_RESPONSE_TYPE, value: text };
295
+ }
296
+
297
+ function cardResponse(card: TeamsAdaptiveCard): TeamsInvokeResponseBody {
298
+ return { statusCode: 200, type: TEAMS_ADAPTIVE_CARD_CONTENT_TYPE, value: card };
299
+ }
300
+
301
+ /**
302
+ * Map one decide resolution to the invoke response — the Teams rendering of what core's Slack handler
303
+ * renders as calls. PURE, so it is tested as a matrix.
304
+ * decided / expired → the decided card REPLACES the pending one (no buttons left to tap).
305
+ * denied, clicker-denied, halted → a note to the clicker alone; the card stays live and the row
306
+ * stays pending, so a person who may decide it still can.
307
+ * contradicted → a note to the clicker; the notice card stays, because it is the room's trace.
308
+ * unknown → a note, with the Slack card-text wording.
309
+ * the silent branches (a stale re-tap, a lost race, an unregistered kind) → a short note that
310
+ * nothing changed. An invoke must answer with something, and re-rendering a card
311
+ * from a stale row could overwrite the winner's decided card.
312
+ */
313
+ export function teamsResponseFor(res: ApprovalClickResolution, deciderLabel?: string): TeamsInvokeResponseBody {
314
+ switch (res.outcome) {
315
+ case "unknown":
316
+ return messageResponse("This approval request is no longer known (it may have already been decided or has aged out).");
317
+ case "not-pending":
318
+ case "expired-race":
319
+ case "lost-race":
320
+ return messageResponse("Nothing changed — this request was already decided.");
321
+ case "unregistered-kind":
322
+ return messageResponse("Nothing changed — this deployment cannot decide this kind of request.");
323
+ case "expired":
324
+ return cardResponse(decidedTeamsCard(res.row, res.summaryLines, "expired"));
325
+ case "denied":
326
+ return messageResponse("Only a domain/org admin can decide this request. It is still pending.");
327
+ case "clicker-denied":
328
+ return messageResponse(clickerFloorRefusalNotice(res.reason));
329
+ case "halted":
330
+ return messageResponse(res.notice);
331
+ case "contradicted":
332
+ return messageResponse(judgeContradictionNotice(res.contradiction));
333
+ case "decided":
334
+ return cardResponse(decidedTeamsCard(res.row, res.summaryLines, res.outcomeLine, deciderLabel));
335
+ }
336
+ }
337
+
338
+ /** Test seams for the tap. Live callers pass only `waitUntil`. */
339
+ export interface TeamsApprovalDeps {
340
+ /** The decide core. Live default: `resolveApprovalClick`. */
341
+ readonly resolve?: typeof resolveApprovalClick;
342
+ /** Leg D's positive per-clicker verdict. Live default: the Bot Connector member probe the inbound
343
+ * message path and the HITL answer path already use. `undefined` leaves leg D inert. */
344
+ readonly userType?: (invoke: TeamsApprovalInvoke) => Promise<"guest" | "member" | undefined>;
345
+ /** Reads the row ONLY to pick the spelling of the click room (see {@link teamsClickSurfaceKey}). */
346
+ readonly readRow?: (org: string, id: string) => Promise<PendingActionRow | null>;
347
+ readonly send?: TeamsSend;
348
+ readonly update?: TeamsUpdate;
349
+ readonly decisionWake?: DecisionWakeDeps;
350
+ /** The route's own `waitUntil`. PRESENT ⇒ the feedback and core's session-grant release run off
351
+ * the response path. ABSENT ⇒ both are awaited first, which is correct and slower. */
352
+ readonly waitUntil?: (p: Promise<unknown>) => void;
353
+ }
354
+
355
+ async function liveUserType(invoke: TeamsApprovalInvoke): Promise<"guest" | "member" | undefined> {
356
+ if (!invoke.clickerId) return undefined;
357
+ return resolveTeamsUserType(teamsServiceUrlRosterProbe(invoke.serviceUrl), invoke.conversationId, invoke.clickerId);
358
+ }
359
+
360
+ /**
361
+ * Handle one VERIFIED OE approval tap and return the invoke response body. The approver policy is
362
+ * core's: the config-as-code roster, matched as `teams/<from.id>` (Teams is not a tenant-scoped
363
+ * person-id space, identity/principal-key.ts), with no platform-native admin leg — a Teams team owner
364
+ * is not an OE authority. This call states its platform rather than letting core infer it. NEVER throws.
365
+ */
366
+ export async function handleTeamsApprovalInvoke(invoke: TeamsApprovalInvoke, deps: TeamsApprovalDeps = {}): Promise<TeamsInvokeResponseBody> {
367
+ const { action } = invoke;
368
+ try {
369
+ if (!invoke.clickerId) {
370
+ console.error(`[open-everywhere:teams-approvals] approval tap on ${action.id} carries no user id — ignoring the tap`);
371
+ return messageResponse("Couldn't read who clicked — this decision was not applied.");
372
+ }
373
+ const clickerId = invoke.clickerId;
374
+ if (!invoke.tenantId) {
375
+ console.error(`[open-everywhere:teams-approvals] approval tap on ${action.id} carries no tenant — refusing the tap`);
376
+ return messageResponse("Couldn't read where this was clicked — this decision was not applied.");
377
+ }
378
+ // Leg D's verdict, resolved BEFORE the decide call so a positive guest verdict cannot race a lost
379
+ // CAS. A thrown probe degrades to "no verdict", never to an aborted tap.
380
+ const userType = await (deps.userType ?? liveUserType)(invoke).catch(() => undefined);
381
+ const known = await (deps.readRow ?? ((org: string, id: string) => getOperationalStore().getPendingAction(org, id)))(action.org, action.id).catch(() => null);
382
+ const surfaceKey = teamsClickSurfaceKey(invoke, known);
383
+ if (!surfaceKey) return messageResponse("Couldn't read where this was clicked — this decision was not applied.");
384
+
385
+ const res = await (deps.resolve ?? resolveApprovalClick)({
386
+ org: action.org,
387
+ id: action.id,
388
+ decision: action.decision,
389
+ clickerId,
390
+ clickerPlatform: "teams",
391
+ clickerTenant: invoke.tenantId,
392
+ clickContext: { surfaceKey },
393
+ ...(userType === undefined ? {} : { clickerIsGuest: userType === "guest" }),
394
+ ...(deps.waitUntil ? { waitUntil: deps.waitUntil } : {}),
395
+ });
396
+
397
+ const pending = decisionFeedback(invoke, res, deps);
398
+ if (pending !== undefined) {
399
+ if (deps.waitUntil === undefined) {
400
+ await pending;
401
+ } else {
402
+ deps.waitUntil(
403
+ pending.catch((err: unknown) => {
404
+ log(`deferred decision feedback failed for ${action.id}: ${(err as Error)?.message ?? String(err)}`);
405
+ }),
406
+ );
407
+ }
408
+ }
409
+ return teamsResponseFor(res, invoke.clickerName);
410
+ } catch (err) {
411
+ console.error(`[open-everywhere:teams-approvals] tap handling failed softly for ${action.id}: ${String(err)}`);
412
+ return messageResponse("Something went wrong handling this click — the decision was not applied.");
413
+ }
414
+ }
415
+
416
+ /**
417
+ * The decision FEEDBACK for a decided or expired tap: a fresh notice in the card's own conversation
418
+ * (the card replacement is an edit, which backscroll does not show), an update of the card message so
419
+ * a viewer who did not tap also sees the decided render, and the decision wake. Undefined for every
420
+ * other resolution. Every leg is best-effort.
421
+ */
422
+ function decisionFeedback(invoke: TeamsApprovalInvoke, res: ApprovalClickResolution, deps: TeamsApprovalDeps): Promise<void> | undefined {
423
+ if (res.outcome !== "decided" && res.outcome !== "expired") return undefined;
424
+ const row = res.row;
425
+ const outcomeLine = res.outcome === "expired" ? "expired" : res.outcomeLine;
426
+ const send = deps.send ?? sendTeamsActivityBackedOff;
427
+ // The invoke's own conversation IS the card's conversation (a channel card's own reply chain). Its
428
+ // serviceUrl is the relayed activity's, which the token does NOT bind, so the allowlist below is the
429
+ // guard for every Connector call here, each of which carries a bearer token.
430
+ const trusted = isTrustedTeamsServiceUrl(invoke.serviceUrl);
431
+ const postNotice = async (text: string): Promise<unknown> => {
432
+ if (!trusted) throw new Error("the tap's serviceUrl is not a Bot Framework host");
433
+ return send({ serviceUrl: invoke.serviceUrl, conversationId: invoke.conversationId, body: { type: "message", text } });
434
+ };
435
+ const target: { surfaceKey: string; origin: UnifiedWakeOrigin } | null =
436
+ row.postedTo && trusted
437
+ ? {
438
+ surfaceKey: row.postedTo,
439
+ origin: { platform: "teams", serviceUrl: invoke.serviceUrl, conversationId: invoke.conversationId, ...(invoke.tenantId ? { tenantId: invoke.tenantId } : {}) },
440
+ }
441
+ : null;
442
+ const updateCard = async (): Promise<void> => {
443
+ const activityId = row.messageTs ?? invoke.replyToId;
444
+ if (!trusted || !activityId) return;
445
+ const card = decidedTeamsCard(row, res.summaryLines, outcomeLine, res.outcome === "decided" ? invoke.clickerName : undefined);
446
+ await (deps.update ?? updateTeamsActivityBackedOff)({
447
+ serviceUrl: invoke.serviceUrl,
448
+ conversationId: invoke.conversationId,
449
+ activityId,
450
+ body: { type: "message", id: activityId, attachments: [teamsCardAttachment(card)] },
451
+ }).catch((err: unknown) => log(`decided-card update failed for ${row.id}: ${(err as Error)?.message ?? String(err)}`));
452
+ };
453
+ const feedback = deliverDecisionFeedback(
454
+ {
455
+ row,
456
+ summaryLines: res.summaryLines,
457
+ outcome: res.outcome === "expired" ? "expired" : (row.status as ApprovalOutcome),
458
+ outcomeLine,
459
+ executed: res.outcome === "expired" ? "none" : res.executed,
460
+ ...(res.outcome === "decided" ? { clickerId: invoke.clickerId } : {}),
461
+ target,
462
+ postNotice,
463
+ },
464
+ deps.decisionWake,
465
+ );
466
+ return Promise.all([updateCard(), feedback]).then(() => undefined);
467
+ }