From 20797777169ebcb6da7aa3b8866f6a2667fd0eeb Mon Sep 17 00:00:00 2001 From: anchenyi <162104711+anchenyi@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:00:22 +0800 Subject: [PATCH] feat: switch to builder API for declarative agent apps (#13056) * feat: add builer api --- .../src/component/m365/packageService.ts | 145 ++++++++- .../component/driver/m365/acquire.test.ts | 4 +- .../component/m365/packageService.test.ts | 279 +++++++++++++++++- .../fx-core/tests/component/m365/success.zip | Bin 0 -> 1839 bytes 4 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 packages/fx-core/tests/component/m365/success.zip diff --git a/packages/fx-core/src/component/m365/packageService.ts b/packages/fx-core/src/component/m365/packageService.ts index 57b5ef6e84..599015911b 100644 --- a/packages/fx-core/src/component/m365/packageService.ts +++ b/packages/fx-core/src/component/m365/packageService.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { hooks } from "@feathersjs/hooks"; -import { LogProvider, SystemError, UserError } from "@microsoft/teamsfx-api"; +import { LogProvider, SystemError, TeamsAppManifest, UserError } from "@microsoft/teamsfx-api"; import AdmZip from "adm-zip"; import FormData from "form-data"; import fs from "fs-extra"; @@ -20,10 +20,17 @@ import { waitSeconds } from "../../common/utils"; import { WrappedAxiosClient } from "../../common/wrappedAxiosClient"; import { NotExtendedToM365Error } from "./errors"; import { MosServiceEndpoint } from "./serviceConstant"; +import { IsDeclarativeAgentManifest } from "../../common/projectTypeChecker"; +import stripBom from "strip-bom"; const M365ErrorSource = "M365"; const M365ErrorComponent = "PackageService"; +export enum AppScope { + Personal = "Personal", + Shared = "Shared", +} + // Call m365 service for package CRUD export class PackageService { private static sharedInstance: PackageService; @@ -139,14 +146,96 @@ export class PackageService { } @hooks([ErrorContextMW({ source: M365ErrorSource, component: M365ErrorComponent })]) - public async sideLoading(token: string, manifestPath: string): Promise<[string, string]> { + public async sideLoading( + token: string, + packagePath: string, + appScope = AppScope.Personal + ): Promise<[string, string, string]> { + const manifest = this.getManifestFromZip(packagePath); + if (!manifest) { + throw new Error("Invalid app package zip. manifest.json is missing"); + } + const isDelcarativeAgentApp = IsDeclarativeAgentManifest(manifest); + if (isDelcarativeAgentApp) { + const res = await this.sideLoadingV2(token, packagePath, appScope); + let shareLink = ""; + if (appScope == AppScope.Shared) { + shareLink = await this.getShareLink(token, res[0]); + } + return [res[0], res[1], shareLink]; + } else { + const res = await this.sideLoadingV1(token, packagePath); + return [res[0], res[1], ""]; + } + } + // Side loading using Builder API + @hooks([ErrorContextMW({ source: M365ErrorSource, component: M365ErrorComponent })]) + public async sideLoadingV2( + token: string, + manifestPath: string, + appScope: AppScope + ): Promise<[string, string]> { try { this.checkZip(manifestPath); const data = await fs.readFile(manifestPath); const content = new FormData(); content.append("package", data); const serviceUrl = await this.getTitleServiceUrl(token); - this.logger?.verbose("Uploading package ..."); + this.logger?.debug("Uploading package with sideLoading V2 ..."); + const uploadHeaders = content.getHeaders(); + uploadHeaders["Authorization"] = `Bearer ${token}`; + const uploadResponse = await this.axiosInstance.post( + "/builder/v1/users/packages", + content.getBuffer(), + { + baseURL: serviceUrl, + headers: uploadHeaders, + params: { + scope: appScope, + }, + } + ); + + const statusId = uploadResponse.data.statusId; + this.logger?.debug(`Acquiring package with statusId: ${statusId as string} ...`); + + do { + const statusResponse = await this.axiosInstance.get( + `/builder/v1/users/packages/status/${statusId as string}`, + { + baseURL: serviceUrl, + headers: { Authorization: `Bearer ${token}` }, + } + ); + const resCode = statusResponse.status; + this.logger?.debug(`Package status: ${resCode} ...`); + if (resCode === 200) { + const titleId: string = statusResponse.data.titleId; + const appId: string = statusResponse.data.appId; + this.logger?.info(`TitleId: ${titleId}`); + this.logger?.info(`AppId: ${appId}`); + this.logger?.verbose("Sideloading done."); + return [titleId, appId]; + } else { + await waitSeconds(2); + } + } while (true); + } catch (error: any) { + if (error.response) { + error = this.traceError(error); + } + throw assembleError(error, M365ErrorSource); + } + } + @hooks([ErrorContextMW({ source: M365ErrorSource, component: M365ErrorComponent })]) + public async sideLoadingV1(token: string, manifestPath: string): Promise<[string, string]> { + try { + this.checkZip(manifestPath); + const data = await fs.readFile(manifestPath); + const content = new FormData(); + content.append("package", data); + const serviceUrl = await this.getTitleServiceUrl(token); + this.logger?.debug("Uploading package with sideLoading V1 ..."); const uploadHeaders = content.getHeaders(); uploadHeaders["Authorization"] = `Bearer ${token}`; const uploadResponse = await this.axiosInstance.post( @@ -211,6 +300,27 @@ export class PackageService { } } @hooks([ErrorContextMW({ source: M365ErrorSource, component: M365ErrorComponent })]) + public async getShareLink(token: string, titleId: string): Promise { + const serviceUrl = await this.getTitleServiceUrl(token); + try { + const resp = await this.axiosInstance.get( + `/marketplace/v1/users/titles/${titleId}/sharingInfo`, + { + baseURL: serviceUrl, + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return resp.data.unifiedStoreLink; + } catch (error: any) { + if (error.response) { + error = this.traceError(error); + } + throw assembleError(error, M365ErrorSource); + } + } + @hooks([ErrorContextMW({ source: M365ErrorSource, component: M365ErrorComponent })]) public async getLaunchInfoByManifestId(token: string, manifestId: string): Promise { try { const serviceUrl = await this.getTitleServiceUrl(token); @@ -293,6 +403,24 @@ export class PackageService { }); this.logger?.verbose("Unacquiring done."); } catch (error: any) { + // try to delete in the builder API + try { + const serviceUrl = await this.getTitleServiceUrl(token); + this.logger?.verbose(`Unacquiring package with TitleId ${titleId} in builder API...`); + await this.axiosInstance.delete(`/builder/v1/users/titles/${titleId}`, { + baseURL: serviceUrl, + headers: { + Authorization: `Bearer ${token}`, + }, + }); + this.logger?.verbose("Unacquiring using builder api done."); + return; + } catch (subError: any) { + if (subError.response) { + subError = this.traceError(subError); + } + this.logger?.error(subError); + } if (error.response) { error = this.traceError(error); } @@ -440,4 +568,15 @@ export class PackageService { this.logger?.warning(`Please make sure input path is a valid app package zip. ${path}`); } } + + private getManifestFromZip(path: string): TeamsAppManifest | undefined { + const zip = new AdmZip(path); + const manifestEntry = zip.getEntry("manifest.json"); + if (!manifestEntry) { + return undefined; + } + let manifestContent = manifestEntry.getData().toString("utf8"); + manifestContent = stripBom(manifestContent); + return JSON.parse(manifestContent) as TeamsAppManifest; + } } diff --git a/packages/fx-core/tests/component/driver/m365/acquire.test.ts b/packages/fx-core/tests/component/driver/m365/acquire.test.ts index 111f26cd6c..64151727a2 100644 --- a/packages/fx-core/tests/component/driver/m365/acquire.test.ts +++ b/packages/fx-core/tests/component/driver/m365/acquire.test.ts @@ -143,7 +143,9 @@ describe("teamsApp/extendToM365", async () => { ["appId", "MY_APP_ID"], ]); - sinon.stub(PackageService.prototype, "sideLoading").resolves(["test-title-id", "test-app-id"]); + sinon + .stub(PackageService.prototype, "sideLoading") + .resolves(["test-title-id", "test-app-id", ""]); sinon.stub(fs, "pathExists").resolves(true); const result = await acquireDriver.execute(args, mockedDriverContext, outputEnvVarNames); diff --git a/packages/fx-core/tests/component/m365/packageService.test.ts b/packages/fx-core/tests/component/m365/packageService.test.ts index c46c81edd7..0013cbf319 100644 --- a/packages/fx-core/tests/component/m365/packageService.test.ts +++ b/packages/fx-core/tests/component/m365/packageService.test.ts @@ -9,7 +9,7 @@ import fs from "fs-extra"; import "mocha"; import sinon from "sinon"; import { NotExtendedToM365Error } from "../../../src/component/m365/errors"; -import { PackageService } from "../../../src/component/m365/packageService"; +import { AppScope, PackageService } from "../../../src/component/m365/packageService"; import { setTools } from "../../../src/common/globalVars"; import { UnhandledError } from "../../../src/error/common"; import { MockLogProvider } from "../../core/utils"; @@ -344,6 +344,14 @@ describe("Package Service", () => { }, }, }; + axiosPostResponses["/builder/v1/users/packages"] = { + data: { + statusId: "test-status-id-builder-api", + titlePreview: { + titleId: "test-title-id-preview-builder-api", + }, + }, + }; axiosPostResponses["/dev/v1/users/packages/acquisitions"] = { data: { statusId: "test-status-id", @@ -356,8 +364,21 @@ describe("Package Service", () => { appId: "test-app-id", }, }; + axiosGetResponses["/builder/v1/users/packages/status/test-status-id-builder-api"] = { + status: 200, + data: { + titleId: "test-title-id-builder-api", + appId: "test-app-id-builder-api", + }, + }; + axiosGetResponses["/marketplace/v1/users/titles/test-title-id-builder-api/sharingInfo"] = { + data: { + unifiedStoreLink: "https://test-share-link", + }, + }; let packageService = new PackageService("https://test-endpoint"); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({} as any); let actualError: Error | undefined; try { const result = await packageService.sideLoading("test-token", "test-path"); @@ -367,11 +388,97 @@ describe("Package Service", () => { actualError = error; } + chai.assert.isUndefined(actualError); + packageService = new PackageService("https://test-endpoint", logger); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({ + $schema: + "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json", + manifestVersion: "1.19", + version: "1.0.0", + id: "${{TEAMS_APP_ID}}", + developer: { + name: "Teams App, Inc.", + websiteUrl: "https://www.example.com", + privacyUrl: "https://www.example.com/privacy", + termsOfUseUrl: "https://www.example.com/termofuse", + }, + icons: { + color: "color.png", + outline: "outline.png", + }, + name: { + short: "test-manifest", + full: "test-manifest full name", + }, + description: { + short: "Short description for test-manifest", + full: "Full description for test-manifest", + }, + accentColor: "#FFFFFF", + composeExtensions: [], + permissions: ["identity", "messageTeamMembers"], + } as any); + try { + const result = await packageService.sideLoading("test-token", "test-path"); + chai.assert.equal(result[0], "test-title-id"); + chai.assert.equal(result[1], "test-app-id"); + } catch (error: any) { + actualError = error; + } + chai.assert.isUndefined(actualError); packageService = new PackageService("https://test-endpoint", logger); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({ + copilotAgents: { + declarativeAgents: [ + { + id: "declarativeAgent", + file: "declarativeAgent.json", + }, + ], + }, + } as any); + try { + const result = await packageService.sideLoading("test-token", "test-path", AppScope.Shared); + chai.assert.equal(result[0], "test-title-id-builder-api"); + chai.assert.equal(result[1], "test-app-id-builder-api"); + chai.assert.equal(result[2], "https://test-share-link"); + } catch (error: any) { + actualError = error; + } + + chai.assert.isUndefined(actualError); + + // without logger + packageService = new PackageService("https://test-endpoint"); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({ + copilotAgents: { + declarativeAgents: [ + { + id: "declarativeAgent", + file: "declarativeAgent.json", + }, + ], + }, + } as any); try { const result = await packageService.sideLoading("test-token", "test-path"); + chai.assert.equal(result[0], "test-title-id-builder-api"); + chai.assert.equal(result[1], "test-app-id-builder-api"); + chai.assert.equal(result[2], ""); + } catch (error: any) { + actualError = error; + } + + chai.assert.isUndefined(actualError); + + packageService = new PackageService("https://test-endpoint"); + try { + const result = await packageService.sideLoading( + "test-token", + "./tests/component/m365/success.zip" + ); chai.assert.equal(result[0], "test-title-id"); chai.assert.equal(result[1], "test-app-id"); } catch (error: any) { @@ -381,6 +488,60 @@ describe("Package Service", () => { chai.assert.isUndefined(actualError); }); + it("sideloading throws error in get status", async () => { + axiosGetResponses["/config/v1/environment"] = { + data: { + titlesServiceUrl: "https://test-url", + }, + }; + + axiosPostResponses["/builder/v1/users/packages"] = { + data: { + statusId: "test-status-id-builder-api", + titlePreview: { + titleId: "test-title-id-preview-builder-api", + }, + }, + }; + let actualError: Error | undefined; + const packageService = new PackageService("https://test-endpoint", logger); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({ + copilotAgents: { + declarativeAgents: [ + { + id: "declarativeAgent", + file: "declarativeAgent.json", + }, + ], + }, + } as any); + try { + const result = await packageService.sideLoading("test-token", "test-path", AppScope.Shared); + } catch (error: any) { + actualError = error; + } + chai.assert.isDefined(actualError); + + const expectedError = new Error("test-status") as any; + expectedError.response = { + data: { + foo: "bar", + }, + headers: { + traceresponse: "tracing-id", + }, + }; + axiosGetResponses["/builder/v1/users/packages/status/test-status-id-builder-api"] = + expectedError; + actualError = undefined; + try { + const result = await packageService.sideLoading("test-token", "test-path", AppScope.Shared); + } catch (error: any) { + actualError = error; + } + chai.assert.isDefined(actualError); + chai.assert.isTrue(actualError?.message.includes("test-status")); + }); it("sideLoading throws expected error", async () => { axiosGetResponses["/config/v1/environment"] = { data: { @@ -388,8 +549,10 @@ describe("Package Service", () => { }, }; axiosPostResponses["/dev/v1/users/packages"] = new Error("test-post"); + axiosPostResponses["/builder/v1/users/packages"] = new Error("test-post-builder-api"); let packageService = new PackageService("https://test-endpoint"); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({} as any); let actualError: Error | undefined; try { await packageService.sideLoading("test-token", "test-path"); @@ -401,6 +564,8 @@ describe("Package Service", () => { chai.assert.isTrue(actualError?.message.includes("test-post")); packageService = new PackageService("https://test-endpoint", logger); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({} as any); + actualError = undefined; try { await packageService.sideLoading("test-token", "test-path"); } catch (error: any) { @@ -409,6 +574,43 @@ describe("Package Service", () => { chai.assert.isDefined(actualError); chai.assert.isTrue(actualError?.message.includes("test-post")); + + packageService = new PackageService("https://test-endpoint", logger); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({ + copilotAgents: { + declarativeAgents: [ + { + id: "declarativeAgent", + file: "declarativeAgent.json", + }, + ], + }, + } as any); + actualError = undefined; + try { + await packageService.sideLoading("test-token", "test-path"); + } catch (error: any) { + actualError = error; + } + + chai.assert.isDefined(actualError); + chai.assert.isTrue(actualError?.message.includes("test-post-builder-api")); + + packageService = new PackageService("https://test-endpoint", logger); + sandbox + .stub(packageService, "getManifestFromZip" as keyof PackageService) + .returns(undefined as any); + actualError = undefined; + try { + await packageService.sideLoading("test-token", "test-path"); + } catch (error: any) { + actualError = error; + } + + chai.assert.isDefined(actualError); + chai.assert.isTrue( + actualError?.message.includes("Invalid app package zip. manifest.json is missing") + ); }); it("sideLoading throws expected reponse error", async () => { @@ -429,6 +631,7 @@ describe("Package Service", () => { axiosPostResponses["/dev/v1/users/packages"] = expectedError; let packageService = new PackageService("https://test-endpoint"); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({} as any); let actualError: any; try { await packageService.sideLoading("test-token", "test-path"); @@ -440,6 +643,8 @@ describe("Package Service", () => { chai.assert.isTrue(actualError.message.includes("test-post")); packageService = new PackageService("https://test-endpoint", logger); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({} as any); + actualError = undefined; try { await packageService.sideLoading("test-token", "test-path"); } catch (error: any) { @@ -469,6 +674,7 @@ describe("Package Service", () => { axiosPostResponses["/dev/v1/users/packages"] = expectedError; const packageService = new PackageService("https://test-endpoint"); + sandbox.stub(packageService, "getManifestFromZip" as keyof PackageService).returns({} as any); let actualError: any; try { await packageService.sideLoading("test-token", "test-path"); @@ -655,6 +861,7 @@ describe("Package Service", () => { }, }; axiosDeleteResponses["/catalog/v1/users/acquisitions/test-title-id"] = {}; + axiosDeleteResponses["/builder/v1/users/titles/test-title-id"] = {}; let packageService = new PackageService("https://test-endpoint"); let actualError: Error | undefined; @@ -676,7 +883,31 @@ describe("Package Service", () => { chai.assert.isUndefined(actualError); }); + it("unacquire by builder api", async () => { + axiosGetResponses["/config/v1/environment"] = { + data: { + titlesServiceUrl: "https://test-url", + }, + }; + axiosDeleteResponses["/catalog/v1/users/acquisitions/test-title-id"] = new Error("test-delete"); + axiosDeleteResponses["/builder/v1/users/titles/test-title-id"] = {}; + let packageService = new PackageService("https://test-endpoint"); + let actualError: Error | undefined; + try { + await packageService.unacquire("test-token", "test-title-id"); + } catch (error: any) { + actualError = error; + } + chai.assert.isUndefined(actualError); + packageService = new PackageService("https://test-endpoint", logger); + try { + await packageService.unacquire("test-token", "test-title-id"); + } catch (error: any) { + actualError = error; + } + chai.assert.isUndefined(actualError); + }); it("unacquire throws expected error", async () => { axiosGetResponses["/config/v1/environment"] = { data: { @@ -685,7 +916,7 @@ describe("Package Service", () => { }; axiosDeleteResponses["/catalog/v1/users/acquisitions/test-title-id"] = new Error("test-delete"); - const packageService = new PackageService("https://test-endpoint"); + let packageService = new PackageService("https://test-endpoint"); let actualError: Error | undefined; try { await packageService.unacquire("test-token", "test-title-id"); @@ -695,6 +926,17 @@ describe("Package Service", () => { chai.assert.isDefined(actualError); chai.assert.isTrue(actualError?.message.includes("test-delete")); + + packageService = new PackageService("https://test-endpoint", logger); + actualError = undefined; + try { + await packageService.unacquire("test-token", "test-title-id"); + } catch (error: any) { + actualError = error; + } + + chai.assert.isDefined(actualError); + chai.assert.isTrue(actualError?.message.includes("test-delete")); }); it("unacquire throws expected response error", async () => { @@ -708,6 +950,7 @@ describe("Package Service", () => { data: {}, }; axiosDeleteResponses["/catalog/v1/users/acquisitions/test-title-id"] = expectedError; + axiosDeleteResponses["/builder/v1/users/titles/test-title-id"] = expectedError; const packageService = new PackageService("https://test-endpoint"); let actualError: any; @@ -1069,4 +1312,36 @@ describe("Package Service", () => { chai.assert.isUndefined(actualError); chai.assert.isUndefined(result); }); + + it("get share link happy path", async () => { + axiosGetResponses["/config/v1/environment"] = { + data: { + titlesServiceUrl: "https://test-url", + }, + }; + axiosGetResponses["/marketplace/v1/users/titles/test-title-id/sharingInfo"] = { + data: { + unifiedStoreLink: "https://test-share-link", + }, + }; + const packageService = new PackageService("https://test-endpoint"); + const shareLink = await packageService.getShareLink("test-token", "test-title-id"); + chai.assert.equal(shareLink, "https://test-share-link"); + }); + + it("get share link - failure", async () => { + axiosGetResponses["/config/v1/environment"] = { + data: { + titlesServiceUrl: "https://test-url", + }, + }; + const packageService = new PackageService("https://test-endpoint"); + let actualError: boolean | undefined; + try { + const shareLink = await packageService.getShareLink("test-token", "test-title-id"); + } catch (error: any) { + actualError = error; + } + chai.assert.isDefined(actualError); + }); }); diff --git a/packages/fx-core/tests/component/m365/success.zip b/packages/fx-core/tests/component/m365/success.zip new file mode 100644 index 0000000000000000000000000000000000000000..2133c113938848d5a9730192b847cc10b1aa76eb GIT binary patch literal 1839 zcmZ`)2~d;Q7XBe5>{LaOB_s@uf*^}jK!}L1tqUfy7?ywt1YQVhf?x{Bk|J0uOF$&7 z0#6<&0wp3vKm-bi#@8$&7-R`yQ;GyaD1;@3H2QpRI!=4cN9oz zHvp)p0Ab$lKE~8b3*(9aV5S5BssI3lkfKPjmN7)ag2r;(+?)LA;Gm$%wbd9^#h1#* zi_23vyZ;X~w;k&{|kS1tf!Sud_wSp-Gfi`Mv$v*fz z8P;^NsE_K2IO}E?I%V4Sg!)*e(L%cd6U3Esu_6O8Kt!CY$8G1G) zyOZ?_Uwq>p$P|d^q(mfTo7o)YB;6zq3_o5ZP+j($N_@9f>?stPj9$1dD~d%}OGdUQ z-4-qeg%!F=jVLD@ZltjUT#!Vs6rFw4*GG$MkkDLBnG?!*kCs{Me7DNRt|d5u6m2LK za`+2w%{6@Q*A^3z*_;bqNZJJY5XZ5j-qc*M;(xMxbUv3GX65#kCLYTSF)5B4&uDrk zxcVz(B1k9QN3~a(Qv4jAFd*p5PnP7eujVLs2Ndz?9q;?bRwQCtd#5tnzgTn%Rw;}w zh=T^ukxtT-5}Dol#tQRM{Kc&Fc<=YF9pSWzNnPZ+w40rg$8w~c(1OM+oW*)yu5g)l z{b8~s+G<*_TJteBo9|_6x9V5$Ac`%iKom|T=ggFLmM1&1|JBuk?-(p4#u z-#e~wA~|CPlXQ>CH8;FG_aC?4i!7Q^N%Yhy7hYrM068$CRHs?R^srOtPWPzG?0b{F z)51vBg$M6KDJ!94{WfzoS``seM(QRMBP^=W#K2LmnVxMYYG$q7mdA!FY|2VCo%Oi9 zB;+D_tJP`EIWWbx^l+1K^Dc5Y;67)Y(_TXi-Dtfa!w9&;oKyrkXp7F&cc$=zd+hJZ z{Fu(N#F2wsw>t4$9Kn0j8_VB^fdVL{N001R00BFi35RD^- zhvMTdSw_T>h*vRZN&1I&bk6x;%V54%3g7N*HH)r>zVwdBN72%Uk?O=@_=@gnKM_CD zz$(GxWlRq;RmftNiTLW%lz!#e$h-z@e(>V09=whhC`;%qpvOFFSd?tBG9E;SCSlhuub^)Z)6!}w_~e7 z=A!eG?}dnZWA|2r4C4puoiu-Qs2ziRH@XR}95H0ZskLv^Hgn9Q?mV>PtPZ}kjmrg7 ze;*8eI-9w8C##0PLD^MX{i<;Fm$VoV<0orN^gLM#pE_;>9k58Wnm1VfN5oSH(|BG| zT%tBb+Bh@{-b*=FLn5^gs0416>-%3On`}7Mhpb`;@0I_!GLn(ho>On>3F6E&q~?>R z<7McdgAGObbGps}fme`YRKHp;Y*yUrLY72Fznx;(2cyRCf90vsSV0YxB)l^eg&kN9 z8wy8PIlRuxbycjQ{5mD&r8T5f1sgrYmxmpTeEy`}PyZmEVyAPCetJ(EBBn{E{Hant zHOup~J2R{D3fYvKy4*27ZZT0t*Y@!ShHs?dLY&ttzx6ii<_z{9s=gvxJbbDP0)`b56U7@T=yDq; z*NJndFcnrooQFgoO$TF!5w@k)*yU}-nVTmj8}Gd_F%Bq^6-=syX9tQ?^knTytU27Z z*g7ty1-HjR^mhJhSl@YZSs-`2_~G$#AID5vi?o?Xli-#a59wZ^U19u@uOP=Vovd~Y z{esvvRghfar1F5BD<=dmCHy$aktvv&l33$ z{b~JwvV5Sy0Xgk2^@sRGF_I$)^qCsffDd5+PyloRucREUcfhE^|8ULw?Z&*^|NK>- eC+*_}9}f$;;Qy3DcN7@%Ar6vnjvUW?^!@