@open-everywhere/provider-teams 0.3.0 → 0.5.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 (41) hide show
  1. package/agent/app-manifest.ts +6 -1
  2. package/agent/approvals/teams-card.ts +188 -0
  3. package/agent/approvals/teams-flow.ts +467 -0
  4. package/agent/channels/teams-declared-target.ts +108 -0
  5. package/agent/channels/teams-front.ts +32 -8
  6. package/agent/channels/teams-origin-codec.ts +8 -9
  7. package/agent/docs-generated.ts +1 -1
  8. package/agent/register.ts +28 -1
  9. package/agent/surface-parity.ts +19 -11
  10. package/dist/agent/app-manifest.d.ts +3 -0
  11. package/dist/agent/app-manifest.d.ts.map +1 -1
  12. package/dist/agent/app-manifest.js +5 -1
  13. package/dist/agent/app-manifest.js.map +1 -1
  14. package/dist/agent/approvals/teams-card.d.ts +65 -0
  15. package/dist/agent/approvals/teams-card.d.ts.map +1 -0
  16. package/dist/agent/approvals/teams-card.js +134 -0
  17. package/dist/agent/approvals/teams-card.js.map +1 -0
  18. package/dist/agent/approvals/teams-flow.d.ts +144 -0
  19. package/dist/agent/approvals/teams-flow.d.ts.map +1 -0
  20. package/dist/agent/approvals/teams-flow.js +366 -0
  21. package/dist/agent/approvals/teams-flow.js.map +1 -0
  22. package/dist/agent/channels/teams-declared-target.d.ts +12 -0
  23. package/dist/agent/channels/teams-declared-target.d.ts.map +1 -0
  24. package/dist/agent/channels/teams-declared-target.js +96 -0
  25. package/dist/agent/channels/teams-declared-target.js.map +1 -0
  26. package/dist/agent/channels/teams-front.d.ts +8 -3
  27. package/dist/agent/channels/teams-front.d.ts.map +1 -1
  28. package/dist/agent/channels/teams-front.js +26 -9
  29. package/dist/agent/channels/teams-front.js.map +1 -1
  30. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -1
  31. package/dist/agent/channels/teams-origin-codec.js +8 -9
  32. package/dist/agent/channels/teams-origin-codec.js.map +1 -1
  33. package/dist/agent/docs-generated.js +1 -1
  34. package/dist/agent/docs-generated.js.map +1 -1
  35. package/dist/agent/register.d.ts.map +1 -1
  36. package/dist/agent/register.js +26 -2
  37. package/dist/agent/register.js.map +1 -1
  38. package/dist/agent/surface-parity.d.ts.map +1 -1
  39. package/dist/agent/surface-parity.js +18 -11
  40. package/dist/agent/surface-parity.js.map +1 -1
  41. package/package.json +15 -2
@@ -20,6 +20,10 @@ export const TEAMS_DEPLOY_HOST_PLACEHOLDER = "YOUR-DEPLOY-URL";
20
20
  /** The app name the manifest carries when neither an explicit name nor `identity.name` is given. */
21
21
  export const TEAMS_DEFAULT_APP_NAME = "Open Everywhere";
22
22
 
23
+ /** The manifest's accent colour: the brand ink (brand/README.md). It was Discord's blurple, a
24
+ * leftover with no tie to this project. Teams draws it behind an icon with a transparent area. */
25
+ export const TEAMS_BRAND_ACCENT_COLOR = "#1a1a1a";
26
+
23
27
  /** Teams' limit on `name.short`. A longer one fails the package upload. */
24
28
  export const TEAMS_APP_NAME_MAX = 30;
25
29
 
@@ -80,7 +84,7 @@ export function teamsAppManifestTemplate(opts: TeamsAppManifestOptions = {}): st
80
84
  "conversation until that channel is confirmed team-wide.",
81
85
  },
82
86
  icons: { color: "color.png", outline: "outline.png" },
83
- accentColor: "#5865F2",
87
+ accentColor: TEAMS_BRAND_ACCENT_COLOR,
84
88
  bots: [
85
89
  {
86
90
  botId: "{{MICROSOFT_APP_ID}}",
@@ -118,6 +122,7 @@ export const TEAMS_APP_CONSOLE_URL = "https://admin.teams.microsoft.com";
118
122
  /** The ordered steps, from the manifest to an installed app. */
119
123
  export const TEAMS_APP_MANIFEST_STEPS: readonly string[] = [
120
124
  "Save the manifest below as manifest.json in the teams-app/ directory, beside color.png and outline.png.",
125
+ "Keep color.png and outline.png unless you set identity.name and chose your own icon: they are the project mark, the default avatar. A checkout without teams-app/ gets both from https://raw.githubusercontent.com/open-everywhere/open-everywhere/main/teams-app/color.png and https://raw.githubusercontent.com/open-everywhere/open-everywhere/main/teams-app/outline.png. To use your own icon, replace both (color 192x192; outline 32x32, white on transparent).",
121
126
  "Replace {{MICROSOFT_APP_ID}} with the Azure Bot's Application (client) ID (MICROSOFT_APP_ID) — it appears three times and must match everywhere.",
122
127
  "Replace {{DEVELOPER_NAME}} with your org's name.",
123
128
  "Zip it flat: zip -j app.zip manifest.json color.png outline.png (Teams requires the manifest at the zip root).",
@@ -0,0 +1,188 @@
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, 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, and OPE-2456's extra option lands here with no change.
146
+ */
147
+ export function pendingTeamsCard(row: PendingActionRow, summaryLines: readonly string[]): TeamsAdaptiveCard {
148
+ const layout = pendingCardLayout(row, summaryLines);
149
+ const actions = layout.options.map((o) => execute(row, o.label, o.verb, OPTION_STYLE[o.tone]));
150
+ return card(approvalCardBody(row, summaryLines, `Requested by ${layout.requestedBy} · ${layout.expiry}`), actions);
151
+ }
152
+
153
+ /**
154
+ * The card once a decision has landed: the same sentence and summary, no actions, one outcome line. It decides
155
+ * nothing — it renders what the caller already CAS-decided. `deciderLabel` is the name the click
156
+ * activity carried, when the caller has one; the row's principal id otherwise.
157
+ */
158
+ export function decidedTeamsCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string, deciderLabel?: string): TeamsAdaptiveCard {
159
+ const who = deciderLabel ?? row.decidedBy ?? "unknown";
160
+ const decided =
161
+ row.status === "approved"
162
+ ? `✅ Approved by ${who} · ${outcomeLine}`
163
+ : row.status === "rejected"
164
+ ? `❌ Rejected by ${who}${outcomeLine === "rejected" ? "" : ` · ${outcomeLine}`}`
165
+ : "⏰ Expired";
166
+ return card(approvalCardBody(row, summaryLines, decided));
167
+ }
168
+
169
+ /**
170
+ * The judge-lift notice (OPE-1103 on Discord, OPE-2424 here): the trace of an act the armed judge
171
+ * lifted, with the one "This should not have run" button. NOT an approval card — no Approve, no
172
+ * Reject, no expiry line — because the act has already run.
173
+ */
174
+ export function judgeLiftNoticeTeamsCard(row: PendingActionRow, summaryLines: readonly string[], outcomeLine: string): TeamsAdaptiveCard {
175
+ return card(
176
+ cardBody(
177
+ `Ran without asking: ${row.kind}`,
178
+ summaryLines,
179
+ `⚡ Auto-approved by the approval judge — no card was posted and this has already run · ${outcomeLine}`,
180
+ ),
181
+ [execute(row, "This should not have run", "contradict", "destructive")],
182
+ );
183
+ }
184
+
185
+ /** One Adaptive Card as a message-activity attachment. */
186
+ export function teamsCardAttachment(c: TeamsAdaptiveCard): { readonly contentType: string; readonly content: TeamsAdaptiveCard } {
187
+ return { contentType: TEAMS_ADAPTIVE_CARD_CONTENT_TYPE, content: c };
188
+ }