From 86582569ee3b70d8dcf1389aa0624f4fe3965065 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:35:18 +0400 Subject: [PATCH] feat(whatsapp): add guided manual setup flow --- .../whatsapp/manual_setup_controller.rb | 78 ++ .../concerns/meta_token_verify_concern.rb | 1 + .../webhooks/whatsapp_controller.rb | 8 + .../dashboard/api/channel/whatsappChannel.js | 20 + .../dashboard/i18n/locale/en/inboxMgmt.json | 105 +++ .../dashboard/settings/inbox/FinishSetup.vue | 3 +- .../settings/inbox/InboxChannels.vue | 2 +- .../settings/inbox/channels/Whatsapp.vue | 15 +- .../inbox/channels/WhatsappManualSetup.vue | 852 ++++++++++++++++++ app/models/channel/whatsapp.rb | 8 +- app/services/whatsapp/facebook_api_client.rb | 48 + app/services/whatsapp/manual_setup_service.rb | 57 ++ .../manual_setup_validation_service.rb | 79 ++ .../whatsapp/manual_webhook_status_service.rb | 39 + config/routes.rb | 4 + ...webhook_verified_at_to_channel_whatsapp.rb | 5 + db/schema.rb | 3 +- .../manual-setup/add-phone-number-poster.jpg | Bin 0 -> 172498 bytes .../manual-setup/add-phone-number.mp4 | Bin 0 -> 3891259 bytes .../manual-setup/create-meta-app-poster.jpg | Bin 0 -> 151168 bytes .../whatsapp/manual-setup/create-meta-app.mp4 | Bin 0 -> 5605841 bytes .../generate-access-token-poster.jpg | Bin 0 -> 132301 bytes .../manual-setup/generate-access-token.mp4 | Bin 0 -> 1972904 bytes whatsapp_manual_connect_v2.md | 333 +++++++ 24 files changed, 1649 insertions(+), 11 deletions(-) create mode 100644 app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappManualSetup.vue create mode 100644 app/services/whatsapp/manual_setup_service.rb create mode 100644 app/services/whatsapp/manual_setup_validation_service.rb create mode 100644 app/services/whatsapp/manual_webhook_status_service.rb create mode 100644 db/migrate/20260711090000_add_webhook_verified_at_to_channel_whatsapp.rb create mode 100644 public/videos/whatsapp/manual-setup/add-phone-number-poster.jpg create mode 100644 public/videos/whatsapp/manual-setup/add-phone-number.mp4 create mode 100644 public/videos/whatsapp/manual-setup/create-meta-app-poster.jpg create mode 100644 public/videos/whatsapp/manual-setup/create-meta-app.mp4 create mode 100644 public/videos/whatsapp/manual-setup/generate-access-token-poster.jpg create mode 100644 public/videos/whatsapp/manual-setup/generate-access-token.mp4 create mode 100644 whatsapp_manual_connect_v2.md diff --git a/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb new file mode 100644 index 000000000..b8c225017 --- /dev/null +++ b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb @@ -0,0 +1,78 @@ +class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController + before_action :authorize_create, only: [:preview, :connect] + before_action :fetch_inbox, only: [:webhook_status, :setup_webhook] + + def preview + render json: validation_service.perform + rescue StandardError => e + render_setup_error(e) + end + + def connect + setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform + render json: connection_response(setup), status: :created + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) + rescue StandardError => e + render_setup_error(e) + end + + def webhook_status + render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform + rescue StandardError => e + render_setup_error(e) + end + + def setup_webhook + channel = @inbox.channel + Whatsapp::WebhookSetupService.new(channel).register_callback + render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform + rescue StandardError => e + render_setup_error(e) + end + + private + + def authorize_create + authorize ::Inbox, :create? + end + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :update? + channel = @inbox.channel + return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2' + + raise ActiveRecord::RecordNotFound + end + + def validation_service + Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys) + end + + def connection_params + params.permit(:waba_id, :phone_number_id, :access_token) + end + + def connect_params + params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name) + end + + def connection_response(setup) + channel = setup.channel.reload + { + id: channel.inbox.id, + name: channel.inbox.name, + number_access: true, + template_access: true, + webhook_setup: setup.webhook_setup?, + webhook_verified: channel.webhook_verified_at.present?, + webhook_error: setup.webhook_error + } + end + + def render_setup_error(error) + Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}" + render json: { message: error.message }, status: :unprocessable_entity + end +end diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index 42fe918cc..eb20ecdca 100644 --- a/app/controllers/concerns/meta_token_verify_concern.rb +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -9,6 +9,7 @@ module MetaTokenVerifyConcern def verify service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' if valid_token?(params['hub.verify_token']) + mark_webhook_verified if respond_to?(:mark_webhook_verified, true) Rails.logger.info("#{service.capitalize} webhook verified") render json: params['hub.challenge'] else diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index ee71f3c92..eecc0dac3 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -22,6 +22,14 @@ class Webhooks::WhatsappController < ActionController::API token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def mark_webhook_verified + channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + # The verification callback must not trigger remote provider validation. + # rubocop:disable Rails/SkipsModelValidations + channel&.update_column(:webhook_verified_at, Time.current) + # rubocop:enable Rails/SkipsModelValidations + end + def meta_app_secrets [ *channel_meta_app_secrets(whatsapp_channel), diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js index 8f51f4878..e5e59f221 100644 --- a/app/javascript/dashboard/api/channel/whatsappChannel.js +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -16,6 +16,26 @@ class WhatsappChannel extends ApiClient { inbox_id: inboxId, }); } + + previewManualSetup(params) { + return axios.post(`${this.baseUrl()}/whatsapp/manual/preview`, params); + } + + connectManualSetup(params) { + return axios.post(`${this.baseUrl()}/whatsapp/manual/connect`, params); + } + + getManualWebhookStatus(inboxId) { + return axios.get( + `${this.baseUrl()}/whatsapp/manual/${inboxId}/webhook_status` + ); + } + + setupManualWebhook(inboxId) { + return axios.post( + `${this.baseUrl()}/whatsapp/manual/${inboxId}/setup_webhook` + ); + } } export default new WhatsappChannel(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 5db865c06..779d8710e 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -294,6 +294,111 @@ "WEBHOOK_URL": "Webhook URL", "WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token" }, + "MANUAL_SETUP": { + "HEADER": { + "TITLE": "Connect WhatsApp manually", + "DESCRIPTION": "Follow these steps to prepare your Meta account and connect your number to Chatwoot." + }, + "PROGRESS": "Step {current} of {total}", + "STEPS": { + "1": { "LABEL": "Create Meta app" }, + "2": { "LABEL": "Add phone number and get IDs" }, + "3": { "LABEL": "Generate access token" }, + "4": { "LABEL": "Review and connect" }, + "5": { "LABEL": "Verify connection" } + }, + "APP": { + "TITLE": "Create or select a Meta app", + "DESCRIPTION": "Your WhatsApp number must belong to a Meta app with the WhatsApp use case enabled.", + "ITEM_1": "Open {metaDevelopers} and sign in with an administrator account.", + "META_DEVELOPERS": "Meta Developers", + "ITEM_2": "Create a new app, or select the app you already use for this WhatsApp number.", + "ITEM_3": "Choose the option to connect with customers through WhatsApp.", + "ITEM_4": "Select the business portfolio that owns, or will own, the WhatsApp number.", + "VIDEO_TITLE": "Watch: Create a Meta app", + "VIDEO_DESCRIPTION": "This short walkthrough shows where to start a new app in Meta Developers." + }, + "NUMBER": { + "TITLE": "Add your phone number and get its IDs", + "DESCRIPTION": "Add and verify the production number in your Meta app, then copy the two identifiers shown in API Setup.", + "ITEM_1": "Open the WhatsApp use case in your Meta app and choose API Setup.", + "ITEM_2": "In the Send and receive messages section, open the From selector.", + "ITEM_3": "Select an existing production number, or choose Add phone number.", + "ITEM_4": "Complete the WhatsApp business profile requested by Meta.", + "ITEM_5": "Verify the phone number using the OTP sent by SMS or voice call.", + "ITEM_6": "Copy the Phone Number ID and WhatsApp Business Account ID shown in API Setup.", + "VIDEO_TITLE": "Watch: Add a phone number and find its IDs", + "VIDEO_DESCRIPTION": "This walkthrough shows how to add or select a production number and copy the identifiers from Meta." + }, + "TOKEN": { + "TITLE": "Generate a permanent access token", + "DESCRIPTION": "Create a Meta system user with access to your app and WhatsApp Business Account.", + "ITEM_1": "Open Meta Business Settings and go to Users → System users.", + "ITEM_2": "Create an admin system user, or select an existing one.", + "ITEM_3": "Assign your Meta app and WhatsApp Business Account to the system user.", + "ITEM_4": "Grant full control for the assigned WhatsApp assets.", + "ITEM_5": "Generate a token for your Meta app and set its expiration to Never.", + "ITEM_6": "Select whatsapp_business_management and whatsapp_business_messaging, then copy the token.", + "VIDEO_TITLE": "Watch: Generate a permanent access token", + "VIDEO_DESCRIPTION": "This walkthrough shows how to select your Meta app, choose a non-expiring token, and grant the required WhatsApp permissions.", + "WARNING": "Meta shows the token only once. Copy it before closing the dialog." + }, + "DETAILS": { + "WABA_LABEL": "WhatsApp Business Account ID", + "WABA_PLACEHOLDER": "Enter WABA ID", + "WABA_HELP": "Copy this from the API Setup page in your Meta app.", + "PHONE_ID_LABEL": "Phone Number ID", + "PHONE_ID_PLACEHOLDER": "Enter Phone Number ID", + "PHONE_ID_HELP": "Copy the ID shown beside your production phone number in Meta.", + "TOKEN_LABEL": "Permanent access token", + "TOKEN_PLACEHOLDER": "Paste access token", + "TOKEN_HELP": "Use a permanent system-user token with WhatsApp messaging and management permissions." + }, + "REVIEW": { + "TITLE": "Review and connect your number", + "DESCRIPTION": "These details were retrieved directly from Meta.", + "VERIFIED": "The number and token were verified with Meta.", + "BUSINESS_NAME": "Business name", + "PHONE_NUMBER": "Phone number", + "PHONE_ID": "Phone Number ID", + "WABA_ID": "WhatsApp Business Account ID", + "INBOX_NAME": "Inbox name", + "INBOX_NAME_HELP": "We generated this name from your verified Meta business name. You can change it." + }, + "VERIFY": { + "TITLE": "Verify your connection", + "DESCRIPTION": "Chatwoot is checking number access and configuring your Meta webhook.", + "NUMBER_ACCESS": "Number access", + "TEMPLATE_ACCESS": "Template access", + "CALLBACK": "Webhook callback", + "SUBSCRIPTION": "Webhook subscription", + "WEBHOOK_URL": "Webhook URL", + "COPY": "Copy", + "COPY_SUCCESS": "Webhook URL copied to clipboard", + "COMPLETE": "Verified", + "PENDING": "Pending", + "SUCCESS": "Your WhatsApp number is connected and ready for agent assignment." + }, + "ACTIONS": { + "EXIT": "Change provider", + "BACK": "Back", + "OPEN_META_APPS": "Open Meta Apps", + "APP_READY": "My Meta app is ready", + "NEXT": "Next", + "OPEN_BUSINESS_SETTINGS": "Open Meta Business Settings", + "SHOW_TOKEN": "Show token", + "HIDE_TOKEN": "Hide token", + "VERIFY_DETAILS": "Verify details", + "CONNECT": "Connect number", + "RETRY_WEBHOOK": "Retry webhook setup", + "CONTINUE": "Continue to add agents" + }, + "ERRORS": { + "IDS_REQUIRED": "Enter the Phone Number ID and WhatsApp Business Account ID.", + "REQUIRED": "Enter the WABA ID, Phone Number ID, and permanent access token.", + "GENERIC": "We could not complete the WhatsApp setup. Check your details and try again." + } + }, "SUBMIT_BUTTON": "Create WhatsApp Channel", "EMBEDDED_SIGNUP": { "TITLE": "Quick setup with Meta", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue index ec89ec930..410683387 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue @@ -49,9 +49,10 @@ const hasDuplicateInstagramInbox = computed(() => { }); const shouldShowWhatsAppWebhookDetails = computed(() => { + const source = currentInbox.value.provider_config?.source; return ( isAWhatsAppCloudChannel.value && - currentInbox.value.provider_config?.source !== 'embedded_signup' + !['embedded_signup', 'manual_setup_v2'].includes(source) ); }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue index 2cebedebe..c7e7c2da6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue @@ -69,7 +69,7 @@ const items = computed(() => { :global-config="globalConfig" :items="items" /> -
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.HEADER.DESCRIPTION') }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.DESCRIPTION') }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.VIDEO_TITLE') }} +
++ {{ + $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.VIDEO_DESCRIPTION') + }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.DESCRIPTION') }} +
++ {{ + $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.VIDEO_TITLE') + }} +
++ {{ + $t( + 'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.VIDEO_DESCRIPTION' + ) + }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.DESCRIPTION') }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.VIDEO_TITLE') }} +
++ {{ + $t( + 'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.VIDEO_DESCRIPTION' + ) + }} +
+
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.DESCRIPTION') }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.DESCRIPTION') }} +
++ {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.WEBHOOK_URL') }} +
+
+ {{ connection.callbackUrl }}
+
+
+ + {{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.SUCCESS') }} +
+ +hVg
zzspcQ2awwVPDe5_#echVrbX}cL%)8HB)75s3I6+dR!2Hf`OXrzp4a*aaOU5T{~zUO
z&;P-v{7-(l${=6a06*tu3=zLG;ROKvgT?=dpfl+Pdk1jBhhW^zv+uua)^Sb?2vb_M
zt275Yz^=dRdoJfeN}r2JxBw3y^SY;3@U=JG>{+>QF)vdRR4^iGqWaslyBu#HLYv<+
zymRd0OcOlogZAC!des-L`Uj42Dvs|`VC8+|7rN^FKj9BbAY@!PtIrQUgICY}gewT>
zQ7? aUpujpG1f~MS*M)iMiE3zje@u7Hj!`k{q2bXY!!g5$
z*;rS%PNK3VF0gFII9PkR=#ErfrgEGrHiUusJHyXm=byEy&wJ{W9E`k8{oKC^Lyh+K
zN+#_ZrK~*5phxBhH*Xt-pzFdeu<3IhMs|co=#{AVD2SS(>u+%xG0wjvuMGb?E&lm$
z5MKI=qXyXETiSJPP2P4}h&TXN_5UbF{O@WL|K0`i=Rub=o&(n+0ct$|tW6~Qqxh;P
zKHk8Rvi6-* P_pC#?5YCM8S;dq3QQ6N5}7v
zC=VoNB?HL%9Ict_Y*eiI3(5BM=gGXy
zRlUZADIFMtH+m=ni{#($&xwDrduv(MIO>*{(%W$-QYmM%nQt=P%sS(WUeTmX9y+hX
zSi>3+*0;zIJLFu|uF0`={vzi?oTPJ;VE0UPClDwGamU71jp;sBam{L1vG?p2Fi)%z
ziUTq*BrOcXP#BqPscZ`%YixcC?<0V^C3^o(8MiqPcb!KS-d#ViU5@pge+f~Wv!OhR
zHD4BQn_nAZ>wS_xI8Ib;>UWslp|FsScheLT9YE+Hpw2RA_}voWONnDQ?Z`V1-npEh
z=fIy1td+Z+jlDo4*Zc(eZ5G6YLv;A5v3UU%F2P>xjjy`1*`_;u4O-p91a8RjH(4g{
zP+`{eqo*M;#KCcS6ij!)`_Vf-vX`^EYD&ZOr4qSH(o2gz33A_p*5XYl#2-u>ZWK+R
z49vJKObFX%S%FzuO#$8*o&xtIH_}5vwwSzk)hMia-J}`xe91H-zcs~<xitsB72Q{Aci5GdO+G=L4?20y*X
zvA2Buz$=z-UAz)L78_SGG9R0ls(5PVzcL2@ABp)HgP;ja`A484Q~O;2fbxIV9CAAV
zfbP-hZ&?bB$+QZM0b>X;fbbt+hfw-4hfuP{(BFT=@cVRvNbftJ@H71LJ01XFq~O6l
zJq~#)eiL7<+QB)6%ze>P*OGWy=|Zfc)w7Wey596PjUwjiQcTLcmZETY$#uE3rfe`&
zKgfwGD{|9~6uiw-XT3A$G;*w?@ZNoO=FA5&A0*+0m<6
z`SgajA(t<*j4UN8Ue`zlPc%C*Yo|YWl_B_$M$Eki^to~2%pRH7Qtq&Q)Nn6DrnFGO
zfaWc~VZU;%O+vuu-f9mh@Jem9u6Mrcn$(>k(zRgU995^jCW7PyAC6U~MLV^GG;0l;
zc}nUXV;5yG%L}e+KNK6N7hI#$FOw9aa-Xdxq{kQqyMW|af1@aq1^Y_Aj#AD{PB&!e
zE
Tc~x=p+!0x)
zMz+_2L=>okrNCDkf2-JD!#_5yUJj#7Q*YG9S?$Zg3{8UTCy8hmxfM;JC!v#S!mLoh
zO2kaXrdy81K}w*n>#I-w2Ti^q{^lQza?BL(T*47@ef?j~IWK0~>uB(c6jGWZ?QNiw
za;%>KhGgYpZ=BkCda8=fOU}bmzfo2iZXBWIG15NOdf8f)
zx(|6J$GyEiefAS2QDJpa1y1?d%lpF`TJ
yMM`zz*+oDc3~HFBwV)h}%w
zNiI~2dv$)$-lmU-yWsN%n+&lZJfBm{EW61>&RCn!X|cqvE~ALOBh`6Y{c=A5jAwrU
z60Ur+k39LkGxLg#*JkiqxPi8DCv4xJpmCjZ+_@YTU|CQ(x$m9V8et`_#X7nH&7egs
z%7L)4d2&zJdEHA#2SKD)BKb{+DO=GO9t7K}!ahHpu53qHxEwjgP>g>RiT_fOP_Xk^
zi(};S<&0NKveh>&$`&5K_TXss`pWZ?)I`uSuO9qZJu2A4yjh#}c_TajNRZ<32)f8-
zUQnT^&Z8x}y27;Kl_XegU|u2G!my^$Tu~tGOK!G~!wW}9>D=O!659)j{V%>kDtc-{
zsJlh_pc%7;mF#8U=aPZ^p71TjxD8}z=|}xaEZ6syO}rNgj}{5Eo>tYLthEiR8Dnd!
zqbX=2=Osuomp3I@!-(dr9bna`JZfU>P
Hpa8yT*On)!(LOzekjd${2Pp@}Zi8ZJvsJUm8x)qK3WuC|m2hshT<=1^kZiy66#9V!;%Dr&SDQ-iUcRDs
zOU5U6y5HZR43yf#jdS0)~VemdF!(rQx#G8sqvMpYE>Yv1m
zGDNM0mxt;hFj#{r$*T^<$H_fgtc&I=lI_#4zu*ICM&;n{BUPSzwDCwYVdF+YaFgAp
z+cCMy8enC&z1(|w?&fjLsyZi10sSL#gr&uJjXJ8>%l@|8F_rdVH&0FIAXeo>f59uG
zTORrpj>mgHq2)JaopzCcz;-l|z0TU7mI>9`x!rxOW_xMP0m$qYE1IQN=!CJxq%cZE
z3uiAgZHcUmH@(EXE$6fNO5!|`eahWUVQKvAbS%lgXpy(&*8W$PZ+%yb7^`7)dFju;
z*l)K-pdeAZrASS|T_TeBIr>TZwRkljuy5V7xw4Y$USg8XDp4L1=GLWRdiOt?8sBh3
z-5$jTh&13f3n7gU;$Yud?rw*=l~lq@hCK8ND)ow(m9tA;Z54m#C-6e(mW^*NBk-fK
z8s_&Nbj&XodB_}?s&8Xs8`ow#5fY}Ak3_gCks;|L3yel7Y8!`VT8-Y26Y{J%33Pd>
zzE1q7eU~qZ=kax9K>cXGQV2r~<}?V@CM{o?yOZzm9^q;V>sqDNSn)9b-0vbQt1D(e
z(7e?N%$O?%gaxoY6R#CqoJq5o+YK<0FY5>{q7AxiJ=5ZfaPSnA14tbS9DLdnZ(Ips
zMxm%%8cnC{0a&
Scdu(pOpJ8G@Yf8NpQM>i{VXY<(3~QOe5+XH8}I%NT9erP4PvqU
z4WdAAZMhsj2>1|ET2l7l9Sux5@>
zLLx3_4!B1b!XZW?scT|@=lwbr#QU=8b2Aa)M?EL+lZ0SI$!MrW(7kd~a&8lF*V()V
z&AC}FI@2S_Rb+VIn=c5!1~+MRK_Ywa)w9rn3fugxldUcmLeh;HtXvK1A_YY#k^;2{
z==0x8&U40brZsAEA9I(Aq($Lyav8l$kTEj{$C&U797Niyyeamnfdfxg98OaQyC+RJ
z;3;$+OS8`9(KTJOyovT1t{(DxF~Li76MvMWF*l!<8mTXV+$2%Wwbfbv$5+DpB?Dx?
zp<};k_I!mPu<1a0nY!rv2Iw%p&c^xB^T>%Wh*yF#YDj1_>K^<@svHlgt&g?&bU9+M
z8$G{n_{xWnMUn9UeLMNiJqSplyl0mcYp*F!Y?zlOm
z1JVtVhtTz3We31TPH+#|=#G9gzC1psO&MIAn@v1%SLt@J{`lrLHrnFch(8ph5E#Jf
zFvnXfIT<7n?5bgxH`Oc9LC-j;mrf6D(^#md@YSbcU1YUM