From 93b060bc8b681bceca421ee20954940e893216af Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 6 Jul 2026 14:48:51 -0400 Subject: [PATCH] Modernize bot to Node.js 22 with modular architecture and remote SIW agent - Refactor monolithic index.js (2646 lines) into src/{webex,integrations, cards,flows,commands,services} modules; replace node-fetch/form-data with native fetch/FormData; move all secrets to .env via dotenv - Add dockerized remote SIW agent (docker/remote-agent/) with cross-arch buildx packaging (arm64 Mac -> linux/amd64), idempotent install.sh deploy bundle, and docker-free ZIP inspector for arch verification - Bot hosts a WebSocket server; agent proxies SIW requests with a per-request insecure:true flag, replacing the process-wide NODE_TLS_REJECT_UNAUTHORIZED bypass - Add ESLint flat config + Prettier, rewrite Dockerfile as non-root multi-stage node:22-alpine build, README covering setup / deploy / remote agent workflow - Fix parseStoreArg to read trigger.prompt correctly (was indexing past the framework's post-match slice); register /help as regex (string matcher only compares the first token); switch catch-all to /.+/ (previous /.*/gim was stateful due to the g flag); remove /fixDisplayNames command and its flow/card Co-authored-by: Cursor --- .dockerignore | 44 + .env.example | 50 + .gitignore | 39 + .prettierignore | 6 + .prettierrc.json | 8 + Dockerfile | 30 + README.md | 197 + docker/remote-agent/.env.example | 16 + docker/remote-agent/Dockerfile | 54 + docker/remote-agent/README.md | 152 + docker/remote-agent/deploy/README.md | 136 + docker/remote-agent/deploy/docker-compose.yml | 26 + docker/remote-agent/deploy/install.sh | 138 + docker/remote-agent/docker-compose.yml | 40 + docker/remote-agent/inspect-bundle.sh | 135 + docker/remote-agent/package.json | 19 + docker/remote-agent/package.sh | 334 + docker/remote-agent/remoteAgent.js | 129 + eslint.config.js | 30 + greetings/AEGreeting.wav | Bin 0 -> 44258 bytes greetings/AerieGreeting.wav | Bin 0 -> 36658 bytes greetings/OfflineGreeting.wav | Bin 0 -> 37858 bytes greetings/UnsubscribedGreeting.wav | Bin 0 -> 39258 bytes package-lock.json | 11319 ++++++++++++++++ package.json | 40 + scripts/fixStorePhones.js | 36 + scripts/generate911Csv.js | 92 + src/cards/storeInfoCard.js | 66 + src/cards/userInfoCard.js | 185 + src/commands/attachmentActions.js | 63 + src/commands/buildStore.js | 29 + src/commands/helpers.js | 36 + src/commands/migrateStore.js | 33 + src/commands/stageStore.js | 30 + src/commands/storeInfo.js | 25 + src/commands/userInfo.js | 25 + src/config.js | 86 + src/constants.js | 115 + src/flows/buildStore.js | 70 + src/flows/greetingSelector.js | 13 + src/flows/migrateStore.js | 75 + src/flows/stageStore.js | 57 + src/flows/stepRunner.js | 21 + src/http.js | 67 + src/index.js | 95 + src/integrations/google.js | 52 + src/integrations/siw.js | 120 + src/integrations/twilio.js | 18 + src/logger.js | 25 + src/services/websocket.js | 218 + src/webex/announcements.js | 38 + src/webex/auth.js | 93 + src/webex/autoAttendants.js | 50 + src/webex/client.js | 71 + src/webex/devices.js | 168 + src/webex/licensing.js | 38 + src/webex/locations.js | 164 + src/webex/schedules.js | 46 + src/webex/users.js | 106 + 59 files changed, 15368 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker/remote-agent/.env.example create mode 100644 docker/remote-agent/Dockerfile create mode 100644 docker/remote-agent/README.md create mode 100644 docker/remote-agent/deploy/README.md create mode 100644 docker/remote-agent/deploy/docker-compose.yml create mode 100755 docker/remote-agent/deploy/install.sh create mode 100644 docker/remote-agent/docker-compose.yml create mode 100755 docker/remote-agent/inspect-bundle.sh create mode 100644 docker/remote-agent/package.json create mode 100755 docker/remote-agent/package.sh create mode 100644 docker/remote-agent/remoteAgent.js create mode 100644 eslint.config.js create mode 100644 greetings/AEGreeting.wav create mode 100644 greetings/AerieGreeting.wav create mode 100644 greetings/OfflineGreeting.wav create mode 100644 greetings/UnsubscribedGreeting.wav create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/fixStorePhones.js create mode 100644 scripts/generate911Csv.js create mode 100644 src/cards/storeInfoCard.js create mode 100644 src/cards/userInfoCard.js create mode 100644 src/commands/attachmentActions.js create mode 100644 src/commands/buildStore.js create mode 100644 src/commands/helpers.js create mode 100644 src/commands/migrateStore.js create mode 100644 src/commands/stageStore.js create mode 100644 src/commands/storeInfo.js create mode 100644 src/commands/userInfo.js create mode 100644 src/config.js create mode 100644 src/constants.js create mode 100644 src/flows/buildStore.js create mode 100644 src/flows/greetingSelector.js create mode 100644 src/flows/migrateStore.js create mode 100644 src/flows/stageStore.js create mode 100644 src/flows/stepRunner.js create mode 100644 src/http.js create mode 100644 src/index.js create mode 100644 src/integrations/google.js create mode 100644 src/integrations/siw.js create mode 100644 src/integrations/twilio.js create mode 100644 src/logger.js create mode 100644 src/services/websocket.js create mode 100644 src/webex/announcements.js create mode 100644 src/webex/auth.js create mode 100644 src/webex/autoAttendants.js create mode 100644 src/webex/client.js create mode 100644 src/webex/devices.js create mode 100644 src/webex/licensing.js create mode 100644 src/webex/locations.js create mode 100644 src/webex/schedules.js create mode 100644 src/webex/users.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f58e19 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +.git +.gitignore +.dockerignore + +# Dependencies (installed inside the image) +node_modules +npm-debug.log* + +# Secrets and local runtime state +.env +.env.* +config.json +config/wbxTokens.json +config/google-service-account.json +config/*-service-account.json +googleData.json +*.pem +*.key + +# Legacy top-level scripts (deleted after refactor but ignored defensively) +get911.js +getMeeting.js +phoneFix.js +storeAddress.js + +# Local scripts/tooling not needed at runtime +scripts/ +eslint.config.js +.prettierrc.json +.prettierignore + +# Remote-agent packaging (its own Dockerfile / image; not part of the bot image) +docker/ + +# Docs and generated output +*.md +*.log +*.zip +*.csv + +# OS / editor cruft +.DS_Store +.vscode/ +.idea/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4d0ea7 --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Copy this file to .env and fill in real values. +# .env is git-ignored. NEVER commit real secrets. + +# --- Webex bot (webex-node-bot-framework) --- +WEBEX_BOT_TOKEN=your-webex-bot-token +WEBEX_BOT_NAME=AEO Call Provisioning +WEBEX_BOT_USERNAME=aeoCallProvisioning@webex.bot +WEBEX_BOT_ID=your-webex-bot-id + +# --- Webex service account (integration used for admin API calls) --- +WEBEX_SVC_CLIENT_ID=your-webex-integration-client-id +WEBEX_SVC_CLIENT_SECRET=your-webex-integration-client-secret + +# Path to the JSON file that persists the service-account access/refresh tokens. +# Created and rewritten automatically by the token-refresh cron. +WEBEX_TOKEN_STORE=./config/wbxTokens.json + +# --- Twilio (phone number validation) --- +TWILIO_ACCOUNT_SID=your-twilio-account-sid +TWILIO_AUTH_TOKEN=your-twilio-auth-token + +# --- Store Info Web (SIW) --- +# SIW requests are proxied through the on-prem remote agent (see WS_* below), +# so the base URL and credentials are forwarded to the agent per-request. This +# bot never talks to SIW directly. +SIW_BASE_URL=https://storeinfoweb-prod.ae.com +SIW_USERNAME=your-siw-user +SIW_PASSWORD=your-siw-password + +# --- Remote agent WebSocket bridge --- +# This bot listens on WS_PORT; the on-prem agent (a second instance of the +# netanalyzer remoteAgent.js) dials in with WS_URL=wss://.../ and this token +# as its Bearer credential. Rotate WS_TOKEN with any high-entropy string. +WS_PORT=8080 +WS_TOKEN=change-me-to-a-long-random-string + +# --- Google --- +# Simple REST API key used by Address Validation + Time Zone endpoints. +GOOGLE_API_KEY=your-google-api-key + +# Optional. Path to a Google service-account JSON key file (the one shaped +# like { type: "service_account", private_key: "-----BEGIN...", ... }). +# Not required for the REST calls above, but standard for any future code +# that uses google-auth-library / googleapis. Keep the file itself outside +# of git (config/google-service-account.json is already ignored). +GOOGLE_APPLICATION_CREDENTIALS=./config/google-service-account.json + +# --- Runtime --- +NODE_ENV=production +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cac650 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Secrets and local config +.env +.env.local +.env.*.local +config.json +config/wbxTokens.json +config/google-service-account.json +config/*-service-account.json +googleData.json +*.pem +*.key + +# Runtime artifacts +*.log +buildlogs.log + +# Generated CSV outputs (from scripts/generate911Csv.js) +buildingFile.csv +locationFile.csv + +# Archives +*.zip + +# Remote agent packaging output (produced by docker/remote-agent/package.sh) +docker/remote-agent/dist/ +docker/remote-agent/.env + +# OS / editor cruft +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9f9b6ad --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +greetings/ +buildingFile.csv +locationFile.csv +package-lock.json +config/wbxTokens.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7456336 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "tabWidth": 4, + "printWidth": 100, + "trailingComma": "all", + "semi": true, + "arrowParens": "always" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd75802 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.7 + +# --- build stage: install production dependencies only --- +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +# --- runtime stage: minimal image with just what's needed to run --- +FROM node:22-alpine AS runtime +ENV NODE_ENV=production +WORKDIR /app + +# Bring in dependencies (owned by the built-in `node` user). +COPY --from=deps --chown=node:node /app/node_modules ./node_modules + +# Application code. +COPY --chown=node:node package.json package-lock.json ./ +COPY --chown=node:node src ./src +COPY --chown=node:node greetings ./greetings + +# Directory the token-refresh cron writes to. Mount a volume here in prod. +RUN mkdir -p /app/config && chown node:node /app/config + +# WebSocket port the on-prem SIW agent connects back to. Override at runtime +# via -e WS_PORT and -p host:container. +EXPOSE 8080 + +USER node +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..42576a5 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# wbxStoreProvision + +Webex Calling provisioning bot for AEO retail stores. Runs as a Webex bot and +exposes slash commands that create Webex locations, attach phone numbers, upload +greetings, build auto-attendants, and clean up user licensing. + +## Requirements + +- Node.js 20+ (Docker image uses `node:22-alpine`). +- A Webex bot token, a Webex integration (service account) with the scopes + currently used by admin API calls, a Twilio lookup account, an SIW basic-auth + user, and a Google API key with Address Validation + Time Zone enabled. +- An on-prem host that can reach Store Info Web, to run the remote agent + (see [Remote SIW agent](#remote-siw-agent) below). +- Optional: a Google service-account JSON key. Only the REST API key is + required today, but if/when you add code that uses `google-auth-library`, + save the JSON at `config/google-service-account.json` and set + `GOOGLE_APPLICATION_CREDENTIALS` in `.env` to point at it. The file is + git-ignored. + +## Local setup + +```bash +cp .env.example .env +# Edit .env with real values +npm install +npm start +``` + +The token-refresh cron reads and writes `config/wbxTokens.json` (path +configurable via `WEBEX_TOKEN_STORE`). Seed this file once with a valid +`access_token`, `refresh_token`, and `expiresOn`; the process will keep it up to +date from then on. + +## Scripts + +| npm script | What it does | +| -------------------------- | ----------------------------------------------------------- | +| `npm start` | Run the bot. | +| `npm run dev` | Run with `node --watch` for local iteration. | +| `npm run lint` | ESLint (flat config) across the tree. | +| `npm run format` | Prettier write. | +| `npm run generate-911-csv` | Rebuild `buildingFile.csv` + `locationFile.csv` from SIW. | +| `npm run fix-store-phones` | Normalize licensing + default meeting site for store users. | + +## Docker + +```bash +docker build -t wbxcallprov . +docker run --rm --env-file .env \ + -p 8080:8080 \ + -v "$(pwd)/config:/app/config" \ + wbxcallprov +``` + +The volume mount preserves `config/wbxTokens.json` across restarts so the +refresh cron doesn't lose state. The `-p 8080:8080` publishes the WebSocket +port so the remote SIW agent can connect back to the bot. + +## Remote SIW agent + +Store Info Web only accepts connections from inside the corporate network, +but the bot runs in the cloud. To bridge the gap, the bot hosts a +WebSocket server; a small on-prem agent dials in and proxies HTTP requests +back and forth. + +The agent itself is intentionally generic — it just proxies whatever +`{method, url, headers, auth, body}` payload arrives — and lives in +[`docker/remote-agent/`](docker/remote-agent) as a self-contained Docker +bundle you can build here and ship to the on-prem host. + +Once the agent is connected, the bot logs `Remote agent connected` and any +`/buildStore`, `/stageStore`, `/migrateStore` command will succeed. If the +agent is not connected, the SIW-dependent commands fail immediately with +`No remote SIW agent connected` rather than silently timing out. + +### Deploying the agent + +The `docker/remote-agent/` folder produces a fully offline-installable ZIP +(image tarball + `install.sh` + `docker-compose.yml`). The workflow is the +same as netanalyzer's bundle — same layout, same operator playbook — and +the two bundles use distinct image tags and container names +(`wbxprov-remote-agent` vs `sha-remote-agent`) so a single on-prem host +can run both agents side by side. + +Build the bundle (on your workstation, or in CI): + +```bash +# Default: linux/amd64 (typical Rocky/RHEL/Ubuntu server) +npm run agent:package + +# Or explicitly, with a different target arch: +./docker/remote-agent/package.sh --platform linux/arm64 +``` + +That produces `docker/remote-agent/dist/wbxprov-remote-agent-.zip`. +Transfer it to the on-prem host, then: + +```bash +unzip wbxprov-remote-agent-.zip +cd wbxprov-remote-agent- +./install.sh # first run: seeds .env and stops +vi .env # set WS_URL + WS_TOKEN +./install.sh # second run: starts the container +docker compose logs -f # tail the agent's connection status +``` + +Set `WS_URL` to the bot's public WebSocket endpoint (e.g. +`wss://your-wbxstoreprovision-host:8080`) and `WS_TOKEN` to the same value +you configured for `WS_TOKEN` in the bot's `.env`. See +[`docker/remote-agent/README.md`](docker/remote-agent/README.md) for build +details and [`docker/remote-agent/deploy/README.md`](docker/remote-agent/deploy/README.md) +for the full operator guide (upgrades, troubleshooting, coexistence with +`sha-remote-agent`). + +## Bot commands + +Registered in [src/commands](src/commands): + +- `/buildStore ` — full green-field build (create location, calling, + greeting, attach user, license cleanup). +- `/stageStore ` — pre-migration setup: same as buildStore but + without phone-number attachment or licensing cleanup. +- `/migrateStore ` — cut-over for a staged store: attach the phone + number, set caller ID, create the auto-attendant, finalize licensing. +- `/storeinfo ` — show current Webex info for the store user + (`ae<5-digit>@ae.com`). +- `/userinfo ` — show current Webex info for any user by email. +- `/help` — bot's own help output. + +Card confirmations post `attachmentAction` events, dispatched in +[src/commands/attachmentActions.js](src/commands/attachmentActions.js). + +## Architecture + +``` +src/ + index.js bot bootstrap, cron, command wiring (~70 lines) + config.js dotenv loading + validation + constants.js org-scoped IDs (route groups, licenses, greetings, ...) + logger.js small level-aware logger + http.js fetch wrapper (rate limit + optional SIW TLS agent) + webex/ all Webex API calls, one module per resource family + integrations/ SIW / Twilio / Google + cards/ Adaptive Card builders + flows/ multi-step provisioning (build, stage, migrate, ...) + commands/ framework.hears handlers + attachmentAction dispatch +scripts/ one-off maintenance scripts (911 CSV, phone fix-up) +greetings/ WAV files uploaded as location announcements +config/wbxTokens.json persistent service-account token cache (git-ignored) +``` + +Data flow for a store provisioning: + +```mermaid +flowchart LR + User[Webex user] -->|/buildStore 1234| Commands[commands/*] + Commands --> SIW[integrations/siw.js] + Commands --> Users[webex/users.js] + Commands --> Card[cards/storeInfoCard.js] + Card -->|confirm| Attachment[attachmentActions.js] + Attachment --> Flow[flows/buildStore.js] + Flow --> Locations[webex/locations.js] + Flow --> Devices[webex/devices.js] + Flow --> Announce[webex/announcements.js] + Flow --> License[webex/licensing.js] + subgraph background + Cron[node-cron every 60s] --> Auth[webex/auth.js] + Auth --> Tokens[(config/wbxTokens.json)] + end +``` + +## Security notes + +- `.env` and `config/wbxTokens.json` are git-ignored. Never commit them. +- `NODE_TLS_REJECT_UNAUTHORIZED=0` is no longer set globally. If SIW's TLS + cert cannot be verified from your host, set `ALLOW_INSECURE_SIW_TLS=true`; + the insecure dispatcher is then scoped to SIW requests only. +- Concurrent Webex token refreshes are collapsed into a single in-flight + refresh in [src/webex/auth.js](src/webex/auth.js). + +### One-time secret rotation + +The pre-refactor `config.json` and `getMeeting.js` stored real secrets in +plain text on disk. Regardless of git history, treat the following as +**exposed** and rotate them at their source: + +- Webex bot token (`WEBEX_BOT_TOKEN`) +- Webex integration client secret + service-account access / refresh tokens + (`WEBEX_SVC_CLIENT_SECRET`, plus everything in `config/wbxTokens.json`) +- Twilio auth token (`TWILIO_AUTH_TOKEN`) +- SIW basic-auth password (`SIW_PASSWORD`) +- Google API key (`GOOGLE_API_KEY`) and any service-account private key that + was previously in `config.json` + +After rotating, populate the new values in `.env` and seed a fresh +`config/wbxTokens.json` with the new access/refresh token pair. diff --git a/docker/remote-agent/.env.example b/docker/remote-agent/.env.example new file mode 100644 index 0000000..1739ea8 --- /dev/null +++ b/docker/remote-agent/.env.example @@ -0,0 +1,16 @@ +# ============================================================================= +# wbxStoreProvision Remote Agent — Environment +# Copy this file to `.env` (next to docker-compose.yml) and fill in the +# values. NEVER commit .env — the .gitignore already excludes it. +# ============================================================================= + +# WebSocket URL of the main wbxStoreProvision bot. Use `wss://` if the bot +# is reverse-proxied through TLS; `ws://host:port` for a direct connection. +# Do NOT put the token in a `?token=` query parameter — WS_TOKEN below is +# sent as an Authorization: Bearer header instead. +WS_URL=wss://wbxstoreprovision.example.com/ws + +# Shared secret that must match WS_TOKEN on the bot side (the value in the +# bot's own .env). Generate a strong random value once and rotate it if you +# suspect it's been exposed. +WS_TOKEN=change_me_to_a_long_random_value diff --git a/docker/remote-agent/Dockerfile b/docker/remote-agent/Dockerfile new file mode 100644 index 0000000..67f0a97 --- /dev/null +++ b/docker/remote-agent/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.7 + +# ============================================================================ +# wbxStoreProvision — Remote Agent +# ---------------------------------------------------------------------------- +# Tiny WebSocket client that proxies HTTP requests (Store Info Web today, +# anything else in the future) from an internal network back to the main +# wbxStoreProvision bot. Ships as a standalone container so it can run +# inside the segmented network where SIW lives. +# +# Build context is docker/remote-agent (self-contained — the agent doesn't +# need any of the bot's source or dependencies). +# +# Build: +# docker build -f docker/remote-agent/Dockerfile \ +# -t wbxprov-remote-agent:latest docker/remote-agent +# +# Run: +# docker run --rm -it \ +# --env-file docker/remote-agent/.env \ +# --name wbxprov-remote-agent \ +# wbxprov-remote-agent:latest +# ============================================================================ + +# ---- Stage 1: dependencies ------------------------------------------------ +FROM node:22-alpine AS deps + +WORKDIR /app + +# Minimal manifest: ws + axios + dotenv. No lockfile — three stable deps. +COPY package.json ./package.json +RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force + +# ---- Stage 2: runtime ----------------------------------------------------- +FROM node:22-alpine AS runtime + +# tini reaps zombies and forwards signals correctly so `docker stop` reaches +# Node's SIGTERM handler for a clean websocket close. +RUN apk add --no-cache tini + +WORKDIR /app +USER node + +COPY --chown=node:node --from=deps /app/node_modules ./node_modules +COPY --chown=node:node package.json ./package.json +COPY --chown=node:node remoteAgent.js ./remoteAgent.js + +ENV NODE_ENV=production + +# The agent is a WebSocket CLIENT — it doesn't listen on any port, so we +# intentionally skip EXPOSE. + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "remoteAgent.js"] diff --git a/docker/remote-agent/README.md b/docker/remote-agent/README.md new file mode 100644 index 0000000..7818f27 --- /dev/null +++ b/docker/remote-agent/README.md @@ -0,0 +1,152 @@ +# wbxStoreProvision Remote Agent — Build & Package + +This directory produces a self-contained, offline-installable Docker bundle +for the wbxStoreProvision remote agent. The agent is a tiny WebSocket +client that runs inside a segmented (store / on-prem) network and proxies +HTTP requests from the main bot back to Store Info Web. + +The layout mirrors the equivalent bundle in the `netanalyzer` repo so the +same operator playbook applies. Both bundles use distinct image tags and +container names (`wbxprov-remote-agent` vs `sha-remote-agent`), so a single +on-prem host can run both agents side by side without conflict. + +## Layout + +``` +docker/remote-agent/ +├── Dockerfile build definition (node:22-alpine + tini, non-root) +├── docker-compose.yml build-from-source compose (dev / rebuild use only) +├── package.json the agent's own package manifest (ws + axios + dotenv) +├── package.sh produces the shippable ZIP under dist/ +├── inspect-bundle.sh docker-free arch/OS check on any built ZIP +├── remoteAgent.js the agent itself — proxies WS → HTTP +├── .env.example template consumed at deploy time +├── deploy/ +│ ├── docker-compose.yml runtime-only compose (used inside the ZIP) +│ ├── install.sh idempotent installer bundled into the ZIP +│ └── README.md operator-facing README bundled into the ZIP +└── dist/ produced by package.sh (git-ignored) +``` + +## Build & package a bundle + +From the repo root, or the `docker/remote-agent/` directory: + +```bash +# Default: build for linux/amd64 (typical Rocky/RHEL/Ubuntu servers) +npm run agent:package +# or explicitly: ./docker/remote-agent/package.sh + +# ARM Linux target (e.g. Raspberry Pi, ARM-based server) +./docker/remote-agent/package.sh --platform linux/arm64 + +# Override the version tag +./docker/remote-agent/package.sh --tag 1.0.1 + +# Preflight only — verify the build environment without actually building. +# Useful the first time you set up a new machine. +npm run agent:check +``` + +Output: `docker/remote-agent/dist/wbxprov-remote-agent-.zip`. + +### How cross-arch builds (arm64 Mac → linux/amd64) are guaranteed + +Building an `x86_64` Linux image from an Apple Silicon Mac is the single +easiest way to silently ship a broken bundle, so the script defends against +that in **four independent layers**: + +1. **Dedicated `docker-container` builder.** The default buildx builder on + Docker Desktop uses the `docker` driver, which is pinned to the daemon's + native architecture and will happily *ignore* `--platform`. The script + creates (and, if it finds a mis-driver builder with the same name, + *re-creates*) a `wbxprov-remote-agent-builder` using the + `docker-container` driver, which spins up an isolated BuildKit instance + that actually honors `--platform`. +2. **`binfmt` handlers.** When cross-building, the script best-effort + registers QEMU handlers for the target arch via `tonistiigi/binfmt`. + Docker Desktop usually has these; Colima / plain Docker Engine often + don't. +3. **`docker image inspect` check after build.** Reads the image out of the + local daemon and refuses to proceed if `Architecture` doesn't match the + requested `--platform`. +4. **`inspect-bundle.sh` on the final ZIP.** Independent, docker-free check + that reads the image config JSON directly out of the tarball inside the + ZIP. If this passes, the ZIP is provably correct regardless of anything + the local daemon may have said. + +If any layer detects a mismatch, `package.sh` exits non-zero and prints +the exact rebuild command. You cannot accidentally ship an arm64 bundle to +an amd64 host. + +### Verify a ZIP after the fact (no docker required) + +```bash +# Auto-detects the newest ZIP in dist/: +npm run agent:inspect + +# Or point it at a specific bundle: +./docker/remote-agent/inspect-bundle.sh path/to/wbxprov-remote-agent-1.0.0.zip + +# Enforce expectations (exits non-zero if wrong): +EXPECTED_ARCH=amd64 EXPECTED_OS=linux npm run agent:inspect +``` + +This works on any machine with `python3` + `unzip` — no Docker daemon +required — so you can verify a bundle on the Linux target host itself +before running `./install.sh`. + +### Requirements on the build host + +- Docker with the buildx plugin (Docker Desktop includes it out of the box). +- `zip`, `node`, `python3`, and either `sha256sum` or `shasum`. + +## Deploy the bundle on the remote host + +Transfer the ZIP produced above to the target host, then: + +```bash +unzip wbxprov-remote-agent-.zip +cd wbxprov-remote-agent- +./install.sh +``` + +`install.sh` will: + +1. Verify the SHA-256 checksum against `SHA256SUMS`. +2. `docker load` the image tarball. +3. Sanity-check the image architecture matches the host. +4. On first run: copy `.env.example` → `.env` and stop, asking you to fill + in `WS_URL` (the bot's public WebSocket endpoint) and `WS_TOKEN` + (matching the value in the bot's `.env`). +5. On the second run: `docker compose up -d` to start the container. + +See `deploy/README.md` for the full operator-facing guide (upgrades, +troubleshooting, logs). + +## Configuring the bot side + +The bot's `.env` needs matching values: + +``` +WS_PORT=8080 # what the bot's WebSocket server listens on +WS_TOKEN= +``` + +Make sure whatever public URL fronts the bot (reverse proxy, ingress, etc.) +maps `/ws` (or wherever `WS_URL` in the agent's `.env` points) through to +`WS_PORT` on the bot container. + +## Coexisting with the netanalyzer agent + +The two bundles are cleanly separated: + +| | wbxStoreProvision | netanalyzer | +| ---------------------- | --------------------------------- | ---------------------------- | +| Image tag | `wbxprov-remote-agent:` | `sha-remote-agent:` | +| Container name | `wbxprov-remote-agent` | `sha-remote-agent` | +| Deploy folder | `wbxprov-remote-agent-/` | `sha-remote-agent-/`| +| `.env` variables | `WS_URL`, `WS_TOKEN` | `WS_URL`, `WS_TOKEN` | + +Each has its own `.env` inside its own folder pointing at its own server, so +there's no shared state between them. diff --git a/docker/remote-agent/deploy/README.md b/docker/remote-agent/deploy/README.md new file mode 100644 index 0000000..5153ed3 --- /dev/null +++ b/docker/remote-agent/deploy/README.md @@ -0,0 +1,136 @@ +# wbxStoreProvision Remote Agent — Deploy Bundle + +This ZIP is a self-contained deployment bundle for the wbxStoreProvision +remote agent. Extract it, run `install.sh`, fill in your `.env`, and the +agent will start as a Docker container. + +## What's in the bundle + +| File | Purpose | +| --- | --- | +| `wbxprov-remote-agent-.tar.gz` | The Docker image, saved via `docker save`. | +| `docker-compose.yml` | Runtime-only compose file (no build step; references the loaded image). | +| `install.sh` | Verifies checksum, loads the image, seeds `.env`, starts the container. | +| `.env.example` | Template — copied to `.env` on first run for you to fill in. | +| `SHA256SUMS` | Integrity check for the image tarball. | +| `VERSION` | Plain-text version marker used by `install.sh` and `docker-compose.yml`. | +| `README.md` | This file. | + +## Prerequisites (on the remote host) + +- Docker 20.10+ with the daemon running. +- Docker Compose — either the modern `docker compose` plugin (v2) or the + legacy `docker-compose` binary. `install.sh` auto-detects. +- Whichever user runs `install.sh` needs permission to talk to the Docker + daemon (member of the `docker` group, or run under `sudo`). +- Outbound network access from the host to: + - The main wbxStoreProvision bot (`WS_URL`). + - Store Info Web (the internal API the agent proxies for). + +## Install / start + +```bash +unzip wbxprov-remote-agent-.zip +cd wbxprov-remote-agent- +./install.sh +``` + +On the first run `install.sh` will: + +1. Verify the SHA-256 of the image tarball against `SHA256SUMS`. +2. Load the image into Docker (a fast no-op on subsequent runs). +3. Copy `.env.example` → `.env` and stop, asking you to fill it in. + +Fill in `.env`: + +```bash +vi .env # set WS_URL and WS_TOKEN +``` + +Then re-run: + +```bash +./install.sh +``` + +That last run will start the container (`docker compose up -d`) and print +the log-tail command. + +## Day-to-day operations + +```bash +docker compose logs -f # tail the agent logs +docker compose ps # show container status +docker compose restart # cycle it +docker compose down # stop and remove the container +docker compose up -d # bring it back up +``` + +Healthy startup looks like: + +``` +Connecting to wss://.../ws... +Remote Agent connected to wbxStoreProvision +``` + +## Upgrading + +When you receive a newer ZIP: + +```bash +# Optional: back up your existing config +cp -a /.env ./wbxprov-remote-agent--env.bak + +# Stop the old container +cd && docker compose down && cd .. + +# Extract and start the new one +unzip wbxprov-remote-agent-.zip +cp /.env wbxprov-remote-agent-/.env +cd wbxprov-remote-agent- +./install.sh +``` + +The old image stays in Docker's local cache until you `docker image prune` +it — handy if you need to roll back quickly. + +## Coexistence with the netanalyzer agent + +This bundle uses distinct image and container names +(`wbxprov-remote-agent`), so it can run on the same host as +`sha-remote-agent` (netanalyzer's agent) without any conflict. Keep the +two deploy folders separate — each has its own `.env` pointing at its own +server. + +## Troubleshooting + +- **"Cannot talk to the Docker daemon"** — either Docker isn't running or + your user isn't in the `docker` group. Try `sudo ./install.sh` or add + yourself to the group: `sudo usermod -aG docker $USER` and log back in. +- **"Checksum verification FAILED"** — the ZIP was corrupted in transit. + Re-transfer. +- **"exec /sbin/tini: exec format error"** or **"Image architecture + does not match this host"** — the ZIP was built for the wrong CPU + architecture (typically an Apple Silicon Mac produced an `arm64` image + for an `x86_64` Linux host). `install.sh` catches this and prints the + exact rebuild command; ask your build operator to run: + ``` + ./docker/remote-agent/package.sh --platform linux/amd64 + ``` + (or `linux/arm64` if this host is ARM — run `uname -m` to check: + `x86_64` → `linux/amd64`, `aarch64` → `linux/arm64`.) + + Note that `install.sh` **always** re-runs `docker load` on the bundled + tarball, so a stale image left from an earlier wrong-arch attempt at the + same version tag will be transparently replaced when you install a + corrected bundle — no need to `docker rmi` by hand. +- **Agent connects, then disconnects immediately** — `WS_TOKEN` doesn't + match the bot's `WS_TOKEN`. Fix in `.env`, then `docker compose restart`. +- **Agent never connects** — check `WS_URL` (correct hostname, correct + scheme `ws://` vs `wss://`) and that there's no firewall between this + host and the bot. +- **Requests to SIW fail from the agent's logs** — the container needs + direct network reachability to SIW. If SIW lives on the host's local + network and the container can't reach it, uncomment `network_mode: host` + in `docker-compose.yml` (Linux only) or attach the container to the + right user-defined network. diff --git a/docker/remote-agent/deploy/docker-compose.yml b/docker/remote-agent/deploy/docker-compose.yml new file mode 100644 index 0000000..c8cd2f4 --- /dev/null +++ b/docker/remote-agent/deploy/docker-compose.yml @@ -0,0 +1,26 @@ +# Runtime-only compose file that ships inside the deploy ZIP. +# Unlike the build-time compose one level up, this one does NOT build +# anything — it references the image loaded from the tarball +# (`wbxprov-remote-agent:__VERSION__`, replaced at package time). +# +# Run: +# ./install.sh # first-time setup (loads image, seeds .env, starts) +# docker compose up -d # subsequent starts once installed +# docker compose logs -f # tail logs +# docker compose down # stop + +services: + remote-agent: + image: wbxprov-remote-agent:__VERSION__ + container_name: wbxprov-remote-agent + restart: unless-stopped + env_file: + - .env + # The agent is a websocket CLIENT — no ports to publish. + stop_signal: SIGTERM + stop_grace_period: 10s + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' diff --git a/docker/remote-agent/deploy/install.sh b/docker/remote-agent/deploy/install.sh new file mode 100755 index 0000000..1ce171f --- /dev/null +++ b/docker/remote-agent/deploy/install.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# wbxStoreProvision Remote Agent — install / (re)start on a remote host. +# +# Run this after extracting the deploy ZIP: +# unzip wbxprov-remote-agent-.zip +# cd wbxprov-remote-agent- +# ./install.sh +# +# On first run: verifies the image tarball, loads it into Docker, and drops +# a starter .env so you can fill in WS_URL / WS_TOKEN. Re-runs are safe — +# the script is idempotent. + +set -euo pipefail + +# Move to the script's own directory so relative paths work regardless of +# where the user invoked it from. +cd "$(dirname "$0")" + +RED=$'\033[0;31m' +GRN=$'\033[0;32m' +YLW=$'\033[1;33m' +RST=$'\033[0m' + +log() { printf '%s[install]%s %s\n' "$GRN" "$RST" "$*"; } +warn() { printf '%s[install]%s %s\n' "$YLW" "$RST" "$*"; } +die() { printf '%s[install]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; } + +# --- 1. Preflight ---------------------------------------------------------- + +command -v docker >/dev/null 2>&1 || die "Docker not found on PATH." +docker info >/dev/null 2>&1 \ + || die "Cannot talk to the Docker daemon. Is it running / do you have permission?" + +# Detect either `docker compose` (v2 plugin) or the legacy `docker-compose`. +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + die "Neither 'docker compose' nor 'docker-compose' is available. Install Docker Compose." +fi + +[[ -f VERSION ]] || die "VERSION file missing from bundle — is this a valid deploy ZIP?" +VERSION="$(cat VERSION)" + +IMAGE_TARBALL="wbxprov-remote-agent-${VERSION}.tar.gz" +[[ -f "$IMAGE_TARBALL" ]] || die "Image tarball not found: $IMAGE_TARBALL" + +# --- 2. Verify checksum (optional; skip gracefully if no shasum tool) ----- + +if [[ -f SHA256SUMS ]]; then + if command -v sha256sum >/dev/null 2>&1; then + log "Verifying SHA256 checksum..." + sha256sum -c SHA256SUMS >/dev/null || die "Checksum verification FAILED." + elif command -v shasum >/dev/null 2>&1; then + log "Verifying SHA256 checksum (macOS shasum)..." + shasum -a 256 -c SHA256SUMS >/dev/null || die "Checksum verification FAILED." + else + warn "No sha256sum/shasum tool found — skipping integrity check." + fi + log "Checksum OK." +else + warn "No SHA256SUMS file in bundle — skipping integrity check." +fi + +# --- 3. Load the image ----------------------------------------------------- +# ALWAYS docker load, unconditionally. `docker load` reassigns the tag to +# whatever's in the tarball and is a fast no-op when the layers are already +# present, so this is safe to re-run. We intentionally do NOT "skip if the +# tag already exists" — a stale image left over from a previous wrong-arch +# attempt has this exact tag, and skipping the load would hide the correct +# image in the tarball we're actually holding. + +IMAGE_TAG="wbxprov-remote-agent:${VERSION}" + +log "Loading Docker image from ${IMAGE_TARBALL}..." +docker load -i "$IMAGE_TARBALL" + +# --- 3a. Platform sanity check -------------------------------------------- +# Inspect the image we just (re)loaded — this is the ground truth for what +# was actually shipped in this ZIP, independent of anything that was on the +# host before. If the tarball was built for a different CPU architecture +# than this host, Docker will let it "run" but tini (and node) fail with +# cryptic errors like "exec format error". Catch that up front with a +# clear message. + +IMAGE_ARCH="$(docker image inspect --format '{{.Architecture}}' "$IMAGE_TAG" 2>/dev/null || true)" +IMAGE_ID_SHORT="$(docker image inspect --format '{{.Id}}' "$IMAGE_TAG" 2>/dev/null | sed 's|^sha256:||' | cut -c1-12)" + +HOST_ARCH_RAW="$(uname -m)" +case "$HOST_ARCH_RAW" in + x86_64|amd64) HOST_ARCH="amd64" ;; + aarch64|arm64) HOST_ARCH="arm64" ;; + armv7l) HOST_ARCH="arm" ;; + *) HOST_ARCH="$HOST_ARCH_RAW" ;; +esac + +log "Loaded ${IMAGE_TAG} (id: ${IMAGE_ID_SHORT:-unknown}, arch: ${IMAGE_ARCH:-unknown}); host arch: ${HOST_ARCH}." + +if [[ -z "$IMAGE_ARCH" ]]; then + die "Could not read image architecture from Docker — is the image really loaded?" +fi + +if [[ "$IMAGE_ARCH" != "$HOST_ARCH" ]]; then + warn "Image architecture (${IMAGE_ARCH}) does not match this host (${HOST_ARCH})." + warn "The container would fail to start with 'exec /sbin/tini: exec format error'." + warn "" + warn "This means the ZIP was built for the wrong CPU. On the build host:" + warn " 1. git pull # make sure you have the fixed package.sh" + warn " 2. ./docker/remote-agent/package.sh --platform linux/${HOST_ARCH}" + warn "The updated package.sh verifies the architecture during build and" + warn "refuses to produce a ZIP that would fail this check." + die "Aborting install. Ship a linux/${HOST_ARCH} bundle and re-run this script." +fi + +# --- 4. Bootstrap .env ----------------------------------------------------- + +if [[ ! -f .env ]]; then + if [[ -f .env.example ]]; then + cp .env.example .env + warn ".env did not exist — copied .env.example into place." + warn "EDIT .env now to set WS_URL and WS_TOKEN, then re-run this script." + exit 0 + else + die ".env is missing and no .env.example is bundled. Cannot proceed." + fi +fi + +# --- 5. Start the container ----------------------------------------------- + +log "Starting wbxprov-remote-agent (version ${VERSION})..." +"${COMPOSE[@]}" up -d + +log "Done. Tail logs with:" +log " ${COMPOSE[*]} logs -f" +log "Stop with:" +log " ${COMPOSE[*]} down" diff --git a/docker/remote-agent/docker-compose.yml b/docker/remote-agent/docker-compose.yml new file mode 100644 index 0000000..634b8c8 --- /dev/null +++ b/docker/remote-agent/docker-compose.yml @@ -0,0 +1,40 @@ +# Build-time compose file for the wbxStoreProvision remote agent. +# This one BUILDS from source; the runtime-only version used inside the +# deploy ZIP lives at deploy/docker-compose.yml. +# +# docker compose -f docker/remote-agent/docker-compose.yml up -d --build +# +# Environment values come from docker/remote-agent/.env (copy the .env.example +# next to it). Set WS_URL to the main wbxStoreProvision bot's public +# websocket endpoint, and WS_TOKEN to the shared secret configured on the +# bot side. + +services: + remote-agent: + build: + context: . + dockerfile: Dockerfile + image: wbxprov-remote-agent:latest + container_name: wbxprov-remote-agent + restart: unless-stopped + env_file: + - .env + # The agent is a websocket CLIENT — nothing to publish. It just + # needs outbound network access to: + # - the main wbxStoreProvision bot (WS_URL) + # - Store Info Web (whatever internal SIW endpoint it proxies) + # + # If those live on the host's Docker network, uncomment + # `network_mode: host` (Linux only) or attach to a shared + # user-defined network. + # + # network_mode: host + + stop_signal: SIGTERM + stop_grace_period: 10s + + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' diff --git a/docker/remote-agent/inspect-bundle.sh b/docker/remote-agent/inspect-bundle.sh new file mode 100755 index 0000000..d5b3d7f --- /dev/null +++ b/docker/remote-agent/inspect-bundle.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# Inspect a wbxprov-remote-agent deploy ZIP (or the tarball inside one) and +# report the actual OS/architecture of the Docker image it contains — WITHOUT +# needing docker installed. Reads the OCI/Docker image manifest directly out +# of the tarball. +# +# Use this before shipping a ZIP to a remote host to confirm you built the +# right platform. The main `package.sh` already runs `docker image inspect` +# on the image it just built, but this script gives you a completely +# independent check (no docker daemon involved at all) that also works on +# machines that never had docker on them. +# +# Usage: +# ./docker/remote-agent/inspect-bundle.sh +# ./docker/remote-agent/inspect-bundle.sh # auto-detect newest ZIP in dist/ +# +# Exit codes: +# 0 bundle looks well-formed; arch/os printed +# 1 bad arguments / no bundle found +# 2 bundle is malformed or arch could not be read +# +# Optional env: +# EXPECTED_ARCH=amd64 die if the image's architecture does not match +# EXPECTED_OS=linux die if the image's OS does not match + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DIST_DIR="$SCRIPT_DIR/dist" + +RED=$'\033[0;31m' +GRN=$'\033[0;32m' +YLW=$'\033[1;33m' +RST=$'\033[0m' + +log() { printf '%s[inspect]%s %s\n' "$GRN" "$RST" "$*"; } +warn() { printf '%s[inspect]%s %s\n' "$YLW" "$RST" "$*"; } +die() { printf '%s[inspect]%s %s\n' "$RED" "$RST" "$*" >&2; exit 2; } + +command -v python3 >/dev/null 2>&1 || die "python3 is required (ships with macOS; on Linux: 'apt install python3')." + +BUNDLE="${1:-}" +if [[ -z "$BUNDLE" ]]; then + # Auto-detect: newest ZIP in dist/ + [[ -d "$DIST_DIR" ]] || { printf '%s\n' "usage: $0 "; exit 1; } + BUNDLE="$(ls -t "$DIST_DIR"/*.zip 2>/dev/null | head -n 1 || true)" + [[ -n "$BUNDLE" ]] || { printf '%s\n' "usage: $0 (no ZIPs in $DIST_DIR)"; exit 1; } + log "Auto-detected newest bundle: $BUNDLE" +fi + +[[ -e "$BUNDLE" ]] || die "File not found: $BUNDLE" + +# Resolve to the actual image tarball. If given a ZIP, extract to a tempdir +# and locate the tar.gz inside; otherwise assume the arg IS the tarball. +TMPDIR_INSPECT="" +cleanup() { [[ -n "$TMPDIR_INSPECT" ]] && rm -rf "$TMPDIR_INSPECT"; } +trap cleanup EXIT + +case "$BUNDLE" in + *.zip) + command -v unzip >/dev/null 2>&1 || die "unzip is required to inspect a ZIP." + TMPDIR_INSPECT="$(mktemp -d)" + log "Extracting ZIP into temp dir for inspection..." + unzip -q "$BUNDLE" -d "$TMPDIR_INSPECT" + TARBALL="$(find "$TMPDIR_INSPECT" -maxdepth 3 -name '*.tar.gz' -type f | head -n 1 || true)" + [[ -n "$TARBALL" ]] || die "Could not find an image tarball (*.tar.gz) inside the ZIP." + ;; + *.tar.gz|*.tgz|*.tar) + TARBALL="$BUNDLE" + ;; + *) + die "Unrecognized bundle type: $BUNDLE (expected .zip, .tar.gz, .tgz, or .tar)" + ;; +esac + +log "Reading image manifest from: $(basename "$TARBALL")" + +# The image tarball is a standard docker/OCI save. It contains: +# manifest.json -> lists image config path (per-tag entry) +# .json OR blobs/sha256/ -> per-image config JSON +# We read manifest.json to find the config blob, then read the config +# blob's 'architecture' and 'os' fields. Everything happens in-memory via +# `tar -xO`, so nothing is written to disk. + +DECOMPRESS="cat" +case "$TARBALL" in + *.gz|*.tgz) DECOMPRESS="gunzip -c" ;; +esac + +MANIFEST_JSON="$($DECOMPRESS "$TARBALL" | tar -xO manifest.json 2>/dev/null || true)" +[[ -n "$MANIFEST_JSON" ]] || die "No manifest.json in tarball — is this really a 'docker save' bundle?" + +CONFIG_PATH="$(printf '%s' "$MANIFEST_JSON" | python3 -c " +import json, sys +data = json.load(sys.stdin) +if not data: + sys.exit('empty manifest') +entry = data[0] +config = entry.get('Config') or entry.get('config') +if not config: + sys.exit('no Config in manifest entry') +print(config) +")" + +CONFIG_JSON="$($DECOMPRESS "$TARBALL" | tar -xO "$CONFIG_PATH" 2>/dev/null || true)" +[[ -n "$CONFIG_JSON" ]] || die "Could not read config blob '$CONFIG_PATH' from tarball." + +read -r ARCH OS <<<"$(printf '%s' "$CONFIG_JSON" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d.get('architecture','?'), d.get('os','?')) +")" + +log "Image tags: $(printf '%s' "$MANIFEST_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(', '.join(d[0].get('RepoTags') or ['(none)']))")" +log "Image OS: ${OS}" +log "Image arch: ${ARCH}" + +FAIL=0 + +if [[ -n "${EXPECTED_ARCH:-}" && "${ARCH}" != "${EXPECTED_ARCH}" ]]; then + warn "Expected arch '${EXPECTED_ARCH}' but got '${ARCH}'." + FAIL=1 +fi + +if [[ -n "${EXPECTED_OS:-}" && "${OS}" != "${EXPECTED_OS}" ]]; then + warn "Expected OS '${EXPECTED_OS}' but got '${OS}'." + FAIL=1 +fi + +if (( FAIL != 0 )); then + die "Bundle does not match expected OS/arch. Rebuild with the correct --platform." +fi + +log "OK — bundle looks well-formed." diff --git a/docker/remote-agent/package.json b/docker/remote-agent/package.json new file mode 100644 index 0000000..f88a18e --- /dev/null +++ b/docker/remote-agent/package.json @@ -0,0 +1,19 @@ +{ + "name": "wbxprov-remote-agent", + "version": "1.1.0", + "private": true, + "description": "Standalone container for the wbxStoreProvision remote agent (WebSocket proxy for Store Info Web from an internal network).", + "main": "remoteAgent.js", + "type": "commonjs", + "scripts": { + "start": "node remoteAgent.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "axios": "^1.16.1", + "dotenv": "^17.4.2", + "ws": "^8.20.1" + } +} diff --git a/docker/remote-agent/package.sh b/docker/remote-agent/package.sh new file mode 100755 index 0000000..f2303ca --- /dev/null +++ b/docker/remote-agent/package.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# +# Package the wbxStoreProvision remote agent into a self-contained ZIP +# for offline / manual transfer to a remote Docker host. +# +# What this script does: +# 1. Reads the version from docker/remote-agent/package.json. +# 2. Builds `wbxprov-remote-agent:` from the local source tree. +# 3. `docker save`s the image, gzip-compressed, into a temp staging dir. +# 4. Copies deploy/docker-compose.yml, deploy/install.sh, deploy/README.md, +# and .env.example into the staging dir. Rewrites the compose file's +# __VERSION__ placeholder to match the built image tag. +# 5. Writes VERSION and SHA256SUMS files for identification / integrity. +# 6. Zips the whole staging dir into docker/remote-agent/dist/. +# +# Usage: +# ./docker/remote-agent/package.sh # tag=package.json, platform=linux/amd64 +# ./docker/remote-agent/package.sh --tag 1.0.1 # override tag +# ./docker/remote-agent/package.sh --platform linux/arm64 # ARM Linux target +# ./docker/remote-agent/package.sh --platform linux/amd64 # explicit default (Linux RH/Rocky/CentOS/Ubuntu on Intel) +# +# The image is ALWAYS built for the target platform via `docker buildx +# build --platform ...` so the tarball you ship matches the remote host. +# Default is linux/amd64 because that's the overwhelmingly common Linux +# server architecture; override with --platform if your remote host is +# something else (e.g. linux/arm64 for a Raspberry Pi or ARM-based server). +# +# Requires: docker (with buildx), zip, node (for reading package.json), +# sha256sum OR shasum (macOS ships shasum by default). + +set -euo pipefail + +# --- Locations -------------------------------------------------------------- + +# The build context is this directory (docker/remote-agent), which contains +# both the Dockerfile and the single agent source file. This is intentionally +# self-contained — the agent doesn't need any of the bot's source. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTEXT_DIR="$SCRIPT_DIR" +DIST_DIR="$SCRIPT_DIR/dist" +DEPLOY_DIR="$SCRIPT_DIR/deploy" +DOCKERFILE="$SCRIPT_DIR/Dockerfile" + +# --- Colors ----------------------------------------------------------------- + +GRN=$'\033[0;32m' +YLW=$'\033[1;33m' +RED=$'\033[0;31m' +RST=$'\033[0m' + +log() { printf '%s[package]%s %s\n' "$GRN" "$RST" "$*"; } +warn() { printf '%s[package]%s %s\n' "$YLW" "$RST" "$*"; } +die() { printf '%s[package]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; } + +# --- Argument parsing ------------------------------------------------------- + +VERSION="" +# Default target platform. Overwhelming majority of Linux server hosts +# (RHEL, Rocky, CentOS, Ubuntu, Debian) run on x86_64. Override with +# --platform for ARM Linux (linux/arm64) or anything else. +PLATFORM="linux/amd64" +CHECK_ONLY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) + shift + VERSION="${1:-}" + shift || true + ;; + --platform) + shift + PLATFORM="${1:-}" + shift || true + ;; + --check) + # Preflight only: verify docker/buildx/binfmt/builder are set up + # for the requested --platform. Do not build anything. + CHECK_ONLY=1 + shift + ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + die "Unknown argument: $1 (try --help)" + ;; + esac +done + +[[ -n "$PLATFORM" ]] || die "--platform requires a value (e.g. linux/amd64, linux/arm64)" + +# --- Preflight -------------------------------------------------------------- + +command -v docker >/dev/null 2>&1 || die "docker not found on PATH." +command -v zip >/dev/null 2>&1 || die "zip not found on PATH." +command -v node >/dev/null 2>&1 || die "node not found on PATH." + +# buildx is required so we can cross-build for a specific target platform +# on any host (e.g. build linux/amd64 from an Apple Silicon Mac). +docker buildx version >/dev/null 2>&1 \ + || die "docker buildx not available. Install Docker Desktop or the buildx plugin." + +if [[ -z "$VERSION" ]]; then + VERSION="$(node -p "require('$SCRIPT_DIR/package.json').version")" +fi +[[ -n "$VERSION" ]] || die "Could not determine version." + +log "Packaging wbxprov-remote-agent version: ${VERSION}" +log "Target platform: ${PLATFORM}" + +IMAGE_TAG="wbxprov-remote-agent:${VERSION}" +BUNDLE_NAME="wbxprov-remote-agent-${VERSION}" +STAGING_DIR="$(mktemp -d)" +STAGING_ROOT="$STAGING_DIR/$BUNDLE_NAME" +mkdir -p "$STAGING_ROOT" + +# Guarantee cleanup even on error. +cleanup() { rm -rf "$STAGING_DIR"; } +trap cleanup EXIT + +# Normalize host arch into a linux/* platform string for cross-build detection. +HOST_ARCH_RAW="$(uname -m)" +case "$HOST_ARCH_RAW" in + x86_64|amd64) HOST_LINUX_PLATFORM="linux/amd64" ;; + aarch64|arm64) HOST_LINUX_PLATFORM="linux/arm64" ;; + armv7l) HOST_LINUX_PLATFORM="linux/arm/v7" ;; + *) HOST_LINUX_PLATFORM="linux/${HOST_ARCH_RAW}" ;; +esac + +EXPECTED_ARCH="${PLATFORM##*/}" + +# --- 1. Ensure a cross-arch-capable buildx builder -------------------------- +# The DEFAULT buildx builder on Docker Desktop uses the "docker" driver, which +# is tied to the daemon's native platform. Passing --platform linux/amd64 on +# an arm64 host with that driver can silently produce an arm64 image (which +# is exactly the "image arch does not match host" failure the install.sh +# sanity check catches on the target machine). +# +# We work around it by creating (once) a dedicated builder with the +# "docker-container" driver, which spins up an isolated BuildKit instance +# capable of cross-arch builds when QEMU/binfmt is available. + +BUILDER_NAME="wbxprov-remote-agent-builder" +CURRENT_BUILDER_DRIVER="" +if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then + CURRENT_BUILDER_DRIVER="$(docker buildx inspect "$BUILDER_NAME" 2>/dev/null | awk -F': *' '/^Driver:/ {print $2; exit}')" +fi + +if [[ -z "$CURRENT_BUILDER_DRIVER" ]]; then + log "Creating dedicated buildx builder '${BUILDER_NAME}' (docker-container driver)..." + docker buildx create \ + --name "$BUILDER_NAME" \ + --driver docker-container \ + --bootstrap >/dev/null +elif [[ "$CURRENT_BUILDER_DRIVER" != "docker-container" ]]; then + # A builder with this exact name exists but uses the wrong driver — the + # plain `docker` driver, most likely — which is the exact silent-fail + # source: it's pinned to the daemon's native arch and will happily ignore + # --platform. Recreate it correctly. + warn "Existing builder '${BUILDER_NAME}' uses driver '${CURRENT_BUILDER_DRIVER}', not 'docker-container'." + warn "Recreating it so cross-arch builds actually respect --platform..." + docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true + docker buildx create \ + --name "$BUILDER_NAME" \ + --driver docker-container \ + --bootstrap >/dev/null +else + # Right builder, right driver — just make sure it's up. + docker buildx inspect --bootstrap "$BUILDER_NAME" >/dev/null +fi + +# --- 2. Cross-arch binfmt (only when needed) -------------------------------- +# Docker Desktop ships QEMU/binfmt handlers by default so this usually no-ops, +# but plain Docker Engine, Colima, or rootless setups often don't. When we're +# cross-building, best-effort install binfmt for the target arch. If it fails +# (e.g. no --privileged, no internet, no image), warn but continue — the +# subsequent build step will fail fast with a clearer error if binfmt truly +# is missing. + +if [[ "$PLATFORM" != "$HOST_LINUX_PLATFORM" ]]; then + log "Cross-arch build (${HOST_LINUX_PLATFORM} -> ${PLATFORM}); ensuring binfmt handlers..." + if ! docker run --privileged --rm tonistiigi/binfmt --install "$EXPECTED_ARCH" >/dev/null 2>&1; then + warn "Could not auto-install binfmt for ${EXPECTED_ARCH}. If the build fails, install it manually:" + warn " docker run --privileged --rm tonistiigi/binfmt --install all" + fi +fi + +# --- 2a. --check preflight short-circuit ------------------------------------ +# If --check was passed, we've now verified: docker daemon reachable, buildx +# available, a docker-container builder exists (or was just recreated) for +# cross-arch, and binfmt handlers were attempted. Report and exit without +# building anything. + +if (( CHECK_ONLY == 1 )); then + log "" + log "==========================================================" + log " Preflight OK for target platform: ${PLATFORM}" + log " host platform: ${HOST_LINUX_PLATFORM}" + log " builder: ${BUILDER_NAME} (docker-container)" + if [[ "$PLATFORM" != "$HOST_LINUX_PLATFORM" ]]; then + log " cross-arch: yes (binfmt registered above)" + else + log " cross-arch: no (native build)" + fi + log "==========================================================" + log "Re-run without --check to actually produce a bundle." + exit 0 +fi + +# --- 3. Build directly to a portable tarball -------------------------------- +# `--output type=docker,dest=...` writes a `docker load`-compatible tarball +# straight to disk. This intentionally bypasses `--load` (and therefore the +# question of whether the local daemon can even store cross-arch images). + +UNZIPPED_TAR="$STAGING_ROOT/${BUNDLE_NAME}.tar" +log "Building ${IMAGE_TAG} for ${PLATFORM} -> $(basename "$UNZIPPED_TAR") (context = ${CONTEXT_DIR})..." +docker buildx build \ + --builder "$BUILDER_NAME" \ + --platform "$PLATFORM" \ + --output "type=docker,dest=${UNZIPPED_TAR},name=${IMAGE_TAG}" \ + -f "$DOCKERFILE" \ + "$CONTEXT_DIR" + +[[ -s "$UNZIPPED_TAR" ]] || die "buildx produced no output tarball. Aborting." + +# --- 4. Verify the built image actually matches --platform ------------------ +# Regression guard: if buildx (or binfmt) silently ignored the requested +# platform, catch it here instead of shipping a broken bundle that only +# fails on the remote host with an "exec format error". + +log "Verifying built image architecture..." +docker load -i "$UNZIPPED_TAR" >/dev/null +ACTUAL_ARCH="$(docker image inspect --format '{{.Architecture}}' "$IMAGE_TAG")" +if [[ "$ACTUAL_ARCH" != "$EXPECTED_ARCH" ]]; then + die "Built image architecture is '${ACTUAL_ARCH}' but '${EXPECTED_ARCH}' was requested. + This usually means buildx couldn't cross-compile for ${PLATFORM}. + Try installing binfmt handlers explicitly: + docker run --privileged --rm tonistiigi/binfmt --install all + Then re-run: + $0 --platform ${PLATFORM}" +fi +log "Verified: image architecture is ${ACTUAL_ARCH} (matches requested ${EXPECTED_ARCH})." + +# Also tag :latest locally for convenience (only when it matches the host, +# so we don't leave a broken cross-arch :latest sitting in the daemon). +if [[ "$PLATFORM" == "$HOST_LINUX_PLATFORM" ]]; then + docker tag "$IMAGE_TAG" "wbxprov-remote-agent:latest" 2>/dev/null || true +fi + +# --- 5. Compress the tarball ------------------------------------------------ + +IMAGE_TARBALL="${BUNDLE_NAME}.tar.gz" +log "Compressing image tarball -> ${IMAGE_TARBALL}..." +gzip -c "$UNZIPPED_TAR" > "$STAGING_ROOT/$IMAGE_TARBALL" +rm -f "$UNZIPPED_TAR" + +TAR_SIZE_MB="$(du -m "$STAGING_ROOT/$IMAGE_TARBALL" | cut -f1)" +log "Image tarball size: ${TAR_SIZE_MB} MB" + +# --- 6. Copy deploy assets -------------------------------------------------- + +log "Copying deploy assets into bundle..." +cp "$SCRIPT_DIR/.env.example" "$STAGING_ROOT/.env.example" +cp "$DEPLOY_DIR/install.sh" "$STAGING_ROOT/install.sh" +cp "$DEPLOY_DIR/README.md" "$STAGING_ROOT/README.md" + +# Template the version into the runtime compose file so it references the +# specific image tag we just built. +sed "s|__VERSION__|${VERSION}|g" \ + "$DEPLOY_DIR/docker-compose.yml" > "$STAGING_ROOT/docker-compose.yml" + +chmod +x "$STAGING_ROOT/install.sh" + +# --- 7. VERSION + SHA256SUMS ----------------------------------------------- + +printf '%s\n' "$VERSION" > "$STAGING_ROOT/VERSION" + +log "Computing SHA-256 checksum for the image tarball..." +pushd "$STAGING_ROOT" >/dev/null +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$IMAGE_TARBALL" > SHA256SUMS +elif command -v shasum >/dev/null 2>&1; then + # macOS: shasum -a 256 emits the same " " format + # `sha256sum -c` understands. + shasum -a 256 "$IMAGE_TARBALL" > SHA256SUMS +else + warn "No sha256sum/shasum available — skipping checksum file." +fi +popd >/dev/null + +# --- 8. Zip ----------------------------------------------------------------- + +mkdir -p "$DIST_DIR" +ZIP_PATH="$DIST_DIR/${BUNDLE_NAME}.zip" +rm -f "$ZIP_PATH" + +log "Creating ZIP: ${ZIP_PATH}" +# Zip from inside the temp dir so the archive contains the top-level folder +# with the bundle name (matching what install.sh expects when unzipped). +(cd "$STAGING_DIR" && zip -qr "$ZIP_PATH" "$BUNDLE_NAME") + +ZIP_SIZE_MB="$(du -m "$ZIP_PATH" | cut -f1)" + +# --- 9. Independent post-build inspection ---------------------------------- +# Final belt-and-suspenders: run inspect-bundle.sh against the ZIP we just +# produced. This reads the image config straight out of the tarball WITHOUT +# going through the docker daemon, so it catches anything a misbehaving +# daemon or `docker image inspect` might have hidden earlier. + +INSPECT_SCRIPT="$SCRIPT_DIR/inspect-bundle.sh" +if [[ -x "$INSPECT_SCRIPT" ]]; then + log "Running independent (no-docker) inspection on the final ZIP..." + EXPECTED_OS=linux EXPECTED_ARCH="$EXPECTED_ARCH" \ + "$INSPECT_SCRIPT" "$ZIP_PATH" \ + || die "Independent inspection FAILED. Bundle at $ZIP_PATH is not fit to ship." +else + warn "inspect-bundle.sh not found or not executable; skipping independent verification." +fi + +# --- 10. Done --------------------------------------------------------------- + +log "" +log "==========================================================" +log " Bundle ready:" +log " $ZIP_PATH" +log " (${ZIP_SIZE_MB} MB, built for ${PLATFORM})" +log "==========================================================" +log "" +log "Transfer to the remote host, then:" +log " unzip ${BUNDLE_NAME}.zip" +log " cd ${BUNDLE_NAME}" +log " ./install.sh" diff --git a/docker/remote-agent/remoteAgent.js b/docker/remote-agent/remoteAgent.js new file mode 100644 index 0000000..2e8f7ef --- /dev/null +++ b/docker/remote-agent/remoteAgent.js @@ -0,0 +1,129 @@ +const WebSocket = require('ws'); +const axios = require('axios'); +const https = require('https'); +require('dotenv').config(); + +const WS_URL = process.env.WS_URL; +const WS_TOKEN = process.env.WS_TOKEN; + +if (!WS_URL) { + console.error('WS_URL is not set in .env'); + process.exit(1); +} + +const INITIAL_BACKOFF_MS = 2000; +const MAX_BACKOFF_MS = 60000; +const PROXY_TIMEOUT_MS = 30000; + +// Shared https.Agent used only when the bot flags a proxied request with +// `insecure: true` (e.g. reaching Store Info Web, which is served with an +// internal-CA cert Node doesn't know about). All other requests use axios's +// default (validated) TLS. Kept as a module-level singleton so we don't +// leak sockets per request. +const insecureHttpsAgent = new https.Agent({ rejectUnauthorized: false }); + +let ws = null; +let reconnectAttempts = 0; +let shuttingDown = false; + +/** + * If WS_TOKEN is provided, send it as an Authorization: Bearer header so the + * secret stays out of access logs. (The server still accepts the legacy + * ?token=... query parameter for backward compatibility.) + */ +function buildClientOptions() { + if (!WS_TOKEN) return undefined; + return { headers: { Authorization: `Bearer ${WS_TOKEN}` } }; +} + +function connect() { + console.log(`Connecting to ${WS_URL}...`); + + ws = new WebSocket(WS_URL, buildClientOptions()); + + ws.on('open', () => { + console.log('Remote Agent connected to wbxStoreProvision'); + reconnectAttempts = 0; + }); + + ws.on('message', async (data) => { + let request; + try { + request = JSON.parse(data); + if (request.action !== 'proxyRequest') return; + + const insecure = request.insecure === true; + console.log( + `Proxying ${request.method || 'GET'} ${request.url}${insecure ? ' (insecure TLS)' : ''}`, + ); + + const response = await axios({ + method: request.method || 'GET', + url: request.url, + headers: request.headers || {}, + auth: request.auth || undefined, + data: request.body || undefined, + timeout: PROXY_TIMEOUT_MS, + ...(insecure ? { httpsAgent: insecureHttpsAgent } : {}), + }); + + ws.send( + JSON.stringify({ + requestId: request.requestId, + status: response.status, + data: response.data, + headers: response.headers, + }), + ); + } catch (err) { + console.error('Proxy error:', err.message); + ws.send( + JSON.stringify({ + requestId: request ? request.requestId : null, + error: err.message, + status: err.response?.status || 500, + data: err.response?.data || null, + }), + ); + } + }); + + ws.on('close', (code) => { + console.log(`Disconnected (code: ${code}).`); + if (!shuttingDown) scheduleReconnect(); + }); + + ws.on('error', (err) => { + console.error('WebSocket error:', err.message); + }); +} + +function scheduleReconnect() { + reconnectAttempts++; + const backoff = Math.min( + INITIAL_BACKOFF_MS * Math.pow(1.5, reconnectAttempts - 1), + MAX_BACKOFF_MS, + ); + console.log( + `Reconnecting in ${Math.round(backoff / 1000)}s... (attempt ${reconnectAttempts})`, + ); + setTimeout(connect, backoff); +} + +connect(); + +function shutdownRemote(signal) { + console.log(`${signal} received. Shutting down remote agent...`); + shuttingDown = true; + if (ws) { + try { + ws.close(); + } catch (_e) { + // ignore close errors during shutdown + } + } + process.exit(0); +} + +process.on('SIGINT', () => shutdownRemote('SIGINT')); +process.on('SIGTERM', () => shutdownRemote('SIGTERM')); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..59fbe48 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import prettier from 'eslint-config-prettier'; + +export default [ + { + ignores: ['node_modules/**', 'greetings/**', 'buildingFile.csv', 'locationFile.csv'], + }, + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + globals: { + ...globals.node, + fetch: 'readonly', + FormData: 'readonly', + Blob: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + eqeqeq: ['error', 'always'], + 'no-var': 'error', + 'prefer-const': 'warn', + }, + }, + prettier, +]; diff --git a/greetings/AEGreeting.wav b/greetings/AEGreeting.wav new file mode 100644 index 0000000000000000000000000000000000000000..2503d63c76d7dc70dcf424bc9ecc69c5fe65124f GIT binary patch literal 44258 zcmY(r_g5RwwkLXKt+&>^ALg8Iav}*ZISEY8A{a2)29rS|2$Usq4*$V>b7$^7PJjRj zP!<`45;-G`ZE_G<5s93^uke}s=Dk`Vy1TZjtGlbK_Wpc6o0!nx;8oV&{+8e$ACyBV zH2U*zfBV~i{o8-}o1fv|e*ONJzx|)!NKQsp;r{{t(tss0qcG#~@72ZC+41qo`Ni4s z(ecUg@sU;wel%LJb##2JeJn?y{5pPedV2EsdwdK&9;^RV^Q-!C6MPR8?a?nAP#)U%!Zf`Du;qBes-R;%c>Dk5A_09Da_!2mH^LX&)_V(_+rW%x*pFgXseig7& zRZ(76T3S+6SXhvso12@LUrlOjznWfgo>Ku!=De= z)z?*(6}MLx%gbw;;>(+gLQ-Bq(e05+d8ks#wdY5-HK5h1lFF*PtDiOZcV!j%gd$=| zepv~rtc+Aa$}hzik_syGPgWNXGD)XpRRtv^+_mOpRWQ7HkOhC2urj+aHT-pMYGOvM zKF&){RqyYsWXqT8jf%3I^mN3H?5-S>LnIJO@K|I99-dZ^o|O&9pm2E^SX^orF%yGF z-aMd)So}FICo=x{*2E`v&AFuKNl zEg;W+`$HdMgAlbex00#s_UQ+hQ?P2djX|Z-86Dr;jCKC_yN;o8au=h%rIF0-5~jeM zJmPP8^cHF}oip&-TK}(qJT)-#?P8Lfnwr=>e7wJ#OCVm%ZlzLbto~$s$dkW6HGp_^ zGs)B@3a49ugt@o{#SO7(%~Tqj|I^X%`9JHuina%8aYHM$QkAHBqbL7e(hu+k};*5qtAyS=?__+~$o#TNOSJhytL9}9_Kh1J(lK2SJaK3 zy=)BIUJretfBwX18S@W=dL?A=`iB-h3{9+GxY$EPuSs>Z+7B(1&IFW)XCh*1bUz4c zVDnNx+SH0?-%VzI{M_2e+gEtMNh{jShB+9T*yy=A84mitAv5d$2}a?}6e1`A{np>z z3Swqq;A?Nj_afAD>i*e6rp*Y{Z_binUUo(hD+BY_rhy}`7!=k&YbkY|B3^9bnXi+* zp`Mk2aj1#6(4Ir#e)vMEW%Ap$W7oZ)b_ROpdZykck<%XZR>nVT8*15|Txmg`i;cOi zfuWwYoz+JCMl-$sLw!A&!|hgL_$}|H>PviN3C`u7RKmHd2hFxM*MI)>sg~T#8Ax=sGSb%reKN6jOB1r1>b`vbQrE=jT?~F@ zX=DIiabpXp&!vDxt*@=EBe!q_yMa#DrbdQ_#%9(IJ{f~;bV>ucp_$nsT8;8{va`0d zw6?W(_K8pq_knqZ$!_Z#kf@VFe7)RUT;083K@oAeYtsWFk!WCKeD?d=5i0p@WJGv) zWK?WwCh=&0ePwlZb8|<1d|yzKTXQinTy;;v6Of<^o{q#7ohqc8>q`qut7}rJ z?4*K-!XVNRa0G@xAXQ$d6+3%d8`ACF9mUy6btwsp%E6=3b8=9G{DS)ng>p|S-P_xd z$u$?3cZ6&_HXE6ZA>`(k*J$Ji2RmEay9YAW>0N0tiHO4yummEhQnS4A9wm|P?h`iF zN=-;fDMyr|%8!aR@3vRZ zR#mF~%G0Z25?B-PD0EH*wmSdr;itMrR&=Pi&|E7Y?r^t+vWuL{oErR1@xueErts+K zOp&+$Q+=c=QXCTY(q*Njb(HL~WJ6P_uK2lIexW{;ZSU;Js#Vom{9SIr$&unrb-J~G zw12)|x_iENPPnO1o}HXk7wr9%Y6zQU@@?b-W>baRLMxS3+J}Q9)qdgqHf{rTv|XmT zK3451u6MPEXQc;uiZbm+xs zOtEvfp;hi4RZ}zS><*N1bm2#=_=PqfhdZ$dKzTMeB-j!8s7D=_5!#%LA zQ0|u>99Ad`k4`SMnu}x2-S*|d?y*!Umua=PH^6DgzN^6Oa#nd|}~eE8ui~ z{#5_ZFHMbHM6`n;ME{wQk*=GU6NAnEP)q*Mz-XQJg;^Sa*}z8Mgy=Zm)K~w3`sE9o z+2iMJZ)5;@VQOLEALrS^V11~ke4@2;lii)o3?O<2P>7wxt*4>u%g5GQa(Bn9o3qv9 zL||)Tz7|zQW;K6kYO3Y(g+9(UFCqGRw&q5%U06da>q8yAmO01|a)Q1zG|;tzLSFNH z*!Aq%Pc$-BJeuZYZ(<11G4n8R<^~AAFv%a9DNUjY^eYE52t>y?(9o8BuvJT?d?ZsT z!U=-Cy(I*q3kf#0XcM98$rLcRHHl`{9c-;22D%2XElhj6mKw?A&*Vn(fOf~x+8kn_ zW8iCJF5>bj}2O8N4I+BQ0kvNJJxX{c-95CBUQwUS$!>+2dh3mYZQmS$!a zMi9Hekax@7G#Z`KNMZ2jmHrO4HnuhvR&H;?<2U&nHjDP?X?>0Na&>fcb9MEGg{7cR zmdE)$J-s}>Xs#L)85R^65*id8@iwFQ>O%Q-er9Z9a%xYuaEHdG!qeiT-lf4)Gm`RU zo8MOEz_6QLIe*AX!=}Z=$EBoYR{kskZ_MWW#*d}rZwoa!>FDJ5@YJ~YJgs`~M!xt> zbHBTLfGgX}D@;v5;WN^*D^E(!q?;90>zZ3^*$yEyCqLt^IthMSi6kfw2=|vLl`8xG z249wgB>v1!J0{FvqdLi=evbg9B3%|?9 zWu=gCneeonlH4Lp_Wf}=q2L6ABmn0&4}riUvU3Rd90W;*$}S>Z;ZslqTnQo#k4GQ~ zd6@_V4wp^BWu@nqV9~kOWLNAs3N@!jvN6Sy&_zlZD2h z5t&3Jiipp^3Mm>f8gkb^*EqLElkRu&vZ%0QyC5NI?CfdYOu zGAlhJ3!R>WLc`J6?2Meu%*^bp?2L2_J_DJZot=rv!r;;oxwwqc9qL)F8j?DFim>P~(vQylJJ%A`v5&2h0(q0wH{>?mWTSVU&gMR_(nr?@B=my?qLCzj(7X<394JQ56{%3D%8 z0!zwB!=F~B#3U3P6OxnA$1?SG9wE2-vN$a|IyT{5WMp(gdXap6`PNFbV6zD|t@ z^l`D&eP%dK<;LJ!Ya6+oW*V3ReZB0ApZ%^o{H58^y!+q(of$q!rA*pGL*V}QhEM*o zY_6>_fPVSEY6F~DpD3Ot4l^UcHikMcEW4Z4CeVg|`;z9=_NhM0%yvV7fxgsxVYu2N zvM^`-+s934-={jIC3JIuT5-Io?J$Xm7}O5#8B6Kxff;vWqxjs38sHxxY~`j^6jlvtR8Q3Q+xQjrrzx;-Yv|R=Bzm|8N9s)$<7iw{Obf{LV zm9hhwTy}6ElO62sD^yCQN^^X6eylmvYSjw4Ql;46J5b2>cJ>ayhElDPD-KVN)Jip| z2DVDs{=vci?(W|H{$mRVa-~wPP=Z#KDgY^fBm28MyN_jW@6l#&514}@d$ir#+1=lf zZtd;wZtZ}*-Ms@~4h;4|d%%8Y2YlyO%b;yJXbUu~0KbpT$`z02dOQgB!MW7nO2Bmi zfaekRd4z5rFAcnEj~|Di>R0&}r~|50peW=A>c#PC?c0^1sr#cL(Q5pzkiQokJtLY; zc&nbBuSOmI__29)ytA?{$ERgoZK<-7kX2Ri#FT7gLUbJAyd0g0xsmN1kqGw(`}ftV z)rEtq>fJ@@QD#!6Vrk>HaD9AiBQs-hWIi!UDiGpBNP@m^;i&^X6HyVPUE(13NMXPH zb?l_5#xIaGUFz)~J-ra@oiL~H_jy0R`^G;C4vz`&3CX&O3vO*qU?O^SK9F}WELxgcJ87x8mK0gIR$&khdzE}RSUee-n!;rBXycwj#v zZcB2RRWUOc=`Z8=$voYOVuh#IbmycOG>G2|_wd^sxQ&XR5UT=w!Z&6l#o;k?B1v4} zTL~fH^?Qvv=MD!C&-gZ)6zm--<|DnmPIx14yaSep;jp)gX$34~Y9a#`CK-+Jiu^WC z3R2E4CBm`>mXbo=Yz{6R67cZUw?*st`IDc9xMY$&UOspPrR~oXU;CpjD$q5v1ykxrbD# z>~f6|H9Imlo#OjbA{NH^L@8#*;h_ofnH$OP%i~|iU==GHh%2RbdU$4hW_?IBIy&`z zbZUKJ9T5}Y;s*2ac7?imy1K){W`+Iy0YPskm(6YO;q$w?`b0B`*G{kO%!~m-VP3%H zAU$Hn85DM(aG1+rwzB$qdfK_7^-y;g2MdVKlcy#N)W%TatpU;~;C6+qV)JIKW}|`@4Jjx*0wFhtAZe|K8wf z0Lf^kb~sph^)|MMQloiXKFK{&z-CRsynG{_4gdOwf%wD!XmK?#nr)`=Y%Sfo$j!Xy z2!2OTsaN^qxUqAW}U==Dwf*OB8Ly_%rc^PTN{RamJ1E5j%M(24x~@AVoJ z<1khO*TckI-CRc*3J#y@7HkHE&33o*_ab1PHU@wGX0Y_}pX6jyvouCyudA`;8l{#Z z4hfR<^+}?mmioK+3sJs~rnQte=D0rEpujVP+PunRRuHxv;2tq4+yIXIb1YTJs0s-0x2e$Ll}W z^ICP{8e*Z(qomKmV+IO}-(BApY-P*>lk5b>j6rrP{lJKNLnPhBi&Cg2z&)=R| z2pc|=SrgyKdO8M!?5v%am<{A+?qqgUL~If~Z*_KhG1Lz7^!KO6)wJ4rTL1WPZ#y6E zXk#DM)l6<;^smN;N4|}RtH008rMZ~C_~S1<_YQJBwPRqgm)kMtWn<$tOmA#xX&)y> zg-1re$8COu9E_j+ z@zgMwO>Us~2zeYZkufb!&LjpqL52mN{Tuop6`2^zAMWHf#c^yo8$ECfcO)#&u5$unx=EVj1 zdIu$c9iDs(HGKN#bF(Z?3!|;CozcP&!>p}czj9gt>f0-ttqApY_k2?}KJd-gO8?0d zT{{Vj&hG4DwX|?mj@I@OJSL6K;&%5-Vm+N*VW~?)L%W_9`hPvqa~fbW+q$`}lvZAV zt&QhA7hsZX7MEA%{mRKZ@_c1{c*zGC{-x(6VzWCt0B*{fb%#2}j&^snF6TrQ#{mJg$!9SNL`ZGla_ygmmuEu(QJu!3}U+HI%$?Y>SljGL`ev@r97MsQ4rFc1d zze$vic6IPCJuM7%{?M_IjZbr%>zQKdG`}J&Xu7+N%WY-yQeggmFt1b*w@vgt&F!V$ zpHI!b_TP3>$joUWj~C}tHY}L#Wl)*J%gR(wN8clHe|INu)eFqIf9Tmp5cU}qnn2Xv zzx{fD^y{LKNn>^jCg8rFuxP2cvxEO5)Xqrn&lfh1q3<|UPJa)({{}lbowDA?;joyU z6Sy#M&p_Eoe^>A5I|mbkCr_zzIf)!*UDJ`sj;19$V7ja zkj>(Axq~B18|xc9P6xMLyq*~s^=@-wbY%S2*UrN5ImF?m`}nq)-^J-?Gemu&vdzWO z&JHeDF!duLCLJ%Co|{SacC;|ogV>q6Ohqp9hIw6VMt5gFUNQBxkK5kPn^?@sx;~wn z{3am;`8ry^G%ztUaS8Ci%zf))(`gKL&)D$DKxbQbxA5!Qp?rR3YIZj(#0_d;sHbma zYy4+ODSSS!p?!AwMYrYPOhesC^0$glN4D<~kCQuh&>2Nn-RvM_Rwmz|_pD&s^jt-9} zu8s~2kIYs_`@XU^Hh>r!Svj}_FNirU)TTy2n{@T{_6aA|kpZtmqll|3KW4ve+(i30 zS{fUhnA*H@hLw%BKhi@CPDghik0%&g!p6kJCEy;2<)>xvC?BXf1VHPrqEqL3IJCwF z3cYjgeVAWBSVTlTLOLqw?da+gjC?PP@USxjx6<*|J35!$$Ye3a!(mVdE31H*Q3jJv z>lDle!QAZKJz!O9L*0G7yovSfkl^4qh`jZQsqwKT=}kgRMA+NZqq&KJ!2#jO%(v7a zPY0-_rM;DXWHO({VS)!81q<}`i`X3(wsSi>`QKB5e0_tmzKse612bnafj+RHcV&_R z0gophUnoru506Q_S(i#n;=}!YePM6Xf+-3^7ihLHvV~g`N(shUf$f-{H(cqWR)Lksw%6hAMUOJNe!6rU(wU) z#r4e%Xz=Ft{sEBAH4pcIg1)9O|o;_UR~9Bczd`|6S31}A*Dy}G&vdyi)T-N`wocKKWGW$ib7`||VR*5ybay8<8kLRmcMptA zj0j4;K}3hdq(((2cC!Z8-{2>Z;`Ux1@^;{>Wd5s2k{Rw70LCZ)M9sxC?)&J0V<(sE z?%~M7LH@|n5O0)}up<`sb$0a*OP6ORrsgFx z{_9OnZ?bQC&uC~4pVK}ibmS#Zwc0rzCi`{TAvw$B%J{?c%HA`e|K+ zbwTF@UIf@NVRzS$sJGD`rg}P0pZjrGtX#;@97`UJlnX_siS=e54Vu78?U2;EP8u4gT@`+8|vv73)`6-;g19_EB)tB z|9W9R!yB6F>zGLJ^^Qb@B#K$htxS3=vuji`IxsyeYUA`(`Pf@Pp8xfi?koK6+CV#l zJCzt$eHIOi7jqa4MhlhJCK?|Ujm%1>rZ?mK+#Rf6>gyYso10lXzn>nP9q#3_+jzom zu-Gvl=OY@E-QG8{_0HeT*2F+h9}si82BwZ7akHHaDwu`nGjT8(YPPdqFLX^mO#}bYAE|EWO^I z_A{wuGPSk2xuuoPWYFlq&EdB7i03vl1D&l+0o<>v`$EUS$Tl#0uC1jBP;;Pztu)Yy zRtA&F;&kxF7NiN@Pykel_CV-akA#S6!Iu>jWqU z{TI5R1Hd$3>HlxaWB~7=Q!qY0kHM$;Is()G-RigTw#NF$%W4D)hL%=f+SA)5oSOWe ziAnQ)^~>~EtERTWU&pxrGHq&XWwg?mOinMaTQoC0pMwRh+JPqk`l<^$U>h($*3nc? z29JQ!)XJpM0EyEl08#DSJT5yUz{$??*Ui!e>Ioaz!gx33mnpTWl?AT@2WQ4~dQ{jlTyN6I0s09QB17IW? zy^0sNQ@?!pQeRi!+P_UK@Nx8!OQ%NooHibhH!5|rHqrg#HxSvl!8Yi?hD@Q#-96nc zY`qG4I~Wa(To!kDt287tJE{T+bGJ3neg4-U5VZ4aMqM+lrJ>zDyx87`s0BPu6Q!$- zQ-z+XrI|+XBGt0;ZOOj?GmC&O%=w$X1r;TsuEdC2ZNZ_}IlfqluMLrq|-PJT2pi%X_V#E!!4 zz4*PeJ@k${xCi9fv)^7sir(@+)lZMp_DpT1(Qd*9N;|!Qos#w4*PhrZ8s;#k{9R4J z_2@;6p?bg6kNjXxTUg@cKHsTi7KcjPf#t+H`}K8-dzfNZOQRS6_}z907v1!cmeb1& zcxgWk_YsjBIBW_#+BZ4e^=)qlzk_>V3qqK`>;33|&Hwm$D3#S~Z4@{f8^)=paG0(A zejbs*K6hQ5&OV7VL0tEY_h)v36+iCB+VRx3Hs_c2a|?09APfi7*h;V$ zEOmZrVk*j7|H)sFxW4zv)DKkLE?Z=4>ODMP!J`1hRi|(x#4AWXyImaPWc2J$y`WC1 zU;C%JvCuy5YcsoL@$xK_Os02Q^V5sMbFXtdZGU~ zgXd@!I5>+GQUN~2>z(kng*w|BK6|1c*fa07)=<|IILGX8w}uVQ#!j^Wd(JE*;@E#%o#1NW&6zkvqIL}U+r6zVb}2npyUH*wqPtTi`hcRNF!r@A(! zq=0YDO@g>Sn#j>@fd9t7v!S`2)5?^?Ko%^6(bR+Gwf4Gu3VXw0-A%1s zY${{yeWb7LqlJmLuiH**i+GOJI^~J!nf5JZwQxAiRPOM~dsnc4z0fsvad%&4vIfOW z*4N0f-p#OKCaaT4p>>NUqaAH540ZJ_UO}DbxxJG;^tOc`y?kOpAGf!Q-pb+iPlUTV z0KtKYt(}eAck%Lc8(T2a(>1vv<_(D3*`0m8f~ELSmsCfZ73DTU+}(>+3r}z9d&2D2|Vmszbm?0sYXy zK469R0qwLa-P+lD49KM0dw^b29col6KtllzX@6%|aRBsAyHcQ3+LZ2YgOe*2>POY@ zBMWq>(x?Cp1vn$X^~mH0`+(;Hgce|f0RQz%uX_$clt(%5|00Hf;1@7RCqSk9sL_3> zt^)B#MJ4zIVN!MVzrr36`TUAdKzveB2I7(8;-be=2!0+5_lp)9t-hb z<=6JVKK^xp@Sg?$SaAPu!D4Y(EEe;h<$rIYK|!Iw51{sQvXRKFUnMg$1A)kjU71N! zXpvtlksEOM0VaL-;A%yxmTDCTRoW}!u@aYGUVXl`eW5+c!>F$pr#I3rR|!kfC~Qph z_n5@>x0jOj$j#+SEj)X8U_mmzpE$aBGBtB5k)&0@W3mzWYxK;DWGeA%PUL-XSYlYz zVWp-t166waGdVdi{4gc4I=K*;a}#rj(B>jGZmXwbiZ|cyu zMP$YoUA)JhN2h0m0a@~8=Em~o!qUt)#Tp?gqv%akf_G@3TZo@$R7gN>wn44nl)jdY&9 zc&_)-HfW67LLrlz07fNR-&~m&^z{lC!|jcApX%u7SvtMvv#3Cd*v#k{n2UZ-To?6r z_Kv2wS?WCleyF+oyFpenl|pW2bqq7(8)*Q&7Vx@y`Uk$pc-fg7 z8i0FaVgZG%@<0q&4@8{Z!Vv($_4bSBQhn?}y}rHy5Mc(a@aT>8_4Q4leqwTf-`6ji ze0c2)U^GyS%x%4X3Rx6T-$3u+k4=vV`uW1)#dqFNQwXRxG`DtsH{RYtuCF7vw)KyG zpBDD_gWD^O@U(pi0p+Eotp{?D)BLLg9sKbLK-~5B3&xZY?p8*i9y9>;xe~LfAf9Sw zbqdBreLOy|Up%c2cQk*zLQ`uS`+&BmKce01Go)JJ+aX z0k{VZKt$dv5Q)S>zIb%}D=N&*>ZPHPks0)rW5mj6drMOjrJ2(!9G@H#iva>THafc- z>0|*hFfy|C@bJAJ?_~j8v4z<;GPfsN`@SXpK0hTE@C9Q@4kn-(TL*iufYd=&Q$s^@ z8-HR;x*(aG9UT+&ba(NmlRWIrO-ytRUPA06$AH=u1X9f{Eu5b5L=Q&?Yik=TYp^)` z-pvXClFa~7SQmeE<{S=>L)=T3zRxaPM7UU+SzEYzdse)~^$L5yg^q8&35oX(tBBkj zn-cT+pvMc#TTu}a{{C+wA~0%&WMX`HU}$3Et5S_kh>MO4kBSJ7iGG`sg2=%T3l3!) z3*Y8uXQroTz*4lLI4e#=#7Dmi4-XFs4-1QmicdlibN3F`zpYBXew$tTu`1nA>?ma$ z`%4Rp-}g|F9u97HZULS-M^^x3?d}nbNMgf6ykP!7I+m@To|zO+jE{a_+^oSM;@-ZG z1R4u`>HYe~(zhSqmX7k8veV$1DR?|uc6P9`wY;*nzPY85 zpA=|`dDZCBTw*by2nB>U`9MvTmPaIM?-U1$b=k(IY*QiM0dk(}v+{f#3Z9k{mlPkL zmW(PW-I1;U3FP?L$jI=>*yPvslpqg#S375Kx8#gNfw-@O4+7$)^r*0)kkH6vgi^XD znV+5cHn%F>Bju;3W+bO$at>A73oBp0F8z@1EAa$GVsh;Jg!r^f)Xmxc+UobOlIf|j z(P8l*kQ5J0%A!4-Us>2$T07f^%YJmUa~NE1*Px^{BEZ+n3+DGG9=;)&86F!Mnwpy1 z+|c51sY%H((eI<;VxtqHlTy=f4_D@*?<4?c<9^zAGbr9Ifn(Oe`UG)^_DF zLT(!f+}IsH$7luU2$;fIOrD5;GPQvDfsPJ!va~YRHP$gOH}Zpz(^?wqfNqn< zVv6{p(y{GgNn(_*ow*HIBc7WXTU5ujwY1dNH#Rh}>7AoJ>gDnHuR9^(ugq*Ay2j58 z%?#hc#Vw$T2Ee~F*<-y^*pc+&mDeyQQyYk`(Q`v{<2aP)KMfeHk?v{INMiP>m#3|X zjjn;g3-gy&Tcq~p7IGa}h&XiaP{+o~WE3va)zRGCM8`;1-^$o~bq=t30E{35gr~Qs z?{G{Rmh5J4X=1Je(bu&yfl5>$=p@%u$W6=^4v#C5%!Y<}*@1dpAZE2PvmQrvQ9y7- zB{u=sv70NJn~V?ewlg;|(}fu7SX)>R6M88uF!MAt(Oc-9T>i{NvL9FqOigtS4RviT zY=^PkR5rMYWGW4?q+H(QWQw1st*O~dT_cE|ow@xuzP*J75V%GPP>i;<^-j%@d_AG2 z;4v5(>e*S@jVb$@!5rDrLCA(sTmFf zz^dR)U~#yeVD4R7dl&3&Z)aoi(#*`%_LW=pteC?EQz)qBc6IYa;)(esble+nmsj@I zR@PQF4z91WzX^F=-1fHi?yjD`KHv?Ej7v5(siA%zu8wxLcEFMM3{BY@18X5D{r~z9 zfQDvPs^DQTFCY=Iw|8)K@d}7c*DTFW4iA9!gvaaSft7DyRI+rC5gQI%eRmfpM<*9I zFIezf4{BOpXo@4-E~E zj806=eqCIbT@uriVxz*t-UJ7|4h#$g(^W)FDyBxhzVuB3X7A}4$^6pV=KeK0Ju4$6 zF&U0XO-@ZqNqisk?rn5*)VsvgbnLx+Ykh5HX=!PB{Xnf$T;p(gxu}e63=#oPN2H~} z(?FI2A|o>gpI32zeh5Tg2fKUnN1508eRXAJaUsZfE6D@CH6D+Bbc@j-V~kW-d3$*Z zM3^cdUpu|2Dl5rCVStUrMd-z33$YsYGnWg|=u04Fn zt2)?Lp6{+~9v-Wer5O3v%D1ERd&%TsRJ3e%Gd>b8nN=s{tbN^qr)pO=it)1L75TYl zcjvgcuoUPx_t$r;%GEov!kl9Hj~jbzgv@?;s-A0^g{ivBUr6ix&o# zzL-%K`O`nY(Dogyt!(XI`A;zGKG!z2PvCrCSr|eLUz(XfEZ=a+4Gb=y%Ng@F)-^B> z5_EAG)VjK6p_7@ZwGVNskKI&T+rTaKeC6P1Z)*#6^8%QFf#n0EmC5a6w6JEZ^>iU_ zfGc6r0cYFcYi?-bHqWKefcT!u-FLABxjiYxsqaF4p+?W2>e=vs``z5y%;LKkJbz&n z&TRnRHl5kF;|v0CHvy%-t_f%~hLfDFE$m>ky?s*=4gfoSqGuzZ)YX!yE!+`j$n$3g z?p@^CdJ3ysum!UME}Ocw_H#YGZ{a?{_mz#U$6+_SZOjj-W1l_MvmT;+`Bd9P<88Va z1IXNVqVe}onc@KkcQg`YPk|uB z^bH%F1|ZouPaEJ@8$l(_pFez|ihZoj%q(r3L&n*SwLmhQY;R$1WQu50O4fG1~WVrm!D(eULEa4I>}Sy!?bDI&j*jlIcl#qig2(Oq z7VKna>)?~H0Q|RUO$ZoHx(3#BG%%VNyoHR{9(Ig*AexhF%1c>}`8P+!l;qoeNACoo*Xd_A3@*0!D#^tvzg%y~Ck8^-|5 z`rM?T6G(<6FsO~4CwyXHWFd!@S)cpy!|Q$0Uhv+XD7xdr=~|lyq-rn$7W?N0i6`; z>*`=4!`6(-R|uLqLnYsa0MU@UAn8es4B!Fl48Ls_7<4OCZr*e87awW zIrk?AvfbUC4FF}GmE>e0F?eidN@7AnQbJ-}e0)M`X3_2G)$O5TYh!I`W%V&3Pr4^l z-sWYcr6i}MrX<8fM@B`zPfA6ARWA))p;2kh&ehv13qO|Dq`N>Ly)Or-nEWgkuH1NKQ@#;{NlK>oeKL>h|Ha<^bf^DK#39qkaW){m`h)^z=-SBa)Gk zkp^NcbP*{t8K@X;szE|aCh=6Ixhky!sSTR5+k24xUGekkrYaW;)>9N3otd77$jU;b zB&VilXJuq$V{v(T1w@dRT~}@ zNGy?r1t&&gNF-bi5{)mZssgF$I6_`g(IX%R(mn_zBFIh2LZWa)JO-@a7<3L2?BH=2 z457HHrkH?5<48q?BvO81VQwz?AQ5wmff}H^va-C0L?jiIS67wf=M|Kcl@=F*z7>}N zu|P#-Sy5prNP{meEiJ34uBompuY9<^x~l>^0Mi4qf}gk7w~wL&fLUIFHMQdB&Gkb$ zP#08ISC$qOmR3Jhl@ym%JSNx!@xd<%0&o@{!M?Kcipq+z;v#_g6+g-l9$gTiNdWL) zX+<^2$givc@`ayO)sKz_I8XIY;HEqZ7%CsL^+72sud1#nEh(+2EUy6WM@4yM6_8Xs zVvE%exA#EH0rU?*bn){Z$RZvd?r!gE?m@EtuYU+|b#ZwOgdXQ0)#T>t>ip>V{PGOA zIgeQ9AyDieX@KNjp#W-qxk9a09LRu5es50>fKs4;*8mt)tpXZ;Fo=LUUoKYw`MXka zunX#e{i9kR2*c$<`yd|xWCDPEfk%=4{sG7x*xlZh$+x9@3dLh1AWH!J0%&)8 zUoP7Pi3ebFYhQ5yenH!hDC@z2>ahA(b8g2v~D>t=v^< zG_q}(62QG6pxru9?r(#H(gV$*LI#xoGPUe+SH8a~-3Djc-QL*;J$!_5W&3-NIR^@r zT&7ma_oaI(#jgDN6kNCJ@bFl*D+PHskJ$3=!R`UL%zdR6+zF6;b0FP2zu8^e)+jgE zx713I@Or2^*q6%G0HaoDcUM*w+Jp7ILjXh{-Ya*uWm>gr@A2ly_qX@9W%9k9E%p8J z_MY}cu?4ayCmKfRW3O0_qY8juNebEZr=IcSam2rD>^#Aygxp^(3Cw~ zYVWkl)2oZy^Xt-wimR%;>#LhW(#fHs=I6!nVQFFEH4gRjRDD+p{{O_qjrs(<5H}~c z;QvTyFUs@Jw4|KE%ZpMJz*O=Pg#ELeoKi*A!_}cSzpUmOm!5s11y1MIeMZjZd1g}f z^*t)5RHH1!6Hkti=#%~9EKHf=6q}QKhC-Db9HY@!`=uqMs@sBs!n>;?!d(?1ujV`} z1NU%SRY5E*I|q0+E-$P4XB7!uM#5&{P>5^-0bgBKkXLbCSXqtF#+^Ulk+~I^Oaw6x zm7bXma(#aR!~zVfvvj9$Kgr2g%zZnv>X&FI}?3fjz?*B32Auc zEiy-=#p3RjS2@{)GLWT*EUhfW7nW7vaiu54aAe6UKis^NQL+^ z621UQ%q3x}&dPB3q6ZSX2#3Ysi@_9$EhyI%ln@Hf2)J|{A_bLyQk-3SS5aMfSBcC+ zqw#1`E~c^~mqY+5gd_|qD;-BFPDhY5m=w(U6{h4&m6wAn%1uc?+>jEJbCp?fX~e>$ zBoqOkoR)W=pH8|fM5SQQwWUdE1WkDk3Rj84R}~i%QAlLDl8~LJtV%`H9AeYbN~-gT znMGAuL@;ru;~y$;xSu8Yg~g|3*j!R&K_NP;r{h*lazNTf>ud@qpHggsX6EOr2NXtLTuH=4L&RH zvZ^Tm_^g^htUUjT%e~ZAmlxigcp^|E1)oriNHnba7u)RRh98jY?Ko zQgtN12Z@3NRQ|2@OkC#9(&($_Pyh-?y|A?w6wJ1{N{Y$XP%~}?+W)`WSk#e zr)6e6zdqQ@$d-t%lhgN3lH&7rPtsFzC41K?Nx0r}6H~KqF0!-IQZfo|UdF}0xVkWl985`nUa$p7Zv~NC?80b)J%9O@w`opPkWUX7aom#W>i#C zYJ7D3%NKA8#zj-2l2YQrqLOgM#z%!mB&Q`uQ(zdy)0<3&jWZg-0DPZ_2ttvijfsg0 z15_n0F*z|-`tXjAiwuv(6%dn<05fP*bQJQqFH(RIz+*pxf`M`Ph{V(v&y!-K!YJX9 z(wddDUWEmdd`cMN6V$Q7@KN{&{tT5a!9iGpLy!W%5d7xf3kBoxUj)_?5sp8jQ3H#@ z^%olh?>_*?oX{`zkp^RIP=U==9{|^f)r?V{?Lqxygyeh2^E;;iavWasJHLg~_SeML-W` zrzggj1XKKB5H)7UCML&*2EU4CC#NuWZhmYKneWB%q0z;)t;M0Sz3h$gk@2~`-NEj$ zv&_x$;f3w|-La9Gud7RQGqbDtlKF+Jq>b_E^(|D`=WrrCPh1>XNDWRN?VrvH3fUSS zo{#aDjPPfUDUsVVBQyIEVH+b8`N6)4D}t5%gH2&<=u5%kWpGgD_M+%&CqFeV$kX?7 zaw&lHa(qZgatlh|%cb}R@68FKgD8dHGRY+O^!cSI_rUeJLvKe?>}67*XFw?VWd6e4 zHFQ52rka4v!Yqmp$(?eUq{X;d+Xp5E+S|DJ z`?xr}x;Z+MkmYi)ck>VNak8;<_49YJv2k&Cb8>QXbFz1Eb#=6{v3GKIw6?Nyu(xw? zw6?HxaC5b_uy%HHMMl!j-qF_5!q(Z*#?r#V($2}&+|1g^+0M$&&DqAqS`<>2aK zXKCZ;Vryn>W@BM&WM*q=V&>rKW@kqV3G(m^j*ASXB<03?JHz{BY+$HQ1et)KuA-jD8UpljGWkrnXs29X&Gxb%n=w|N4{emy&o}TIr&?A%~UV z(~YcNZB1iKFUiQv%|=f{MdpXU%GQ5i*|!$A5;ex#f^GY28d@3j+9uA5s|(4+JIqAk z(d}EZH6J=1=^wf3`fXhnc5U>=rdlekzWpdL#M5_t$W|FRrMvy_>dje2R83-Evy<^! zZ4IN5T196I6QkUnw+G@h6(9cfw%Pj%i?L7T`h?M%6eHh8dUIn{IlXN}65y9T)Gf3i zDBk&xhgI*U%&UtARjg_m=4O4=2AF~>o4GR?zE^!MTt8!VxxfBJ@!qu&Z!5ASKDLwehPs2sjgxcbIZCw^E z(wtvIZ=wQ5#ToK<6tp(9h1+Y({rDHT@=rZ;<)u4n?$l;;MPYuZ)M_YgG0?}LIly^$9h#qSHUw@{b) z=?A5rCN`tIeAcd~j#IGJTF-mQB!?VNU6Nu%drUE|O;x0Tyc zRZ?1BLE~(Q&TLKe)SukHBcm1A*G8*o>}1wAu=!k04^m5|rDc^({2h`pK}A94mpgL$ zF5=dzisn`%ZP}fik+BxQH2}L}Z-!W?D?*I)vy7U5XrfkDl$BO9 zd6QlSSc&YTM^DsrEib#9>+7gcPS-ZE;K`|_NegJ5GeM@BYRb>#WFM<&8CnDncd#J_ zr`FWeq5ja&*wonA%KN%PHq}Ojf}lYp>KIzsIR_>$^r1)#S%Nfm!{)U2kDri(-Ei%? z*jm~-J3GR}=iwO`Q@A!iDNV!l4-5@W&MmBLW$zy0X&)XFLr(NZK{z6W5+53$nZ14$ z3Gq)MIhh=@ksnQ_ybKD64W$H!garkJ#)XnoqC%om62eoD)8i5&6Dff{LFBkdzo-N- z5~BS5$T4aDe)0Jcaj~g!exWf5@!=G|(2V5FI6ps1YA>=@EG@0xd2&#yGP@s1J8AV@&7NMaLk-?PUK>y(I2!FqzfMDN{ zpuk}NU_Wnv1UFq+^ffC>s z65t!?6Bry6=<62{f+4{{xDN*gh5DmV8yOrF;2RX|kIxVG_3{l34f6934fXR62tWqP z*E=A{$JdAK=NUo?5AX``4hRm2@b*A8CIoBt_7Cy(_3;S}jEDdvFEoVgj|+{?2u||L$_Pyi z3rP%#Bd2^LNBM?cB&NIwiHJ)GjyjDXMw=@DgPfGz&BNy_iO*M77KHJu8L@FkOQM*a#jW|p z?d3Y3(7=?`ocLTsa|tQOJExM=8(zkz-#8_}n%oYW58%CAM`92>k8M473R7)xUAv_Q=in_t=^m2F*K9x~hc*`lve0H7GhJgxxA4x|EIr#ezWE8YK z+v(re0)8Mr$JZm0$Eb!0q7qWuDOVlk$0!azlzFCM&u^mQPEuZ7&*m?Q&qO0kYFP>D zxa`psl9hpmioz3=cb=&kkVaZ-;2?n6p^Y~&zcAegH9&O@lP3suv9)t@v^3IES5i<^ zA?RAf^)}I@FtxhY?vaHx(Gri@(A+*MjtveBAn!~qhdRTrrmm)@u4&|S-Op;Qt!-qo zJG=XbM~1rD>@KJiMyCPOm>iwl4zx2tsu&M)q&OWNsEf97I=XoS z!&3`_nMst>O!YL>po!7YxA1_I4V6{YXxmtAZEY-OGj<<`+sB`UZgF!j-rdqr3u$UK zf|juzdAf_$g5R@RTU%Ku%`x$|_O8C6iG|helZiPhAJ`WBws{cX)Ck}#z! z#cHLJBaU89J3BZB3s9H=545_bb%<=KOHh6)bMKd59>@_Tbf5v>fx^HeTN)b} z+oX0#ua{4y^lo>3btPz3;Jo_ffr542$2Whzg&RJ_$_NB22a!~i_U`=``iu!4yYkQE zAIsdkd;hT!KFBo4LH~t1Byd_qM2_!-1fzh3#a)UxswZcMk;lzafHu<6u9 zSJS}C)!9Tt>e|0Yq`mp|O?iTbGSOy;*FW7|gD;MlZb zpdRgQiS@Si@=Xe=socr0sC^y}|5U4j+0tZKr* zVu0T0t_sE9-AC@F|M{I#s_w=ZL zf5Vc$tJe6g*!Yo5${&CGb5KRQ_d{`$sd@^vgsJ&hPEqOp&#JV)V@KRktA0~Lyf4H3 z^>0OP3dZ#Jr3*wtZ%J{78oWws4}Xwr`N!XzpWRUe;YjD6%;@ib{}8HRK`Sct*C29> zOF9Wpm5CZMKipe*{lBZ!?#jXQV)@`v(3`&(Evgt%Ka>P&XbqGUvvm|yv^5_8{G)Nv z|1Kgvc-Zl_DCFS-m-l}!5~=G}78Qr8=nNGXvrKS3YdpDkTej-AU&V3{^gq61JbR#C z`TL)ZX1cAPzOaefVrnTPP)}P&UH-wHpUsN?QACh^+WEfJK~^^BqC%f|T&yTz*0kx_l-kgU^Z)9xRUibU32d%&V zTK+^%tLhzFNmjMu-G}n+W+-4>XD3(j;<*T}b|G*!@Q0Q#)%b#svLmmF~Se0)!7vyu2vX{JNtzUJ%yeCwfk*uA8getN_a*@b}K>Ex3s8zi`mekZ`#CU4z^V?8fzL`gW}l3@z6HOJ^1S# z+J{mT<4+YNW3!e32pT?@k2lbnyG9aDOb9lov8I~AJ6L3LddJPBvfUqlV1KHj7=0{; zJ(ZIntI<+cHPu+!Txj9mWo@L4Y`O$ z?mVxoa&l;ZA8IhmoWeAxvZt-7m%H5FG}6@@VyvtHQi)o1U!YU@r$DpTK9{E|9G-ZT z_N9T(Xx}oo;fzQ2Hc*?|8kiIF5P$MJxsiraH{K7=>hnUQDn1AuYdMzkiUYfL{Hjk> zZgqXUxjFC1e7J$m;LsR?g>l||YDedCh_NQltlR2s76JS(pD*UBcqa0yvsPxk6rq$reU)6SaA2cx)>u0xW|9cO&&DGCx09wKj;MeP|I^RQV8FW*(h;Y<29qQ_7f|QZd+D&mGN4eOzJHj?9ckjuupItaT=N!_4Ul2DM6sM1;RVnnPs(Yh#FQSLy3K~Dth}Mvpv`)VPGUE z^y}P;WVoY?KitV`hk%UL4v`S2e?$;yt_i8}cfGTQ=}mim1>&-5S|g{gyLZ{k#ogCG zIHvGb>eoJ$(4|kyCWx>Hg+3PA(6K zdWTaT4M8-(yPm45Y1@Uesh}88Ynj-26YU;C3R2VzzQR1|@FNcRiMa zBwb0vl*Fc2V`snu3$=G=UXZtEN-Q}y)ZH&`0q^SWK&XZotY?~R4h{I@C(xlQ5KOFl zYpLL7z~Rd2Xl1X5yOYk7gKuI2gG1Jbhj~36P=+>j34xHqm(Y7#fx*@(LQdc74tC zY8s5z%`Hv!?4hy!uk$I_H{{5WuWzuWyh$D9(vlnJQQv?Ca!mT3T8;{5VrJxkm~LvI^Sfj!xrH=JzfQ`x#n< z53xI1YH$`bH*x33rsn4*Q@#Cz!*es~4%jf+2hSWeh_h{+!A^SfQm(zTv%dLKe@9yz z6Sncq&7H7iUO!~XY;NDc>`9Cl3Bj2vaOb(&1_KTKu0sv|-uC`;Db{hF-Ggf*{e|A4 zkw=N~gNRmHI67T$-A=3v)+v52)}SgYD<~-wUBYkrS{coqeVmSQcXN0Cz-UiPO>-}Y zH56cPWuW>DJZ8r}M0hPM7@DA;8|?0AWi+vdDX!KqA0m*Dncjw3}pb{XMgW2&o;;8od>+1T4gYnY(SO}Lubge{IwwKul(FR$*oTiBCh6IKSh zy856}9$FO6POq)c4Dsid1Q+1}KA3w`QV-WeXQFQu(EPkz-J>&)_QZ2D;*5jEnbobC zk;z3-K{UzQ%t%j1&)9o#0J>YAIDBzBHTaUx;}3Llhw}q`J)Im}gOXleqjD{}I@?^` zm>=YJc5%7=a|emRKAt}Q0X`&WM>{hkeO+zy{`R2>c4OP@{LoCCqjS>4($~qJzEgKk zKW7U|-_+Ebh<~6-pT>mo6D24g6tPiu_Qw6k(qya@hz_)H|x6)qcA@oPOPiT zb17^B$cyuWOw>Q)W2KUj>|CiFBQql>@72W#S~HwM{E15R%XE}s3SXfvOANX^J3T+%2mko; z)vIrXd3m{+=_vi=WoKkcwIcc7uFsF8WX1iH>+6f-)0>;K{eufBVFRL&yz6sNHI60X z{hOSe!i$r$vjeHyOzJl~zq!UvySe!$6_LD>5fJk?bjwFX*Tpvq^9*V^WAb0HV?wy}QWpZ+g?KwIHIYhD#wBq6M zp;#n2K9h*{PY(A*@Yw+eDVE@W5OiQ$AiTveblMQ@LFsTP-aFho#R1yIaX2`@k4IOB zM-pI4ZZ05nxXeGkxjMT#cy%Q?IJr1Iy8L!}g?)Q`aF(AVIlQ{QIl3r3yE;C*#!dqN zt`s}Lk=JR_(c#{1`pMqm>GcuFk-KNRq8*7u zczS#++LvzF-rk{jTP!+0S(WUIB^x^hN1~k#@x?9_E~3qC(II+g>b2W56`XKx#dmz}+xjUCDQhUj2-ZDm7(Y|<{QkytNY+FBDq^s^%o z?rf}yp&b&4#4GC%`~cdyxwR@5tq68Dw)Rnv+<*b|t6<}Fe`!Imzb#mW6%$s^Q}O2Z z!OG6s&N-w)Iq~yfH^m8%(<~&sSlwGp%39bJ=kBhZoTOi`31cMdqSK7SYB4=! zAuT2A#g*iFQsUWOYWnidRceZO^AN2-PPb10S-wt)69~dyCcn&$+uKYC6AKQKQ!e*5 z63EGUn>P@GQ6!7oH!(3gtItysGfyN5;gX&Bu+tZb2~lwS6NB-K8Tx7Z;MVxuKq( zo({zLdd9Y7q-k238ylKn5k!Q7=YKPk7lsH*?%~~^f4uW_nZ{t2y?s;CmhNN(sinb1 zV>t*_A4}@SgZzA)%+0LKAw9kO%}TWQ7b-hQGswjG)o*?300NVM0KfB4g_ zyDBz%#?;?`D=I6e#i%{Tq|(76@ z{rtIvM?e^-NVIP&hNSrOms$sX3p*2=X%3%csQl=sTX*F3G>prB{jKOjQMsFE!z*s@Vw{CCKK~baBEj|ZuYbIM_rA(P@yQdpCxpqWPw(GCsXQ2C zsikRtIy}%n>7Xie=hhuLRdxHaKmK_4_HB6#RK9X@s*!bNAKrl{$n5ts&@rbRZSse_ zVcrHjLtf3K`Q0CX{`ux(v#|;$E(-b+v|_24ysBx`(b&Yw(R*+5+Eneyy&vz&$g3FF zqQ?Wo()A=m>E0p+v#UP7N4})GWeNIM6Eiyx2O|w7fYTo{6w5t^aCp1zT(wM)>|9NvV4 zBQkhc*qqL;zQN(?g%u&57`uQ3?@DfVX46A_T^uYN-Hi>5EZv+*N#gPTJ~kWvwKnNB zgNaH9r>k#ZY)l|t+8!Ir5Um~Vg?PD`8|&+!Q40a~M3-395x-9v;85x9Xltr(Yyi6n znbE#s{?NL&y@jr-JaX1@3Ti~XS!#7Hy2pcVc3{$puHO_*&`P}F)GwXSo(5tiE9 zx9&e7`geW$^TVg&8uBw)ct|zo%D=q-<28ia?e^+=t{2@6#UCr{dyVDp{B-NqLlt)p z_06B3%4#Nvk7S=dQ4OywDthy}sG9F-uBL1l!mTTXF_ku;EBnjOw{P83k6=;Xzs3bT zttBU;sH7HNT~ze`O%Z*@%0yjBH>itV`o5^Rvc>knT4 zQ8r-$QS|P8WtX>^rZUlSv!&w0`%lG{16q$C{_^APpP!k}))c=ls;0F%sXtRzQ!#6U zGfsMnZ#d|w={YB`fsOc5T){R6Z|}~}Kg(!4wd0j?Dm3RrBqlY2I~uCd1E{vCC)3K( zD|D_^ij1nP9kkbkndHHJIW?kTGN`eD9X54!cU?L89!}4V_Vf%5^Ex^^0HLFQ@BGln zUa?2X005{5kK~kzCb6y6Wq{z7R8eVlt(?9UZ%2D4M+f(-(Jp2!jYg|&>JBnedm;l2 zpRByHru~St#86UBrPnvHI{PPcecio0-F%aGCVE(nbx0<+^rl;)*$l?ZJXIo?B(%|` zCc^5Px+Ye8@9<8byO)=%TX6j8R97pbp{}-pH5hMBkd8-l8Wo~hHcL7llgRpJocIHR zFn>QEclW@k43rF-8|&&CTKi6|i3+e5Kax{W(J%?))S?7iPW_(6<_@d}L0RtN8W@>7 z1q1_}*6>R2*&t=}@9{z02I=@}kgJ%i_WrLy?(VM6Zh@4W2`;t=^^4XX5$fddMq_)F z)eT4k44M?-1g}{$_ReCERJiWqADjR0?QiYbbkIh_8X4dWRn+y}hMF+G6mTkhKwI~8 zn7cd4*)=HnWP%IOWevTqxqZsr06q-7;i)nv&2ugE%2HH^X!M2_R>yFPyDPk2KB?mA zZe~MW4ZXIxbN2gqyy1zGs+R3M6XVf`3MvC^5I+&7UPnjI_{%v^FKeY9iuM_jzN&P4 z9!baB%rpZK`lSrx>zV+uOm=gI+Jh99A?RzBt{`g}pH}bbdjIYJ^wDexvYJK0IGV)4l2EOezRi&T5lvl$5+SJ-H73k^;N3Bm( z?j#2%98RCsfp{~*Q>^}>tb#JZbe~D9DE?dwXc5My23O>u zEj_#srK{KX;o#eT`M!E=4tppDyAv?#gyYGMCY)_G4V)!60~P7f!nak^cHq@jl@)&} z!?_G80lRn0-`mIC)#v5%SZ8xxZ7s|xTMoML8p;4Esidyw)>(_;#UNQpk1o6aA}9o& z+_260!8V3;cndGmg7^%BAK~dmFivFBur`EKx;xfR)RsXd1h7 zX}DPySI`^knn1MVr+SCykwe$}yEzOxy|y03Kz()GD<8u9Lol+E$GJYCGrc|RIuI(G+BVEI0N8wZ|B(_=*S;AHwl84hG}m|TZTEM6U12lZ`&sp@ z4&-q97lJh8?%kGAQ6gAUs{`1j)GhPj9&MdI28UNw($-hkEFyI`j&xT8Pf%ayqot0) zx6~62o>M>2bvmmpG%UD_`s^=_$oz24_)L4Z78-+HQQA4gS+LR9dG_GJPj}iHEx6@n zW=i#qsup$i7um(-&b`&+hHedAP7P(vZPco6r}0q>3qW`8|MXK=g;`$-=+;ei6^AO; z3BSC`w!8A!Y__S#x2dY9hgvHjO^i7?nJGTFd+Yuenknr|vxa)*fI>&*##VDli&;I* z+>YJ4c~x7p#i4SXUHe9?j5W{@?x&K^CUqYeL`7=tv!IeuH+C`Im|IRV>S-DZXs%ii z)wDX=_H^s15oCY-i_Yr++Ye5hQPN;%!mac(uWB@9S2eQQ zXf}}lx151*Xv-smg$S?xP5Lx&JhPb;2xOyZ}0uyo)BX5L~qn$;ucyqHvVAw{w8p%h`2i#*3u1o14P3UCAM4Zc@X< z<~kf2I}&6o@(V65Z_ZDya?)UPNzJ~xIzKwvm+XmQagcgCM55iJlk@9u*B7V9dy?bR z)0-R^NY0P}IFP{kBifaq^8rjAQuD_Frrn2!`-iZRyea^y^Gw>P^7!~vno>AJN&$e* z)3ftS5J9Dg&g*L=AYK)I`%e7)PT_=f6}b)ARRHCD^$OG*IANqZSNwt3|EX%lZwm^b zZ-vVRHknK*0q5n5=l?|Cq<)vnzLYM|%mR-C-^@SdvHxV}NU1qk@UnEB|6C+XaobDI6Q~LTty#&*OKHj#6@8-QRgxGQ zza*egc7?v)8--!f@kdEvi%E$WvH3y1iCN@$ksu);J}o*%oaIM86(s~xGAO}Wsg(Si zki^v&ez800UNKwALF9}4kjxBn>Zxd#Oo_O;3{QQToEUX+nj^}Lprpp0re~a!6R)#Y zMY~af}7yq`cHja`xWy?af^PHIT@=&Pa~E+SwIe=EudQCETROKhHtmk%P>HthoG} zsEmT^)XYQiN%pBYPm-UUlajgrEvry`nxA>J6L+zDQ;?eeB5z-Klb$BtKtq(>qfDfl z3XgBn4!6Yz1t;5==ezrP7rQx`7h-Yl)s8SbbA99Z)xmi({8XaUtF7E?@xft6;oeF1 z`96|M+dG$Mg(t9yrRTw=vwQgMN^*Qtuy=|q*5Ru|k@)!L8p(F^W3cKS0~@{GjdJ}ukpQJogZ9WpCRw}?fP5ctK8Fzlk1za!|S8`ldFq!<%FJ15&K);0r-(G$HZE^aK+TMH1!00B#HF|TI#o0NlKzPA>};uEIC>jnUNEfC$+0YMu!$a9awNpd69jRk(|5! z>hMOAk{lG~?LqSP3J6LS9ts3=g3Xld2r{Z(A>{OpsS!-+`}vYcClhUL3wL*y1x_o6 z+u0XwV{Kt!Wq;J))6vo0{q=iN-z{NuVxVtmaLvoh(HgG`i`aiM_$!I9}=!Tj99+EJ?1nHhhxiH4Gk>qkiYQ^J!jw*`xWbxAq^HR17@ zl8vQB!G8mFY6#x9{dLS1D3OrGSNQ^)hMFxFRSMPRr<5Y)%T;B6UTQNV$(i`wssBK}<>Tk-COR!fQ9HK|2@()f{JO!tdu_9z0nu z{ZvY$(|S$R)r>qmhbqzQf<~|F5l0U|Y{usFg4Q4lRnUDIGX{D>P|K=0&YD_gmQH-2 z>!mZXTF%rE5JNB{+o|PXAyiZ|dWZVQUio=> zyI2?^)$!;dILrq8#u{pQaVd=#=I80+n#HDr&4aPc-Ti|Q^qZTRni%P-E6B-!lA>WZ z#Hz1C&x(4^&_Y3+cz_B1EO<2am|l-%k!;OPj0_EQ2})0qf|O8KtggECI4(t|*+e`J2^{CQe(OsIdLH~j73t7vNxG_>?gOwCdWgWJU$ z9GSwky|OYUIqrGCK_HZyfj0@Q?p9~QDJ^oN@N&0(8tRi2htXc zovnj2$rmm|z+uKmhd~D#9D=JJ3(WU3bIaR11$i0q$?=io;2=MAT5)x9uy=HFbawRw z?Ftl>`8niDq21&U4i3VTBW0loR=~{9191y>P%?5c-pJm#I7z#?xRLyVC@F_)f`!?M z(GjVCXAqw_IypYSv5X9AW=_(JX#i9TLgTEGo_bO`s> z;jw|?$+4xW8R6;I)o)?Duz^9~?G@mOJdUF?@=y+r-oY{X;w5ZS53d*E#KE3n{_y1J z>f-cXw&=x1WKevpzmKyQ3a{oyW@aYl4(>7Ern_FV2f<+*9UEF*SxUW* z@%41HHZ#;vQ6Z>5Q`NWG8fH_GeW4+91p|_BQ`(k?BW2hDHaJ$`VWA7&#{HjVp2<8m zn8G#s_D^Ydw$uta$jZ9+t}_x{G3mOk^R_ka59-KR1{>WA0A{r20(_DQ0Q+~cQ4gLI_Z-Bn+56gw)KR)UtQ;u30`#p8QF{pGfTf(G^7>(_7Jl)0J`&|aD-t}X#} zu#)WvR|L^!uIc-F&81_1QhKIU4hYWMx7AWmuM$yb0076LVk+NUUtLAl6ZVRd@>-s* z47TB}nu2-p+t+X2y=yQvSCdoIF=bVJf!w-AVxmh>H+hK+Q+YLur1Su7;}sND8_=No z-P;n}ou4Y|8ZJ{yKYS>wZnuQ;Ovk;CK|>4mFambNEqN8Ceke|h-hMbXvsPBrHh57B z(oI=4)7em$sO!LQs-;nz0@cwb?6#tc3hy&&B=3rZCZ=ktM8m-v^kXWgvfT}}vd3D}AE2i5Hu}sA24AKfZta z2L(;SLDic-ic3B;TN6}`4RuDUia&v%)@`Y$sbgTlZ)|L6o-~nv@Z%3p^t1x&-~aZh zvZVG(LqXr(B!&LD2+unfNehS8`jS;kXJ#2IJ-mJEsi|c+`~7bqTQ~P>%M<2ff`+(ET4OvB80kAoCfhU2x3AxqbNQ><$|{C~jnuM_A1gSXR>qnHEmC`P z1B2^FkoobpEHQF%w+y?KJ{M}Lr>1JSKr8+XPGg&oA%UQ69Ma!RXUrHY+`oPAsYTyn z6Pn_FU<7OH=xAsfOjmsZ%%^#RVx+D{u%6;DTD!B+p8l?^?o8ZlL-C()OS|;7an@^@ z4OW95MDH0)FxONjn15w5*#m(@JT2ADO)Xq_<)1!P&pDb}xjWdqEYwv%Bgh$D^OTZd z5(l6-3ewe3*F!}R0+i$C1}3+USwn9gz83P>Ju^{pQ)p$oKHl4gRfd2_EcK4bp_924 zDI~w0D&^oeGMU}$PR52NZf6tSY{F92vWl)xS4$ldjZgy?nCt248=IOU2$6jxt4?V_R+Cx|4r!l}iXy?Lzom{Y z?Fw8wWT&gCZD?v{h$Q+WS+xLkJ*uIWefnI%HNiN`0VFf2=-W`kbI>Ic^-au-G~^#X zlp*di=neHWP@T&8I=HZ%yIUFc)!2->8B?N$mXV36K0zKSVJ%Tp9iy%qWZkcEiwGE97HPz70=!w}-Lp_6za>@rcGTT9Q- z$UqC@9zHf3sK>an_wR~mz61poJ^w*Y19)_wDq4dKus&T~9dzz{D5L1!QQH89^t*Q- z8tl{*)Qr#iT5E9meyZfSB3jYb)7R5bhNnW&olUEuSABl_wy4oe@v*Z0VJ8E31WAR&4$-dfg94tGG?HblsTt|ulwNMLq!rB zHJH**P@Gd;)#M(lnhY^2O905KXo)t2H-c!O2PXf6hYF_iwbd10q(H;+PFt0y3Th|? z6-zVKO~d{Osns>L(3Ai7`*%>I6+gyO<%iG^A@lY93*06T5fvG}zV7(!$ySX?{y%U8(z0L)+NKEpnlUSx=)% z7isB$$(>H}aI~?qw6s7pYHXmVqot*#V_@b)77U_-Dy`(u>4=$|+Iq%Ckv^`DcGfV2 zBCCtY6(_ENv4tyTW32x>-?E{;uD-FE)!sL{mf#1IDX4bR;Kdq)(caA5%E>!+XNKR` z2`MDldx-qH24{9-0zBMYU0hsA?p{8=zCIpq&ak7}JG%OWT?4b)4`En$PcKXvW3x+} z`-Q1-u(?a!>+rjWlS6{w%>qZr%P%-Q`BEsDmtx+qpujsjzqGo&cbSy}Iu0!HAwdD~ zf_Zs*`S=G@;-%by`I*V_G1!tPr{@-zw+JN@6fmXI@Nsw&c%u!`Rf3o<}<+vP-tO7TNc^m+- zs5Q+1lXiEscMr}9R#t92Orb-51Ve|49k;6ZBOXCD>>*EUUBJ*V>G}Angx((GU}1u* z4pQQW59L&h#+p#50rru`nucveP1Cv^Xt2*^HJn^0Gd)e%8s#3MM5*cw6!DiY(7rO* ziKaw#ZJQnpLr1^19S2i=ZK4JN;nu?ka?0+^^3R_>my}o5v4U{_*K}qfeELvY+Z|w` zucd)S9k-)L@*0bEC=V6GZ_?1=tgEK32Up2Qkd7M%-Au4HI3S6j2tSf1uGN-(K|7j? zs`_>pjMGnQz_>3kZj#KT`Vk#HJtDraoSHMELOQOpsy zE0Wh}O0THjLj~2e6s&3HBAlNDi|(rLrWE#?lL@v@Q{UJ`3k2oI>h8F}(J`k|`oY*Q z208V3P?l6R^#xn$K~|t+U;;>&tgNyTSL&N7tEi;42#pDKCmCbZK|iE{_L@wlFoW#)%S5#^<9qI zYU+luAiWirQJZ-&4o2G8LLCEbRXoI0%{u|*#y6>;HZGc?zHN?u`njaCv17{%STPNv zmVu#`@>9%W16i~R6p`V@Wx`OPrtOGN#tK^o!yIuQYvQO#C%h1&j9`8a0C;(IZJ)C) zpoEFcYCP*0+?7B_b7MVST>}G6)n`wYwF0<}Xf*=_DYaQ(rHvXHzY$>F2F`ebw}Z8* zF=(7dL=^=k0+7$F<_20dq|lvl<_5;rLN031&FsE~6nA?Ib9ig5@l_O5je^EHxolK& z%d48^Z1nYweP`fWgHf%2^wQ4>;D2jSb5&Ic`c6rs{T;0hcz8Dtgj$)IIfjpM;eKoD z=oyoQdb+tfpqG`ZvYMu;gM0LD7lVq^h8OJYZi`c6Qw(V-w?xFa2CyUEN(R4Z+G)H3{Yo_Ovme zW^P+_0_;>r-=d(4fr4T8kl?w8gSDMo?9xyVG{Cb30q&p!_=YC#e+6CiBs$Q?*8|)( z-{9n|{J3C04-Zej08m3B($9pV)2v*OJjVb+<*?g(M}=fp3u!w(8AYes%E~5AYi$LU zZTL*?scv9D|J4k;^2?X%X+t%2U2_Y4O}*g8vSKPX&^2@?+Dt=DCxG41+&w?r-#zJK zfDV20-Q4cZ76eWc{s2?M5vM^Qny)oCxA(H^>e?NN>O@myJZuJOrR5Bfjg6Bfj=IGl zn)J4`u{wli8U#IO@$?w>8>2JI(!|_}`k&av%p;2XMxLV;|U`D zJ#aGE$4|f+$L014d>kD};X5TBfxUA-8&YruoyO)zaX{oclZ%(`w?_p!%`eI}NGxEWqr5EAiv4Q@f{+=%0 z`0V`1z~I+OyMLI2$zUmi*%P3z zs%056iMvioIgQ>l<7#H@?C5dU%UvQFgMazUU3sE)aBI=q_g^Z@>fDKHdWQNAU6p`< zekq|3rbr(eCJqo>JDb5p^6)Vv{~j=96vN$$U>J5GrwdLU?g}6~HuKUvko7f3%ET1+ z0c^%IHGQjb)aucJ487N`L)}Peye71vs;r{7aM!)f4UNrh>}~8U^oZ)pPvig%Hgf4i zG>tn4y|rg#`DNh+I|XVY`bBXJ*g9)OF^zIM_etDvIRd;zP2quyZg+ZvqEv&rEMex1?1c@2S4=KUzCbA0EnEu?Nm)Om zP=wda5WoE8&Dg*LNlfz9QS^y0D=s8avMNd=-(0S|ia6OzEsO#Z zFh4UqGB!3WLAaNh5_g#x`U0$?u!O|a?b&_N=8K%HwbfTCQE{0sj`HIZze#p7qQhgi zx8o6SMaE`I!UB_pnep2j(IF8p6QdGOV-iFg8&~l+K)>HyBwy?cC86X5D8JJ(Q(l~G z0aTcr7e~p+J4;U%<)vgv&R@iAi4qF-#j9tc!|VMU$xduSZn7lf`aFBTAbl@A{Y6A> z>di_1Md5Yf$eP7-+q3D{CjK>yq5drL~I-$2U716=Q+NOBD;CO3w z^YD0e_2||5#@h1c>AqloeIH!E^Ubf~y`7aU3B2qZ+bf%kJA2FPqKz{V=zFUhTY^=A zU`4QTc>>1ey7)vezXpQkvS4iuc*@P)jiqJ5HeOkQM`K;EwIN(t+7+(Ni%ys3=GOMN z4>wl7F78Ue^@DePyFelki$&sf;o`jL>SXyVK$tt5tMhYP$GKv`{>I`?_RjPYD1~d= zJIf-81gtH92ET3ymY{wHu5H2oV3ZYqsI(uzldn@luu(Py!vcJ84 zw6-KUl?b*VQQnkXV)dst>l;h!U!?--?cLL3R2%kDO2+pVX6&zQO1^EcV~du~p6{** zB?l5wp=fhyNxXe@dL&v{y7+drwkcee+yG9vyL*lL^4iW3q|w=_Ir%4fd%HIv8Lk{1 zimxu9AAb(_#BRp9@H$;A+P&NtUPx9}^U)1p{c1Nf?)ow>^>Di|S-k#rXJ@4_@w(vr zCO79M41rKBfjoL?V9gnlAE&wH6#m=%{>aCns_&Pm7@!%}g2PKhk|7Mq$Z zJ_Sl(dpS9GH6;ZEznJjs1kpjt*PZLrI57NUC}GdDGA?m=^RBlFVp0=xQ{upNT#{_A zkCjYD+p0f6$V+9O z8mOyi`HUe}s;K2#szJ+pTi!6Wm-_nG_mv$Rfyxi%l&!}3R3L7tgHu*&Dr%1G7IaA} zsTm8@efsd1yLTU&vydA=o49==Ep25v6$dtrR$26sI&5i*WR3AS6d`Ce-JE4cMAxOe zzuY&%b|MQ`(r0UEpz`RkQ6B=OcW*v1T#a><9zW9@#|$0ZX>_>Me){kp-XJez(5RpP zd`rzWG1h(hNYNgRk-xlsS2k>EqTch&4AHYwUf%#(3ZGSWCrQ!-8*;gDp(A+P(S?s`U7pj%@_~6$NFQe zs-m}VifBDyCWL45>b6kym3_jLV%7#M=DT-(zNc(H!=e`9Ef7ze;Vp97(S7vK@7}zx zXySVsC_hutkL+%tLMcY)k-nFhetMv6MCqo!`yIN|wh?m$nFq4!W(m#Z(uZGF^MEtD z*l3vf&9ow5uVxH6YCeNv{jQvvNf^8I&)080(^`i06=X2kG1+e^F9PAZs-8>IQCHV8 zb=vEwr&iateYMblIP8}PPt}dwy2{@E`L4LSwi_~1IYokjXE#KoAB)Qo>RRY(>zi1R z1e_+ch!NR9M)^S6+EUlL51UeiA#EfLq(BM!_QN%3IRlGgT@TsL!qnKrhCJEV+1Weq zqKC-+A*7cCBkva2*wNsT*5$0FP9SKS_;%HOr{PpL_3rxEo0(WR1@F!b4a@~$J}9kS z$)jZC(Soc7rbD#OU^62FT_cBWkcUy~qN3;Jkqw&^^-H#2|_;kVv=lh9*`4l_8UT{F82?LSnWkZa%>t_O>q0R`yP=PPQ&V!Ei|m zPBK?9xt`(k2F9j`r)Fj+mzNe6M5o(dkK+?_;sYa7{!e>n!qr5U#qlrFeP+5_TU3x% zR6vkTMN|ZBZCg~Jhdm)lC1kHuk}uNdOrPU)+ec*F8j;5UKQ_ERqp-$?~ON|t#7_qU$1VufcP^Fa%EdH=dsAy6*1v*8)>B> zv9>~L9dkmf^BqxVaVi^>KB3Lyv@4nnUS8I!mDQ`fOmlOXQ!?|8fW%dfj7fRK*lZSy(79H{4rZBGq+AJAczHRfNV^$jVqK6K zTW|fX_mty|gh-QwqxM z?J|cL(d_r}sf3@`#(W_mVc;RH5_yKPnq!F=&5{BdMnR)xj70VNWQ5WQfgr{DW09m7 zOdG;FLn)$tg^n~9Jo5nzCMM3ZcUx9hC_P?BSPF_8_#tihz+$a&2s z7O9>iK04WDRT&^Y(rLo1Im&P*IT;`bN}skfK8>7+izbdF%t@~|4d^D_Ws4H6lq;!D zrA#_~SSM5RW|dvA>c~LA)|F1Bj4HiKmEr;2Ux^rEqWnd@v=o9n_S-MBd8jnNK8li8-!K*446toYncJP!q(QcUp1XmAa$5iTo5 zn8|i^aNTHITjf-Unyav_uXYIYD_u>COC9GOD;*t6^F*^_u0ggcw6?_7+qij^DQrr$ zx4C6a0g7Ra&QPa^R&>e@nl_8yp7`Kl=}1i8?3Xph5v_CSojba)ZX!7IKU7woitr~{ zpD$(d(DIZ!NGkmC)+pDmFwD5KXza*ujb09F`hvmgJFRj%i6zxzD zU8p^Iq(t)f<`!c@$3G{jgc=-L~UEcES6L zk9+VazJ6=)sk^kY^t*#41t%LXO+I@xG<2`O9}F-Uaws|;J)K-mHddA%%*)=MmA&QL z-8=U=T8FP*@BR%&{~=e+$)bHF1qCOMfB)wF!~>|DhoH0=hQoYdVCx$X_#?=90n@_4qaCBe_ix|r?HzhL6<1fE zY^x>5riS|ld+$Ad?5?iZz4P0@eZA$YtiAcMKW<%ty5rZI15*`y5xpmScR|&MC-9{p z3(Mf>a?O#0xFZ+Wy5A4M_>4>b$M9Nx*>^d}>+-j+vUU{Gk6}#u=Py^f5rS^lwr{s) z?JB61g6P8@c6-QirWE(|J^7{e3lFd}!nR!>cr6^-m%C#te4tr5dzv5j0*C(Px9j&p z2XeP>+qNUGM1jC)NPB&2Bz6429;kN?RIj}lfNH1z=@fnO?0MDPm}H?Do~h2?`S&mI zK>Ma>sQ34ue}=zt5FV8;zudY#Zxy}f^5yRP5Lo17=j=T)J8riO)+<$!nL3eWSxO0y}YBL(D zF3;byukf&PYT(Z88@K!Jk4}s`SEoMAyw#s9gaP!69pCK~uJynY+2gG(`Q~f5V7+&) zUj6;oTaNmoo!fH?D#CZK!Du_6IRS6qftrSee*6+0ep_8qQdCe{|6y{duOCmmwyI;b zH5VPv2m1&5hTknUojp-eRdJwDN^8As|NgSUZe;wvHU@WP*3QDva9{V88$(cd<>VF~ z{2tbo8#nvu%Ce&UWyH+b{hN|Bq_Lu`@>tzufA8JfedCJL6@~f56;+q!Ctggx37)Qm z6XDo--82$zJsO*w^)Uvm$GbZ9>hc$j?33IHl@Eb1_w>DJIbO84_*DJNdmxB=hrB1M%8M)N@gyG{dKEb*`N8swPeKho z(Eo6nX*^zDcI3pR1&m3=S(qd0st+GRT)WyjRnwVTiB~DBX>>qp0_)gh{n2uSs=PG& z{MoaY$!er{*s?X7{v#7k@bR0UaAu}}@AaX)yn=lNjYD_u-2C^Q7j0)N zi;E7)r=P*BHZacoSOv9Ut$zOb(EWku^NCYs#U)iW7o6h{q3wF%kkuZ$AiL>LV40j$K@T`~2|}NNMV7j#gIJ z`6dx6;la~MXT14`^OxGZj&~Dp9Fe-3EwPRtmu6xJZ>~eCQE3ZlwHXR@ zBs>5aDFKucLYW~bC6F4sS~d=!VK6hOrVXSf$W8E@AU(l)O0*}MDV+j}bT*qMvwCA2 z;lJxQ&I{)%m5p68`+y?Fsb#)E3UCaN)GmRh(&kXBgD_{=WTwOg|N8Rk{M7u6W6AAu zBDI~1w(xT+)8jAaRvptTbTTC}v(r=W-b~H9gNU`*u52g#f+Ol)w~$tyP1&J^g_%eZ zYBnKuCIYOgD`p|IL0*p^{E%>a#RTc)krg2lgUmc^h_VJ#A{az7eXo5$eR?3w7CoM*pu40y`__qYn8^BMjaIh zjZRRgtTBZ~v{+@zxTw$)MjK%hnso_1nIJ9Aa^6I-Nxw=NO17(Pq}UPicNs-8-bk^s z4wZjhE{0O+R?*wilB5_z+^K4d8B;2`*QPaxJ+vyBAmy!Anu@MRIXUlDtMvws2rv0U zoLMll(N4l*;Y0q_xG0!KN&_TVXN>!TY*M49$aq|gQYO1C5hZnMYciGyMo3aap&O7U zE)`-}5u~l!fY)v#>-T{EOeL%&(W(WXOVMFd+Jtl?B4xBC{2nH5)9BDU;X>3WR-AdVow1qTb2%v4qkpXy_)Els+J<0N-h?HLz%4o8QB4}ulBuzSOXSd=s z&?P0j6o-+zxoD%%XOVy-EdkQ#!%}9DjOqaD2h=cBq+ycj+Zn<+Qw10OX#ipE21oO|AP@7)#)5+EKR zSRA)lDTU$`*Ce>ho#{Sjj5|qIvZl|uX4d*VpXZsp_xJHJ{m*~=Bf|ZIcY02t_Va)I z;~)R`KmON0+#vt>_5NS~_85GWGQA>{R(aIRS^`IQj4JcWIFA;^O=igtK$->q1;oT1t9WCKgv+ zou6NjlZVU3V)jHUN89(+xoJu1X-Vk9JK5g$zFc;2dUIV?S(H~?T~Se7@$j*_yt1%3 z7n7e;ge%C$<`v`=U~vT*d#fcSYfHyPmFU#G+#GCfZr;^lg^(***{?`?8@06}o0*hO zOC@vJ$yxE?FJO393m%S}b&va2g)iUQfk z^!$ok>{Df>eCyz{y5cSkQ(SmkezAF0a}VY%6_8sAO>LwuNwBHLn`i$=%fyh| z+SbrcAk7TVIXK$8Ce6^=+Z$WRw1IaBh|2T-tp?MPHjx?#?Ih`_$QtSB82hu6L~3dw zQ#o1IdTJnpDP$UCXl|p52NtZH9UQ_}x+$dQRtlXLXRfRI`~QI$>vXnI8(PWT!`uLS zUn|#&xlU?3fkN&Qe}HPK{GaDg4cBHyGoDD}_RcxHwYQB=;M2$iGKDsHY@@68?4L%O zrmYN8J(1GgGw6;J8;rN&|K>zYXIWaiwa7sA6epqJX#+}h5V@GwyO=S%HZ?wx!122u-w%AP>M z9qk+jyGV^K1TrVcNd2F$v|f4(f*YF14PZ_LK~}bKn<)+%PiQ6z-W#btQ&UmN_Ft=~ z5NcZq#PL8oI23s>NNjCwqzSzs&s9}lSUOm@(#SRV27IT;6#<32UiFjPTgbiXMsL(q zUz$Qq=^T0uzP6UqlVV{GvxyXUkx5kcdjmDy*Xrh4PW_=R^^Jcukm*4V_Auwf`A%9V za|NMkprHbX=<^+W>RLa2ZYS{E!mMl)-wiPZJULX`OheOB1GEL2XCK3K@z&G^u zae|f?-!4VlB6J{n>V~Ee7YAn&1^-t=eM1Mm|5~_$ibg^WG$ERr#(E|`_M`3XUp_a~ zwa}@2=~RZlJ6vB&Q&U4%&(Ogko=U9yR99P1Br*nvw!D#09Zd~&b%?H^tz$I3z5Y{8 zZCy({wNEe`U}p+ZS65SqXd7DEA2V8OKYyvIZ*HS7d2@bNMq29Xsv24lV}yg0(Ngo} zOHD&lE1AKb@v|_{Qdd*c(1aMmoTunbwO_u}f+P~XUmRp*2mwujBz;r+WhS8>^rE4$ zrJc;+9lAjELEkmCAiBl~p9w~L6TY#jxrNY9?H-MAfEnxS>FVhlm>?X(#hfl0g-jxn zNYpN_G|tn}+8lvESXkS;_(#cq2uB9_JT8aJ9~hmQUMq+R3-h`tJR^cVUrnDVPWO(cZ@T`p%*J?BwVwKPMwKJvAXA zAucvPB?nt_dv$w#e|vj)VXO{DO3`WN|)Sys^2L0eT;ofZ1Ey-#$LtIoPSVE_^D; z%`eSeE0gVAuU}uDU~bWvvfSd_$A`u>Hh5YHDTy}nQb0@od*v9SW9$-$63bzw~ zK7Bl>*xtTAJiL@0<;(J`P96`>j;^2fFV__Zhx><{2U|~@yM=4%8~3+sr8_e0^4awU z8eD^eJ=xCM;mYCtX7(1k{51Pev3It4x3zb*cXodzzgaJRyu8^{9G#t1A1L+@aI2*| zo0-$8izme^*zKK%ec92**2#_HS|O|6R;*v|oUHGypRAqi7oRH7iG% z`&WnOhc{bS`x{lOg)8WlBKd0astmW7y|wpoTXuE5iQ7I0{VZF@9`2SemmRI*HaD}E zuovsteOb}gRoPm_(N@}Og<>mv?_uxy_+D{#E89}Yj*r%Ho8<>*l~)&6cP9_$n?>7l zg-mh2eRTP=;!u8b{BT}%hPy5+OO>x;T3UM=nqDdKh#n zjnOTTen46nYHMpj3{36iY+`-Q=TBeiTNn}#Q!Uk3>ME+HMm9`N>!;dJf7I1C^=$i@ zL9|rf=%_>N;n9>{{O9`5pYcuPske^C5KT1|BP~d{{o9t#mcQ`zpW7+4NTj2_7DQDY zp=l(tnZoxsd}^z&1NS0lq`58xqN)eebpByWY3=$_+g{t!-MQ{yXQHE}sSY>LUw9wi zL~Z@l*j!KR<$Kt}3?Q271_+1~--*`PQCmZ7Ah6i6_SQyvS{hnbhDIZ+NqA!G7d)Y{ zgR|u9WU8;Lp$Aw!VeHiIG-MU6RMJV+~!1N%RuXPjkOlkdsCVX98O9OF!IlvqS2Kq)b7HY(0^bnfsz7SfQ z1t?Ezgr0_$nua^vl*3>)HP?M^A~tgaTo4FDbxjQo8#~wph1T2*rk6_YNpvzZG1XF2 z*R(;xu1W2L*2d4Zw5}eBhl!qvzJ`j1O^A(uCjsBuR$J57Cz>lm=;|5hYN(lp`2|eU zo0^G@^-Yw)luQ>>LjwbSh@L~p+x^jww$|3B7E-4$!rKN0g~H%wFel%zJu$C?Mkdla zdq?+z935<}Z0zi8oqbRlxjRFwo{k>D(4b@?Bgn_g!`sKh-Pg}AJpOL+`^3<|;P8}0 zvU^b!9u(m1@8=&J7!;b6x&Pza*ra4sIK6aK5*HB~?C%@$E<6&I2<*3&@87Gd;Vy=mMR0aeDNqIL`T)TV0TuS&hYHp$jwRz)aq3f5@l_V)Aum0vq%