@open-everywhere/provider-teams 0.5.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.
- package/agent/app-manifest.ts +4 -2
- package/agent/app-package.ts +532 -0
- package/agent/approvals/teams-card.ts +4 -3
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +36 -0
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +4 -2
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/app-package.d.ts +74 -0
- package/dist/agent/app-package.d.ts.map +1 -0
- package/dist/agent/app-package.js +449 -0
- package/dist/agent/app-package.js.map +1 -0
- package/dist/agent/approvals/teams-card.d.ts +1 -1
- package/dist/agent/approvals/teams-card.d.ts.map +1 -1
- package/dist/agent/approvals/teams-card.js +4 -3
- package/dist/agent/approvals/teams-card.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +31 -1
- package/dist/agent/ops.js.map +1 -1
- package/package.json +8 -3
package/agent/app-manifest.ts
CHANGED
|
@@ -78,10 +78,12 @@ export function teamsAppManifestTemplate(opts: TeamsAppManifestOptions = {}): st
|
|
|
78
78
|
short: "An autonomous teammate with per-conversation memory.",
|
|
79
79
|
// OPE-1954: the agent acts under ONE shared bot credential, never on a person's behalf.
|
|
80
80
|
full:
|
|
81
|
-
`${name} runs as a Teams bot: @mention it in a team channel, drop it into a group chat, or DM it 1:1. ` +
|
|
81
|
+
`${name} runs as a Microsoft Teams bot: @mention it in a team channel, drop it into a group chat, or DM it 1:1. ` +
|
|
82
82
|
"It acts under one bot identity shared by this deployment — never under your account and never with your " +
|
|
83
83
|
"permissions — and its memory is scoped per tenant and per conversation. Learnings stay siloed to a " +
|
|
84
|
-
"conversation until that channel is confirmed team-wide."
|
|
84
|
+
"conversation until that channel is confirmed team-wide. " +
|
|
85
|
+
// OPE-2438: the store's AI-content rule asks the listing to say that replies are AI-generated.
|
|
86
|
+
"It uses AI to write its replies, and an AI reply can be wrong: check what matters before you act on it.",
|
|
85
87
|
},
|
|
86
88
|
icons: { color: "color.png", outline: "outline.png" },
|
|
87
89
|
accentColor: TEAMS_BRAND_ACCENT_COLOR,
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// THE TEAMS APP PACKAGE — the zip an org admin uploads, or a publisher submits to the store (OPE-2438).
|
|
2
|
+
//
|
|
3
|
+
// `app-manifest.ts` renders the manifest TEMPLATE. That template still carries two placeholders, and
|
|
4
|
+
// the operator used to fill them and zip the package by hand. Each hand step is a place a listing
|
|
5
|
+
// fails: a placeholder left in, an icon of the wrong size, the shipped project-mark files
|
|
6
|
+
// listed under an operator's own agent name. This module does the fill and the zip, and it checks the RESULT
|
|
7
|
+
// against the rules the two listing routes apply:
|
|
8
|
+
//
|
|
9
|
+
// - "org" — a Teams admin uploads the package in the Teams admin centre (Manage apps → Upload
|
|
10
|
+
// new app). Teams applies the manifest schema at upload.
|
|
11
|
+
// - "store" — a publisher submits the package in Partner Center. Microsoft validates it against
|
|
12
|
+
// the Teams Store validation guidelines, and only a store app can carry publisher
|
|
13
|
+
// attestation or Microsoft 365 Certification in the admin centre.
|
|
14
|
+
//
|
|
15
|
+
// Each check names its source: the manifest schema (`developer.microsoft.com/json-schemas/teams/
|
|
16
|
+
// v1.17` and `v1.25`), or the Teams Store validation guidelines (Microsoft Learn,
|
|
17
|
+
// `teams-store-validation-guidelines.md`, read 2026-10-11). A check this module cannot make from the
|
|
18
|
+
// package (the reviewer's live test, the Bot Framework metadata) is in
|
|
19
|
+
// docs/deploy/surfaces/teams.md § Listing in the Teams admin centre, not here.
|
|
20
|
+
//
|
|
21
|
+
// 🔴 THE AGENT NAME IS CONFIGURED, NEVER THE BRAND. A listing names the operator's agent. A package
|
|
22
|
+
// that carries the project's own name is refused on both routes, because it lists the operator's
|
|
23
|
+
// deployment as the project. The shipped project-mark icon FILES (compared byte for byte, so a
|
|
24
|
+
// re-exported copy of the mark is not caught) block on the store route and warn on the org route:
|
|
25
|
+
// OPE-2334 makes the mark the default avatar of a tenant's own deployment, and a store listing is
|
|
26
|
+
// the case where the mark would present someone else's agent as the project's.
|
|
27
|
+
//
|
|
28
|
+
// This module imports only Node built-ins, the same rule as `app-manifest.ts`: the ops wiring in
|
|
29
|
+
// `ops.ts` reads the settings, the env and the icon files, and passes the values in.
|
|
30
|
+
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { crc32, inflateSync } from "node:zlib";
|
|
33
|
+
import { TEAMS_APP_NAME_MAX, TEAMS_DEFAULT_APP_NAME, TEAMS_DEPLOY_HOST_PLACEHOLDER } from "./app-manifest.js";
|
|
34
|
+
|
|
35
|
+
/** The two listing routes. */
|
|
36
|
+
export type TeamsListingRoute = "org" | "store";
|
|
37
|
+
|
|
38
|
+
/** How a store package treats the `team` (channel) scope. See {@link buildTeamsAppPackage}. */
|
|
39
|
+
export type TeamsChannelFeatures = "omit" | "tier1";
|
|
40
|
+
|
|
41
|
+
/** The SHA-256 of the project-mark FILES the repository ships as `teams-app/color.png` and
|
|
42
|
+
* `teams-app/outline.png` (`scripts/teams-icons-test.ts` pins the same bytes). */
|
|
43
|
+
export const TEAMS_PROJECT_MARK_ICON_SHA256: Readonly<Record<"color" | "outline", string>> = {
|
|
44
|
+
color: "7e9ce93dbf0414a6509b6cd8df3b5f1d2fcded333c66f86f3b6b911496106a89",
|
|
45
|
+
outline: "806277cc2dfc1aa870219acc11522145214a591aac55a548660ecfe3b3303646",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The store schema floor for a channel app: "Starting July 2026, new Teams Store submissions for
|
|
49
|
+
* channel-enabled apps must use app manifest schema version 1.25 or later" (store guidelines). */
|
|
50
|
+
const TEAMS_STORE_CHANNEL_SCHEMA = "1.25";
|
|
51
|
+
|
|
52
|
+
/** The RSC grants that apply only to the `team` scope. A store package without the scope drops them. */
|
|
53
|
+
const TEAM_SCOPE_RSC = new Set(["ChannelMessage.Read.Group", "ChannelMember.Read.Group"]);
|
|
54
|
+
|
|
55
|
+
/** The schema's own patterns and limits (v1.17 and v1.25 agree on each). */
|
|
56
|
+
const GUID = /^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/;
|
|
57
|
+
const SEMVER = /^([0-9]|[1-9]+[0-9]*)\.([0-9]|[1-9]+[0-9]*)\.([0-9]|[1-9]+[0-9]*)$/;
|
|
58
|
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
59
|
+
const LIMITS = { nameFull: 100, descriptionShort: 80, descriptionFull: 4000, developerName: 32, mpnId: 10 } as const;
|
|
60
|
+
|
|
61
|
+
/** Store guideline "App Name": no Microsoft product name, no lead with a core Teams feature, no
|
|
62
|
+
* preproduction word. Whole words, any case. */
|
|
63
|
+
const MICROSOFT_PRODUCT_WORDS = ["Teams", "Microsoft", "Excel", "PowerPoint", "Word", "OneDrive", "SharePoint", "OneNote", "Azure", "Surface", "Xbox"];
|
|
64
|
+
const CORE_TEAMS_FEATURE_WORDS = ["Chat", "Contacts", "Calendar", "Calls", "Files", "Activity", "Teams", "Help"];
|
|
65
|
+
const PREPRODUCTION_WORDS = ["Beta", "Dev", "Preview", "UAT"];
|
|
66
|
+
|
|
67
|
+
/** Store guideline "External domains": domains a store package must not declare. */
|
|
68
|
+
const FORBIDDEN_VALID_DOMAINS = [
|
|
69
|
+
"azurewebsites.net",
|
|
70
|
+
"azureedge.com",
|
|
71
|
+
"microsoft.com",
|
|
72
|
+
"microsoftonline.com",
|
|
73
|
+
"onmicrosoft.com",
|
|
74
|
+
"go.microsoft.com",
|
|
75
|
+
"teams.microsoft.com",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** One finding. `block` stops the package; `warn` ships it and tells the operator. */
|
|
79
|
+
export interface TeamsPackageFinding {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly level: "block" | "warn";
|
|
82
|
+
readonly detail: string;
|
|
83
|
+
readonly fix?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What {@link buildTeamsAppPackage} needs. Every value is already resolved by the caller. */
|
|
87
|
+
export interface TeamsAppPackageInput {
|
|
88
|
+
/** The manifest from `teamsAppManifestTemplate`, with the deploy host substituted. */
|
|
89
|
+
readonly manifest: string;
|
|
90
|
+
readonly route: TeamsListingRoute;
|
|
91
|
+
/** The Azure Bot's Application (client) ID — `MICROSOFT_APP_ID`. */
|
|
92
|
+
readonly appId?: string;
|
|
93
|
+
/** The publisher's name. On the store route it must equal the Partner Center publisher name. */
|
|
94
|
+
readonly developerName?: string;
|
|
95
|
+
/** The Microsoft AI Cloud Partner Program ID (`developer.mpnId`). */
|
|
96
|
+
readonly partnerId?: string;
|
|
97
|
+
/** The package `version`. Absent ⇒ the template's version. A store update must raise it. */
|
|
98
|
+
readonly version?: string;
|
|
99
|
+
/** An email address or https URL where a person reports harmful AI content. The store route
|
|
100
|
+
* requires one, and the package adds it to `description.full`. */
|
|
101
|
+
readonly supportContact?: string;
|
|
102
|
+
/** The store route's choice for the channel scope. Absent on the store route ⇒ refused. */
|
|
103
|
+
readonly channelFeatures?: TeamsChannelFeatures;
|
|
104
|
+
/** Whether `policyPages.privacy` and `policyPages.terms` hold real text. */
|
|
105
|
+
readonly policyPages: { readonly privacy: boolean; readonly terms: boolean };
|
|
106
|
+
/** The icon bytes, absent when the file is missing. */
|
|
107
|
+
readonly icons: { readonly color?: Uint8Array; readonly outline?: Uint8Array };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The result. `archive` is present only when no finding blocks. */
|
|
111
|
+
export interface TeamsAppPackage {
|
|
112
|
+
readonly findings: readonly TeamsPackageFinding[];
|
|
113
|
+
/** The filled manifest, as it goes into the zip (also returned when a finding blocks). */
|
|
114
|
+
readonly manifest: string;
|
|
115
|
+
readonly archive?: Uint8Array;
|
|
116
|
+
/** A file name for the zip, from the app name. */
|
|
117
|
+
readonly fileName: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type Manifest = {
|
|
121
|
+
$schema?: string;
|
|
122
|
+
manifestVersion: string;
|
|
123
|
+
version: string;
|
|
124
|
+
id: string;
|
|
125
|
+
developer: { name: string; mpnId?: string; websiteUrl: string; privacyUrl: string; termsOfUseUrl: string };
|
|
126
|
+
name: { short: string; full: string };
|
|
127
|
+
description: { short: string; full: string };
|
|
128
|
+
accentColor: string;
|
|
129
|
+
bots?: { botId: string; scopes: string[] }[];
|
|
130
|
+
webApplicationInfo?: { id: string; resource?: string };
|
|
131
|
+
authorization?: { permissions?: { resourceSpecific?: { name: string; type: string }[] } };
|
|
132
|
+
validDomains?: string[];
|
|
133
|
+
supportsChannelFeatures?: string;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const wordIn = (text: string, words: readonly string[]): string | undefined =>
|
|
137
|
+
words.find((w) => new RegExp(`(^|[^A-Za-z0-9])${w}($|[^A-Za-z0-9])`, "i").test(text));
|
|
138
|
+
|
|
139
|
+
/** A file-name slug from the app name: lowercase letters, digits and hyphens. */
|
|
140
|
+
function slug(name: string): string {
|
|
141
|
+
const s = name
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
144
|
+
.replace(/^-+|-+$/g, "");
|
|
145
|
+
return s === "" ? "teams-app" : s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** An email address or an https URL — the two forms the store guideline names. */
|
|
149
|
+
function isSupportContact(raw: string): boolean {
|
|
150
|
+
const v = raw.trim();
|
|
151
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || /^https:\/\/[^\s]+$/i.test(v);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fill the manifest, check it and the icons for the route, and zip it when nothing blocks.
|
|
156
|
+
*
|
|
157
|
+
* THE STORE ROUTE AND CHANNELS. A store package with the `team` scope must use schema 1.25 and
|
|
158
|
+
* declare `supportsChannelFeatures: "tier1"`, which also lets a team install the app in shared and
|
|
159
|
+
* private channels. That is a choice about where the agent runs, so the operator makes it:
|
|
160
|
+
* `"tier1"` keeps channels and declares the flag; `"omit"` drops the `team` scope and its two RSC
|
|
161
|
+
* grants, which keeps the agent to 1:1 and group chats.
|
|
162
|
+
*/
|
|
163
|
+
export function buildTeamsAppPackage(input: TeamsAppPackageInput): TeamsAppPackage {
|
|
164
|
+
const findings: TeamsPackageFinding[] = [];
|
|
165
|
+
const block = (id: string, detail: string, fix?: string) => findings.push({ id, level: "block", detail, ...(fix ? { fix } : {}) });
|
|
166
|
+
const warn = (id: string, detail: string, fix?: string) => findings.push({ id, level: "warn", detail, ...(fix ? { fix } : {}) });
|
|
167
|
+
const store = input.route === "store";
|
|
168
|
+
|
|
169
|
+
const appId = input.appId?.trim() ?? "";
|
|
170
|
+
const developerName = input.developerName?.trim() ?? "";
|
|
171
|
+
const m = JSON.parse(
|
|
172
|
+
input.manifest
|
|
173
|
+
.split("{{MICROSOFT_APP_ID}}")
|
|
174
|
+
.join(appId === "" ? "{{MICROSOFT_APP_ID}}" : JSON.stringify(appId).slice(1, -1))
|
|
175
|
+
.split("{{DEVELOPER_NAME}}")
|
|
176
|
+
.join(developerName === "" ? "{{DEVELOPER_NAME}}" : JSON.stringify(developerName).slice(1, -1)),
|
|
177
|
+
) as Manifest;
|
|
178
|
+
|
|
179
|
+
if (input.version !== undefined) m.version = input.version.trim();
|
|
180
|
+
const partnerId = input.partnerId?.trim();
|
|
181
|
+
if (partnerId) m.developer = { ...m.developer, mpnId: partnerId };
|
|
182
|
+
|
|
183
|
+
// --- the store route's channel choice ---------------------------------------------------------
|
|
184
|
+
if (store) {
|
|
185
|
+
if (input.channelFeatures === "tier1") {
|
|
186
|
+
m.$schema = `https://developer.microsoft.com/en-us/json-schemas/teams/v${TEAMS_STORE_CHANNEL_SCHEMA}/MicrosoftTeams.schema.json`;
|
|
187
|
+
m.manifestVersion = TEAMS_STORE_CHANNEL_SCHEMA;
|
|
188
|
+
m.supportsChannelFeatures = "tier1";
|
|
189
|
+
warn(
|
|
190
|
+
"store-channel-tier1",
|
|
191
|
+
"the package declares supportsChannelFeatures \"tier1\", so a team can install the agent in shared and private channels. " +
|
|
192
|
+
"The agent maps every channel that is not confirmed team-wide to a private surface, but its behaviour in a shared " +
|
|
193
|
+
"channel with external members has not been tested on a live tenant.",
|
|
194
|
+
"test a shared and a private channel on your own tenant before you submit — docs/deploy/surfaces/teams.md § Listing in the Teams admin centre",
|
|
195
|
+
);
|
|
196
|
+
} else if (input.channelFeatures === "omit") {
|
|
197
|
+
for (const bot of m.bots ?? []) bot.scopes = bot.scopes.filter((s) => s !== "team");
|
|
198
|
+
const rsc = m.authorization?.permissions?.resourceSpecific;
|
|
199
|
+
if (rsc) m.authorization = { permissions: { resourceSpecific: rsc.filter((p) => !TEAM_SCOPE_RSC.has(p.name)) } };
|
|
200
|
+
} else {
|
|
201
|
+
block(
|
|
202
|
+
"store-channel-choice",
|
|
203
|
+
"a store package with the team (channel) scope must use manifest schema 1.25 and declare supportsChannelFeatures \"tier1\" " +
|
|
204
|
+
"(Teams Store validation guidelines, from July 2026). That flag also opens shared and private channels.",
|
|
205
|
+
"pass channelFeatures \"tier1\" to keep channels, or \"omit\" to list the agent for 1:1 and group chats only",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- the store route's AI-content rules --------------------------------------------------------
|
|
211
|
+
if (store) {
|
|
212
|
+
const contact = input.supportContact?.trim() ?? "";
|
|
213
|
+
if (contact === "") {
|
|
214
|
+
block(
|
|
215
|
+
"store-report-contact",
|
|
216
|
+
"a store app with AI-generated content must give users a way to report harmful content (store guideline \"Apps with AI-generated content\").",
|
|
217
|
+
"pass supportContact: an email address or an https URL that a person reads",
|
|
218
|
+
);
|
|
219
|
+
} else if (!isSupportContact(contact)) {
|
|
220
|
+
block("store-report-contact", "supportContact is not an email address or an https URL.", "pass a monitored email address or an https URL");
|
|
221
|
+
} else {
|
|
222
|
+
m.description = { ...m.description, full: `${m.description.full} To report a harmful or offensive reply, contact ${contact}.` };
|
|
223
|
+
}
|
|
224
|
+
if (!/\bAI\b/.test(m.description.full)) {
|
|
225
|
+
block("store-ai-disclosure", "description.full does not say that the app uses AI (store guideline \"Apps with AI-generated content\").");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- placeholders ------------------------------------------------------------------------------
|
|
230
|
+
const filled = JSON.stringify(m, null, 2);
|
|
231
|
+
if (filled.includes("{{MICROSOFT_APP_ID}}")) {
|
|
232
|
+
block("app-id", "the Azure Bot's application id is not set, so the package still carries {{MICROSOFT_APP_ID}}.", "set MICROSOFT_APP_ID in .env.local (npx oe env set MICROSOFT_APP_ID)");
|
|
233
|
+
}
|
|
234
|
+
if (filled.includes("{{DEVELOPER_NAME}}")) block("developer-name", "the developer name is empty.", "pass developerName: your organisation's name");
|
|
235
|
+
if (filled.includes(TEAMS_DEPLOY_HOST_PLACEHOLDER)) block("host", "the package still carries the deploy-host placeholder.", "pass baseUrl: this deployment's public origin");
|
|
236
|
+
|
|
237
|
+
// --- the schema ---------------------------------------------------------------------------------
|
|
238
|
+
if (appId !== "" && !GUID.test(appId)) block("app-id", "MICROSOFT_APP_ID is not a GUID (manifest schema: id).");
|
|
239
|
+
const bot = m.bots?.[0];
|
|
240
|
+
if (bot && bot.botId !== m.id) block("bot-id", "bots[0].botId differs from id.");
|
|
241
|
+
if (m.webApplicationInfo && m.webApplicationInfo.id !== m.id) block("web-application-info", "webApplicationInfo.id differs from id.");
|
|
242
|
+
if (!SEMVER.test(m.version)) block("version", `version "${m.version}" is not MAJOR.MINOR.PATCH (manifest schema).`);
|
|
243
|
+
if (m.name.short.length > TEAMS_APP_NAME_MAX) block("name", `name.short is ${m.name.short.length} characters; the schema allows ${TEAMS_APP_NAME_MAX}.`);
|
|
244
|
+
if (m.name.full.length > LIMITS.nameFull) block("name", `name.full is ${m.name.full.length} characters; the schema allows ${LIMITS.nameFull}.`);
|
|
245
|
+
if (m.description.short.length > LIMITS.descriptionShort) block("description", `description.short is longer than ${LIMITS.descriptionShort} characters.`);
|
|
246
|
+
if (m.description.full.length > LIMITS.descriptionFull) block("description", `description.full is longer than ${LIMITS.descriptionFull} characters.`);
|
|
247
|
+
if (developerName.length > LIMITS.developerName) {
|
|
248
|
+
block("developer-name", `the developer name is ${developerName.length} characters; the schema allows ${LIMITS.developerName}.`);
|
|
249
|
+
}
|
|
250
|
+
if (partnerId !== undefined && partnerId !== "" && !new RegExp(`^[0-9]{1,${LIMITS.mpnId}}$`).test(partnerId)) {
|
|
251
|
+
block("partner-id", `partnerId must be digits, at most ${LIMITS.mpnId} (manifest schema: developer.mpnId).`);
|
|
252
|
+
}
|
|
253
|
+
if (!HEX_COLOR.test(m.accentColor)) block("accent-color", "accentColor is not a #RRGGBB colour.");
|
|
254
|
+
for (const [field, url] of Object.entries({ websiteUrl: m.developer.websiteUrl, privacyUrl: m.developer.privacyUrl, termsOfUseUrl: m.developer.termsOfUseUrl })) {
|
|
255
|
+
if (!/^https:\/\//i.test(url)) block("developer-url", `developer.${field} is not an https URL.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- the name: configured, never the brand, and the store's naming rules ------------------------
|
|
259
|
+
const name = m.name.short;
|
|
260
|
+
if (wordIn(name, [TEAMS_DEFAULT_APP_NAME.replace(/ /g, "[^A-Za-z0-9]*")])) {
|
|
261
|
+
block(
|
|
262
|
+
"name-brand",
|
|
263
|
+
`the app name "${name}" is the project's name. A listing names your agent, not the project.`,
|
|
264
|
+
"set identity.name in agent/settings/settings.ts, or pass appName",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const product = wordIn(name, MICROSOFT_PRODUCT_WORDS);
|
|
268
|
+
if (product) {
|
|
269
|
+
(store ? block : warn)("name-microsoft", `the app name contains "${product}". The store refuses a Microsoft product name in an app name.`, "choose a name without it");
|
|
270
|
+
}
|
|
271
|
+
const lead = CORE_TEAMS_FEATURE_WORDS.find((w) => new RegExp(`^${w}($|[^A-Za-z0-9])`, "i").test(name));
|
|
272
|
+
if (lead) (store ? block : warn)("name-lead", `the app name leads with "${lead}", a core Teams feature. The store refuses that.`);
|
|
273
|
+
const pre = wordIn(name, PREPRODUCTION_WORDS);
|
|
274
|
+
if (pre) (store ? block : warn)("name-preproduction", `the app name contains "${pre}". The store accepts production manifests only.`);
|
|
275
|
+
|
|
276
|
+
// --- validDomains -------------------------------------------------------------------------------
|
|
277
|
+
for (const d of m.validDomains ?? []) {
|
|
278
|
+
const lower = d.toLowerCase();
|
|
279
|
+
if (/[/:]/.test(d.replace(/:\d+$/, "")) || /^https?/i.test(d)) block("valid-domains", `validDomains holds "${d}", which is not a bare domain.`);
|
|
280
|
+
if (d.includes("*")) (store ? block : warn)("valid-domains", `validDomains holds the wildcard "${d}".`);
|
|
281
|
+
const bad = FORBIDDEN_VALID_DOMAINS.find((f) => lower === f || lower.endsWith(`.${f}`));
|
|
282
|
+
if (bad) {
|
|
283
|
+
(store ? block : warn)(
|
|
284
|
+
"valid-domains",
|
|
285
|
+
`validDomains holds "${d}", under ${bad}, which the store refuses.`,
|
|
286
|
+
"serve the deployment on a domain your organisation controls",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (lower === "localhost" || lower.startsWith("localhost:") || /^\d+\.\d+\.\d+\.\d+(:\d+)?$/.test(lower)) {
|
|
290
|
+
block("valid-domains", `validDomains holds "${d}", which no tenant can reach.`, "pass the deployment's public origin as baseUrl");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- the policy pages ---------------------------------------------------------------------------
|
|
295
|
+
for (const kind of ["privacy", "terms"] as const) {
|
|
296
|
+
if (!input.policyPages[kind]) {
|
|
297
|
+
(store ? block : warn)(
|
|
298
|
+
`policy-${kind}`,
|
|
299
|
+
`policyPages.${kind} is not declared, so /${kind} serves the "not published yet" page. An admin or a store reviewer reads it.`,
|
|
300
|
+
`declare policyPages.${kind} in agent/settings/settings.ts and deploy before you upload`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- the partner id -----------------------------------------------------------------------------
|
|
306
|
+
if (store && !partnerId) {
|
|
307
|
+
warn(
|
|
308
|
+
"partner-id",
|
|
309
|
+
"developer.mpnId is not set. The store guidelines recommend it, and publisher attestation needs a verified partner account.",
|
|
310
|
+
"pass partnerId: your Microsoft AI Cloud Partner Program ID",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- the icons ----------------------------------------------------------------------------------
|
|
315
|
+
checkIcon("color", input.icons.color, 192, store, findings);
|
|
316
|
+
checkIcon("outline", input.icons.outline, 32, store, findings);
|
|
317
|
+
|
|
318
|
+
const fileName = `${slug(name)}-teams-${m.version}.zip`;
|
|
319
|
+
if (findings.some((f) => f.level === "block")) return { findings, manifest: filled, fileName };
|
|
320
|
+
const archive = zipStored([
|
|
321
|
+
{ name: "manifest.json", data: new TextEncoder().encode(filled) },
|
|
322
|
+
{ name: "color.png", data: input.icons.color! },
|
|
323
|
+
{ name: "outline.png", data: input.icons.outline! },
|
|
324
|
+
]);
|
|
325
|
+
return { findings, manifest: filled, archive, fileName };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Check one icon: present, the shipped project-mark file refused on the store route (warned on the
|
|
329
|
+
* org route), a PNG of the right size, and for the outline
|
|
330
|
+
* icon a white glyph on a transparent background (store guideline "App icons"). */
|
|
331
|
+
function checkIcon(kind: "color" | "outline", bytes: Uint8Array | undefined, size: number, store: boolean, findings: TeamsPackageFinding[]): void {
|
|
332
|
+
const block = (detail: string, fix?: string) => findings.push({ id: `icon-${kind}`, level: "block", detail, ...(fix ? { fix } : {}) });
|
|
333
|
+
if (bytes === undefined) {
|
|
334
|
+
block(`${kind}.png is missing.`, `pass the path of your ${size}x${size} PNG as icons.${kind}`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (createHash("sha256").update(bytes).digest("hex") === TEAMS_PROJECT_MARK_ICON_SHA256[kind]) {
|
|
338
|
+
const detail = `${kind}.png is the shipped project-mark file. A store listing shows your agent's own icon.`;
|
|
339
|
+
const fix = `replace it with your own ${size}x${size} PNG`;
|
|
340
|
+
if (store) {
|
|
341
|
+
block(detail, fix);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
findings.push({ id: `icon-${kind}`, level: "warn", detail, fix });
|
|
345
|
+
}
|
|
346
|
+
let png: DecodedPng;
|
|
347
|
+
try {
|
|
348
|
+
png = decodePng(bytes);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
block(`${kind}.png cannot be read: ${err instanceof Error ? err.message : String(err)}.`, "export it as an 8-bit, non-interlaced PNG");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (png.width !== size || png.height !== size) {
|
|
354
|
+
block(`${kind}.png is ${png.width}x${png.height}; Teams needs ${size}x${size}.`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (kind === "outline") {
|
|
358
|
+
let transparent = 0;
|
|
359
|
+
let white = 0;
|
|
360
|
+
let other = 0;
|
|
361
|
+
for (let i = 0; i < png.rgba.length; i += 4) {
|
|
362
|
+
const a = png.rgba[i + 3]!;
|
|
363
|
+
if (a === 0) transparent++;
|
|
364
|
+
else if (png.rgba[i]! >= 0xf0 && png.rgba[i + 1]! >= 0xf0 && png.rgba[i + 2]! >= 0xf0) white++;
|
|
365
|
+
else other++;
|
|
366
|
+
}
|
|
367
|
+
if (other > 0 || transparent === 0 || white === 0) {
|
|
368
|
+
block(
|
|
369
|
+
`outline.png must be white on a transparent background (${white} white, ${transparent} transparent, ${other} other pixels).`,
|
|
370
|
+
"draw the glyph in white (#FFFFFF) on a fully transparent background",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A decoded PNG: RGBA, 4 bytes per pixel, row-major. */
|
|
377
|
+
export interface DecodedPng {
|
|
378
|
+
readonly width: number;
|
|
379
|
+
readonly height: number;
|
|
380
|
+
readonly rgba: Uint8Array;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
384
|
+
|
|
385
|
+
/** A small PNG decoder: 8-bit greyscale, truecolour, palette, and both alpha forms, not interlaced.
|
|
386
|
+
* Anything else throws, and the caller blocks rather than guesses. */
|
|
387
|
+
export function decodePng(bytes: Uint8Array): DecodedPng {
|
|
388
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
389
|
+
if (buf.length < 8 || PNG_SIGNATURE.some((b, i) => buf[i] !== b)) throw new Error("not a PNG");
|
|
390
|
+
let width = 0;
|
|
391
|
+
let height = 0;
|
|
392
|
+
let colourType = -1;
|
|
393
|
+
let palette: Buffer | undefined;
|
|
394
|
+
let trns: Buffer | undefined;
|
|
395
|
+
const idat: Buffer[] = [];
|
|
396
|
+
for (let at = 8; at + 8 <= buf.length; ) {
|
|
397
|
+
const len = buf.readUInt32BE(at);
|
|
398
|
+
const type = buf.toString("latin1", at + 4, at + 8);
|
|
399
|
+
const data = buf.subarray(at + 8, at + 8 + len);
|
|
400
|
+
if (type === "IHDR") {
|
|
401
|
+
width = data.readUInt32BE(0);
|
|
402
|
+
height = data.readUInt32BE(4);
|
|
403
|
+
if (data[8] !== 8) throw new Error(`bit depth ${data[8]} (only 8 is read)`);
|
|
404
|
+
if (data[12] !== 0) throw new Error("interlaced");
|
|
405
|
+
colourType = data[9]!;
|
|
406
|
+
} else if (type === "PLTE") palette = data;
|
|
407
|
+
else if (type === "tRNS") trns = data;
|
|
408
|
+
else if (type === "IDAT") idat.push(data);
|
|
409
|
+
else if (type === "IEND") break;
|
|
410
|
+
at += 12 + len;
|
|
411
|
+
}
|
|
412
|
+
const channels = ({ 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 } as Record<number, number>)[colourType];
|
|
413
|
+
if (channels === undefined) throw new Error(`colour type ${colourType}`);
|
|
414
|
+
if (colourType === 3 && palette === undefined) throw new Error("palette image without PLTE");
|
|
415
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
416
|
+
const stride = width * channels;
|
|
417
|
+
if (raw.length < height * (stride + 1)) throw new Error("truncated image data");
|
|
418
|
+
const px = new Uint8Array(height * stride);
|
|
419
|
+
for (let y = 0; y < height; y++) {
|
|
420
|
+
const filter = raw[y * (stride + 1)]!;
|
|
421
|
+
const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
422
|
+
for (let x = 0; x < stride; x++) {
|
|
423
|
+
const a = x >= channels ? px[y * stride + x - channels]! : 0;
|
|
424
|
+
const b = y > 0 ? px[(y - 1) * stride + x]! : 0;
|
|
425
|
+
const c = x >= channels && y > 0 ? px[(y - 1) * stride + x - channels]! : 0;
|
|
426
|
+
let v = row[x]!;
|
|
427
|
+
if (filter === 1) v += a;
|
|
428
|
+
else if (filter === 2) v += b;
|
|
429
|
+
else if (filter === 3) v += (a + b) >> 1;
|
|
430
|
+
else if (filter === 4) {
|
|
431
|
+
const p = a + b - c;
|
|
432
|
+
const pa = Math.abs(p - a);
|
|
433
|
+
const pb = Math.abs(p - b);
|
|
434
|
+
const pc = Math.abs(p - c);
|
|
435
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
436
|
+
} else if (filter !== 0) throw new Error(`filter ${filter}`);
|
|
437
|
+
px[y * stride + x] = v & 0xff;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
441
|
+
for (let i = 0; i < width * height; i++) {
|
|
442
|
+
const s = px.subarray(i * channels, (i + 1) * channels);
|
|
443
|
+
let r: number, g: number, b: number, alpha: number;
|
|
444
|
+
if (colourType === 0) {
|
|
445
|
+
r = g = b = s[0]!;
|
|
446
|
+
alpha = trns && trns.length >= 2 && trns.readUInt16BE(0) === s[0] ? 0 : 255;
|
|
447
|
+
} else if (colourType === 2) {
|
|
448
|
+
[r, g, b] = [s[0]!, s[1]!, s[2]!];
|
|
449
|
+
alpha = trns && trns.length >= 6 && trns.readUInt16BE(0) === r && trns.readUInt16BE(2) === g && trns.readUInt16BE(4) === b ? 0 : 255;
|
|
450
|
+
} else if (colourType === 3) {
|
|
451
|
+
const idx = s[0]!;
|
|
452
|
+
[r, g, b] = [palette![idx * 3] ?? 0, palette![idx * 3 + 1] ?? 0, palette![idx * 3 + 2] ?? 0];
|
|
453
|
+
alpha = trns && idx < trns.length ? trns[idx]! : 255;
|
|
454
|
+
} else if (colourType === 4) {
|
|
455
|
+
r = g = b = s[0]!;
|
|
456
|
+
alpha = s[1]!;
|
|
457
|
+
} else {
|
|
458
|
+
[r, g, b, alpha] = [s[0]!, s[1]!, s[2]!, s[3]!];
|
|
459
|
+
}
|
|
460
|
+
rgba.set([r, g, b, alpha], i * 4);
|
|
461
|
+
}
|
|
462
|
+
return { width, height, rgba };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A zip of STORED (uncompressed) entries at the root — the flat layout Teams requires. */
|
|
466
|
+
function zipStored(entries: readonly { readonly name: string; readonly data: Uint8Array }[]): Uint8Array {
|
|
467
|
+
const locals: Buffer[] = [];
|
|
468
|
+
const centrals: Buffer[] = [];
|
|
469
|
+
let offset = 0;
|
|
470
|
+
// 1980-01-01 00:00 in DOS time: a fixed stamp, so the same inputs give the same bytes.
|
|
471
|
+
const dosTime = 0;
|
|
472
|
+
const dosDate = (0 << 9) | (1 << 5) | 1;
|
|
473
|
+
for (const e of entries) {
|
|
474
|
+
const name = Buffer.from(e.name, "utf8");
|
|
475
|
+
const data = Buffer.from(e.data.buffer, e.data.byteOffset, e.data.byteLength);
|
|
476
|
+
const crc = crc32(data);
|
|
477
|
+
const local = Buffer.alloc(30);
|
|
478
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
479
|
+
local.writeUInt16LE(20, 4);
|
|
480
|
+
local.writeUInt16LE(0x0800, 6);
|
|
481
|
+
local.writeUInt16LE(0, 8);
|
|
482
|
+
local.writeUInt16LE(dosTime, 10);
|
|
483
|
+
local.writeUInt16LE(dosDate, 12);
|
|
484
|
+
local.writeUInt32LE(crc, 14);
|
|
485
|
+
local.writeUInt32LE(data.length, 18);
|
|
486
|
+
local.writeUInt32LE(data.length, 22);
|
|
487
|
+
local.writeUInt16LE(name.length, 26);
|
|
488
|
+
local.writeUInt16LE(0, 28);
|
|
489
|
+
const central = Buffer.alloc(46);
|
|
490
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
491
|
+
central.writeUInt16LE(20, 4);
|
|
492
|
+
central.writeUInt16LE(20, 6);
|
|
493
|
+
central.writeUInt16LE(0x0800, 8);
|
|
494
|
+
central.writeUInt16LE(0, 10);
|
|
495
|
+
central.writeUInt16LE(dosTime, 12);
|
|
496
|
+
central.writeUInt16LE(dosDate, 14);
|
|
497
|
+
central.writeUInt32LE(crc, 16);
|
|
498
|
+
central.writeUInt32LE(data.length, 20);
|
|
499
|
+
central.writeUInt32LE(data.length, 24);
|
|
500
|
+
central.writeUInt16LE(name.length, 28);
|
|
501
|
+
central.writeUInt32LE(offset, 42);
|
|
502
|
+
locals.push(local, name, data);
|
|
503
|
+
centrals.push(Buffer.concat([central, name]));
|
|
504
|
+
offset += local.length + name.length + data.length;
|
|
505
|
+
}
|
|
506
|
+
const dir = Buffer.concat(centrals);
|
|
507
|
+
const end = Buffer.alloc(22);
|
|
508
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
509
|
+
end.writeUInt16LE(entries.length, 8);
|
|
510
|
+
end.writeUInt16LE(entries.length, 10);
|
|
511
|
+
end.writeUInt32LE(dir.length, 12);
|
|
512
|
+
end.writeUInt32LE(offset, 16);
|
|
513
|
+
return new Uint8Array(Buffer.concat([...locals, dir, end]));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The ordered steps after the zip exists, per route. */
|
|
517
|
+
export const TEAMS_LISTING_STEPS: Readonly<Record<TeamsListingRoute, readonly string[]>> = {
|
|
518
|
+
org: [
|
|
519
|
+
"Open the Teams admin centre: https://admin.teams.microsoft.com → Teams apps → Manage apps → Upload new app, and pick the zip.",
|
|
520
|
+
"Open the app's Permissions tab and grant org-wide consent if your policy needs it. The RSC grants are consented per team or chat at install.",
|
|
521
|
+
"Allow the app in an app permission policy and, if you want it pinned, an app setup policy.",
|
|
522
|
+
"Install it in one team and one group chat, and @mention it in each to prove the round trip.",
|
|
523
|
+
],
|
|
524
|
+
store: [
|
|
525
|
+
"Mark the bot's Entra app registration publisher verified (Entra → App registrations → Branding & properties → Publisher verification). The store requires it.",
|
|
526
|
+
"Make the Azure Bot resource's display name, icon, privacy URL and terms URL match the manifest. The reviewer compares them.",
|
|
527
|
+
"Upload the zip in Partner Center → Marketplace offers → Microsoft 365 and Copilot → your Teams app offer → Packages.",
|
|
528
|
+
"Use the same app name, developer name, short description and long description in Partner Center as the manifest carries.",
|
|
529
|
+
"Give the reviewer a test tenant, test accounts and the testing notes from docs/deploy/surfaces/teams.md § Listing in the Teams admin centre.",
|
|
530
|
+
"After the app is live, complete publisher attestation in Partner Center (Microsoft 365 App Compliance). A new app cannot attest before it is listed.",
|
|
531
|
+
],
|
|
532
|
+
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// route can tell an OE approval tap from an eve HITL answer without a second check.
|
|
17
17
|
|
|
18
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";
|
|
19
|
+
import { approvalLabel, optionsWithoutConfirm, pendingCardLayout, plainSummaryForRow } from "@open-everywhere/core/agent/adapter-api.js";
|
|
20
20
|
|
|
21
21
|
/** The `invoke` activity name Teams gives an `Action.Execute` tap. */
|
|
22
22
|
export const TEAMS_ADAPTIVE_CARD_ACTION_INVOKE = "adaptiveCard/action";
|
|
@@ -142,11 +142,12 @@ const OPTION_STYLE: Record<CardOptionTone, "positive" | "destructive" | undefine
|
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
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
|
|
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
146
|
*/
|
|
147
147
|
export function pendingTeamsCard(row: PendingActionRow, summaryLines: readonly string[]): TeamsAdaptiveCard {
|
|
148
148
|
const layout = pendingCardLayout(row, summaryLines);
|
|
149
|
-
|
|
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]));
|
|
150
151
|
return card(approvalCardBody(row, summaryLines, `Requested by ${layout.requestedBy} · ${layout.expiry}`), actions);
|
|
151
152
|
}
|
|
152
153
|
|