diff --git a/.env.template b/.env.template index e6a5c3264..ccf6bd9ab 100644 --- a/.env.template +++ b/.env.template @@ -24,3 +24,7 @@ THREEMARB_PRIVATE= # POSTMARK_TRANSACTIONAL_STREAM=outbound # DOCKER_IMAGE_TAG= # SENTRY_DSN= +# +# THREE_SIXTY_DIALOG_PARTNER_ID= +# THREE_SIXTY_DIALOG_PARTNER_USERNAME= +# THREE_SIXTY_DIALOG_PARTNER_PASSWORD= \ No newline at end of file diff --git a/ansible/inventories/staging/host_vars/staging.yml b/ansible/inventories/staging/host_vars/staging.yml index c36965b57..b96b0fb4e 100644 --- a/ansible/inventories/staging/host_vars/staging.yml +++ b/ansible/inventories/staging/host_vars/staging.yml @@ -1,110 +1,116 @@ $ANSIBLE_VAULT;1.1;AES256 -38376265303534616566333137376366636462643532666464356163663936396665366263373663 -6535383735653737306639383636626632333663303733610a613536323430363638366466353737 -61633038623236316139663361393439316363306165353731333763663365363366333838616666 -6339323466636539350a356331613664646435306134613030346238636437663430383335343835 -64303132366233643336656331393731633734333334616639353337643533323763626331623864 -36306139663234353537633966623037653935616364643762316465356438373434366637613334 -37616666353539363930306563353066636361326562336130626465316262313935656535643331 -30653562386166303137383966393136343864306266306164623564616463303566313936643038 -35616130623463393137616635666236643562656231363436393763313233633166323533633564 -34623663366130353231656136323735616561643331633263366537646662366563626136626566 -38346364393338313931306432356131303665656437623662656663316232616238396334333430 -36356532633131353534383937326262633033663461613739323662343264363761656137326538 -62663439653666386163393931326639623961303064613636363261616362633661326439353765 -33616432383363343337316530623136353062333366343934343430373434626563646561636138 -36323632376230333165663138383731643136376662356665353538343335613237326666656532 -36336331393139333366396165656436306662643163333138353263633232626439323034373635 -63666532666531356233653165346132363133356566323163623362323564633136316333613637 -62343566333565316363393332666434643033356435326431646365383464393563363961383664 -37336166326530636530323232383831373732623365393861616433643830646533396236323663 -61333863343039653731303130323366653231383065393734616363663236666232373936636265 -65343136653265623766663935353434623330306332346437393637643762333939373939386665 -39663338653436333538313039646165306363383764373135626139373736383134646235666435 -39303864396266356533366639346332666366656131323733313366393166396666653364653962 -61353035376130653630363630613334353636373462333330373631356139393038376536633565 -36646463646561363030366231306633333633333766366339376537323661626436343338633864 -64303234626236636565633961643831323466383164346665313135346236616237613163643463 -31633438653639366661363537663937633839306666623336323332383533303464323731313933 -64653533303863646530636536306337383333656335323237356464346233623263393633353463 -33303531623037636636343036613065653566366666616535366263326632336666633566356538 -39623664343662303264303931653462366537323665653236376335386235653766306131366564 -66336565653839616537376330613563626335326230653530643238626337313162626565323532 -31613363666230393931343137336462653937643739343664613461616134663364313837623832 -35336339663733353330326634653061646238316664646631666132343661323933336230363762 -63643765383934366536626364373934326436353865613938306333663363303236393565663762 -30613161316133636261633930313738636332373838633564363436383762366537623430643233 -64346265323664373336383836643833663235353134363463373836646631393931396665386139 -39316232393438326433326338393666386432613264626564303064346537333934343232303436 -63623139653135643338366235613434613062316139653363343839616336633730376634306133 -65316637326235643061363730636163653439623939613038623033623664363737313166356464 -62653437626237306164313531386465656530613335393637666437663534643464396434303165 -39656236303532303334333331333738363863353261633136363465353261356536633739383837 -33366438316530393539396637353731396363303839336462623739643239663265366662353336 -64643232373762393434306438663938393433343136303237363163393834343738333830346333 -31386531396333313261326261333333333363323630383565343965363833656265623430396239 -36613434383865663366363037643265323832363663396230356431383463633139643733323336 -35623033366236333664633630333636316338643630323034626535633733306266663162303962 -33353565303730613739623461323831323130333831386331643530366635343962613339663039 -65636563643363383232643232666635396336326162653061366639626265393830653363613762 -31613230303839653633303165393939313234653430393134343062346162376331313831363335 -61333232373063303938336566346432303433666364386238343632613061326134643531643531 -31326562336239386530373066643235306139626231353239326535393033666330646665396132 -35653063386236626133316233386365376662313061373966616231356138663838646533393239 -30363333666562313637663063326632643564383762646132343332613333356663363964363033 -38376634303761663363323830336264333064316665313261646330376333396538376438653265 -38666432633735613933386162633139666330623062616636613135313065393135343538633134 -65323862653364663765643737323932393338333239393564613664393463626633356563653561 -33376435313533666261323666303862636435653761636263633538616661346135323339656636 -66366635326631386162316134373330323032363237633164386431626437396264616435333735 -35316436333434343333363832643530353537646534373064626137636234323162363531663764 -35326464303735323036343530643639666133623832303038333565633064353932336435636463 -63303861383534316132623138633963326265666330306262353735373961633037376135306264 -32616231623239663732323565613035323334396566303431653739623163663236303331366234 -34646131613563306635316237636334633437393733363137336230346335653733613838393234 -31326135326365663635653638323363366539643139623738353361623534663563373264336136 -37633564353935353765666665316164383566666538323934383334303732303430353131303934 -63376564653164336433383935663136346166666530663137623563643165383933646430346637 -63363533306132663066383766663633396135663064313732386131396665623334653362383135 -61383161386430363239383966643439616165613662636430303734656665623237663936626635 -37383638326266636231356462643638373061623162613832343739613265613735653130353565 -62363738386339343132383435396632383663656338333636326335616164356239643736383565 -64306538336337366661323164326235363037663537333338326561333636623862393333373535 -61636462653436356163383331313766653661633038303161323239633937306462306465616638 -33633761623566663935653135393030613063646638356235666438326465376231366533383036 -35396639353530613261616364656534306662373861613130373263623735343966303666396631 -64323039643365393662323232373562636264643066383934333865326638656637616366613362 -31666664313037373338626237383332626431353434356665326361383734613566363934303561 -34316462636530633562663661396135656335656464353134363237636633376136343439346262 -30373962383164643336393263666236373132356235653133663264333036383431333966373631 -39643130316135353063643333393130303833333131393662623139306333613338656638393438 -36626433336434393139653338623434376333396235383930633234626362663534366439363364 -34346263383733396536656635376439643538383062303236646137623166353663656438356330 -35356362633437643733363063353738653666396631366662636466663264383463343863613738 -61393536643134623338346465636534323930306432616462323632643039326536306135393531 -62353438323463343065363131383763366264613136336138383164613134646131663136313764 -39336162613361613866653936616166313366613838616164346336626339303465356132636466 -31646237386335636161653830383164353761666464636661373036336332363031633465663931 -62346134636162303432633964313235336563656432663831313832656138353335373830656161 -66343465373537356230393834316464326363366133313639306166646364313734393334346363 -66643837636465616563396331323130383665653636383264353936613164303034356530303361 -39643334623836633561386636323838343839623961643364616135393863326536353338303431 -61666264356433613365646466623535323533663730373431383266383965343939373366396631 -62393262316166343264623636613632653135353663393631333533613330303735613734616434 -36633133643339396134663232353334643732396632613838653437356137356134663732333837 -64656632613234353133633535653065383763393564666665653261363864616430636537356231 -30623661613566333833323835643233626636386334366565616261323730323763353966333034 -30386562353366343832346536316661343131353661613366303566653837313663383435356330 -36313733393134376463363339643939353237313263356533633236343166623232623064386539 -33653533326335643662353731376137343162343532356333626532313635663932656261653536 -39623936393066373130393662323432616537633463313964373631393938376336643830333864 -32396135383335386665653038373638633966316164656664353431663138623866383432306438 -39323834353838356265613838613065626231616534326335303832623733373737363764333062 -64393264396439626630313762663432376331346230303032666538316336313665363136303961 -39656332653263626336396339303331663232636633343332363535393533613430306561393738 -63363430303332636433663463316433653533323464326436666665663834323035336235333461 -37396264666132666337336130616438623162366333626365383366343233653135303366343133 -39653734643933336131393065333762386633366262396335626239666161626432313265353837 -63613233343039643166393430386262343964653265646631343732313639633961653061313236 -36623361613231656238636230366566646263633934323161336163303831363732303535303036 -3861 +62623537373466336432333137376566643835306139633135656165356238366561333830663965 +6530383633656662313733366166633064316139333837390a653765383635356162303865323831 +61316466396562323463643930366362613530373536393965306137366335646463316464656166 +3431343261313562390a343432383330646234666663396135343138663766326634363533363739 +37383765373932356431653936336639363734316139393765326261643265666163613262643563 +33316431353036346130613263653430366337396332623132663536303666396662636136646533 +32393038333430626536363732373066313066333137616162313839636431396536366130316266 +31306363366234376333613739303134326564633162316265616237336564633431323139373531 +64343836356236323232343434316436396561303136616663323164353861633631353639643739 +61396130643466643365643030303232333964353737613465376537326330653131323461623734 +61646534626262323637336564393630656666373834643932326564366565343631306133623564 +33366461376431306364653439653566663665646532303439376430643262643639643731396235 +66616565666430393464656539666261376235383562613631346638393435363266313430383365 +34353130383938386566366434363537363465663434386537376335363061376166303032323635 +61363038346230623162346564653465383332303662633537393933376639626231343965333764 +61323662643630666662336635616638343163343933393331616130353533323434366661366538 +35316537643732626334653566613039323961353632616264386666383836366166646130663666 +64306130363234383134373663613235373135323231306662653339386664326231353330633532 +62343865333131343266393431323739663439343533336436626263633865343332316439366165 +35383666313939303766346433356262323933396336656539343739363835353332653166666633 +34643466633132376333313634326261333862663332623262353938666263353138643638306534 +31613765353835366365373835336566623464636463363365323563353662346535643132343462 +35356233636565393064313463643831353665376261353938383564626530623534303837613663 +64663364376134383562313938386363666539306638616530333530623538373263643232323761 +35313434396338303139343536643836363837636663366133633935326632393039616336333263 +34333836363432313139636463646136633064633030343033303861333432303637393433353132 +30616561303931333736326136386534616636633330373866646465356462363437663936353635 +65326139636362383737386361626463386262323034393332306433353962353631643865326231 +65373865333131333132633534373237623632656136313731643064666435653961363837303034 +63663533306539393463363062343065313138626338316233306165373766313965376535306239 +61306663373334353739636264383765393133353236613433613234666562626234646530623562 +30343963323761326535386264343230326532303938363265333631346164633937366530346264 +63346236356566306564333939333864333965663865366131313430396130663266666436623032 +66646162396135636562343539383161363436633763363035306430343034316430366532366136 +62383939393064316536636634333631613631373432303438363836353966373963653864343933 +38663662373939356365356633653739393962646535646439343765656366303934623939396661 +37373134376330343362663830323936626562333462613232613363386239613866623733333837 +30393736616232316233656338663835316364643866323035343939343236306236323234626331 +62636633613964343132636536396233393662393963646236333936623937383866366138323338 +36626231663938666337366135333761623666326263393636326563333335633735373032353336 +31646337323737633337303637313235386132303432653033616333393131396131616333376564 +66633064333533323633623239353934613166323939633339393530343737353239343964666232 +39663163353665653834666537316232613737636232343566306137326265316135636664323134 +31656363646137363065623666633965306334373538313431306435346536303830353134316333 +36346537333436353034623133646266386234663964653063663538363638643130663262343938 +38353731613038366437323133633766386539363332303764653135336138376139333636653830 +36383435663732353862623066343438383965323335353537633766386239313464306339383765 +34376134303336363035376632353336303865383062343132316133626139366138386331613430 +38366237623862333462666535623566386366656533363735653833623663383863336236663662 +34313065663538303539663837343230396135626462653064386533313264346632613938646465 +62636130313433643633633532356437386131393961666436303461623462663036646235363033 +33383539653532393938643537303565306232306134623931626233633634343637616236643563 +32666436636639303831623562363738383032386433346562636230376634343964306635623532 +65346662303266623361323666313234326666613134643634633831363266643338633135343331 +32346130306436653638663962383464643130386366343534346562643538383362393935303063 +30623836626663373934316330376461383066336334663132376133343337663338646635646130 +36303136393136356431373838343561336461313236376166346637323337363861616230663861 +39376462376330326632353063303566386364383633663232356439373962303339373763386533 +61633664303631313432613563646465616235356537616637613231363262303635353033306166 +64636361623061366234643666323232633565393263383961333633316139353665383532616465 +37313361666566646430363539643961653666353532653334353264613464336362663863323132 +66393362373538663836623066626136393832353163393935303936653331396432366230656662 +33646561316439653631643861333064376131376535623037393336653838613439353936396531 +37636664343138363532386633333234663262376262363665336339656466616630363064643366 +33373236326565363766363038663361386566326464666164666337363434613639663538323731 +36373636656565663538633032366330303163643336623561363134313130333036633436363365 +35633337636164313064323963383166313865643136303766623765613030623130636634636437 +64356439633033613630396164393936303634343532316362306535333934333563343638353639 +66653639363235623038616536646266393534386135663933626439363765636161313163646262 +36303636663633636630633861323739336135663334326138343134393065353039303030643933 +30316265343666356439343437373338656165313439636532636163373733623137376436643263 +31656431633239633235333231663866336439636661316338663662663131343135386235383537 +37623234653235303164623236353766326638316638343236616537343765336264376539663633 +35313735626334313633376466376265376533303937373636643837323532396365623563343066 +39383563636365306432366530316362653937643131356362346338366661353662313665633462 +36613436363764333233303639653833303238386164323130386332316264636137366334633162 +39363034356561336663333564663966336435616261363932643632666135386362663331623862 +62366438306362613537323232326537643932326236663665626666303438636130613131363464 +62656435353038393936393064616237303765303465353532386131663065366134303234343365 +65343463633963303730376535303737336139343166643939353333663962613733393938623365 +61643936356461376165376131353664623964336333303365653138666663386162376266656536 +65396334613363306331313466643433646439393137363334653039663232336436666265343639 +31386433636533366233313236343035626437373834313332613239303563363264326162306434 +64386639653163633837633030316234316538646536353638363862393339326132623932663933 +33626636643763383337623566343366626265633066613632306161383639353439653133663963 +31663138313439316665656532643034653934323337323836373763353031613934363562303838 +65666335303038643461363562383333376464303334386237613864643132646531303638323631 +63353832633934363031373333313438373033313266643962393961373838636665336161633134 +63323463653231306136653233356638383639653531373439623464313961393861363635636364 +36373237363361333739366236306439303561333639346262653235653965316438656636373064 +30313263623534376232366164313034333264316564386336653631303966623336336530633634 +63316534623863323331636137376637306666613039313137633637613465363038643062373866 +36666662353033373939376438623664356135626363393230643135636333663131363235666665 +62663030633265393934346138663166363634353232386463363161343230356232626437366331 +30623433353465663066353964643835333235626130663763386239373861323930663336323333 +64666333396466313839666337303036313533383236363434336430373732323138326335356437 +64356235653436353163363632356635363739333962376263343435326265323039336537666132 +63646435373730356663313963383336336437376430666562376265623538623361666236353430 +63323465633362353331643034333230386335663966366261353631383434303739313664303037 +66306366653761386434373534643038363964616230646439623061356534333232653539343235 +66336639643536386666346631626230393431666437623731663238636431353065313131376463 +37343534303164653637386532666233313334306134646339373264626430663335626536626232 +61363065623037376266663236383365653336346435313632353366616562333938303630306236 +33656636306532363833633731343435623136353533636330356539343030646266356465356236 +38356531396138653232356133366635323136323437396535626164316538356134626235396137 +32626566386165323438643831393639346237616362346164393939643161396532356363633639 +35333931613365323562306466626166643237343563393032623261663935313832313235613939 +33613033343361346230623931363139356139303132343637356631303964663663343335306636 +66356664313362393436386632336461336638623665336530393863633132306130666634663061 +62666339663439653235666439663737396465356637616432346230616134336462376133313039 +33636435336430376235333737336366303138363739613566653339643630323335336532323430 +64346538613430663837316333636561306536363539653233623761643938663334616435343161 +31306466646334636264353364643838376336633262343062383264623936666365616437386533 +32316233373034313662386365313934636235316338633636393734633237613366643161313036 +316638316333303463376332363662363639 diff --git a/ansible/roles/installation/tasks/main.yml b/ansible/roles/installation/tasks/main.yml index ac8f28f7e..abda7ec68 100644 --- a/ansible/roles/installation/tasks/main.yml +++ b/ansible/roles/installation/tasks/main.yml @@ -53,6 +53,10 @@ TWILIO_AUTH_TOKEN: "{{ rails.twilio.auth_token }}" TWILIO_API_KEY_SID: "{{ rails.twilio.api_key.sid }}" TWILIO_API_KEY_SECRET: "{{ rails.twilio.api_key.secret }}" + THREE_SIXTY_DIALOG_PARTNER_ID: "{{ rails.three_sixty_dialog.partner.id }}" + THREE_SIXTY_DIALOG_PARTNER_USERNAME: "{{ rails.three_sixty_dialog.partner.username }}" + THREE_SIXTY_DIALOG_PARTNER_PASSWORD: "{{ rails.three_sixty_dialog.partner.password }}" + community.general.docker_compose: project_src: /home/ansible build: no diff --git a/app/adapters/whats_app_adapter/outbound.rb b/app/adapters/whats_app_adapter/outbound.rb index b80fe22e3..6aac0f41a 100644 --- a/app/adapters/whats_app_adapter/outbound.rb +++ b/app/adapters/whats_app_adapter/outbound.rb @@ -3,95 +3,18 @@ module WhatsAppAdapter class Outbound class << self - def send!(message) - recipient = message&.recipient - return unless contributor_can_receive_messages?(recipient) + delegate :send!, to: :business_solution_provider - if freeform_message_permitted?(recipient) - send_message(recipient, message) - else - send_message_template(recipient, message) - end - end - - def send_welcome_message!(contributor) - return unless contributor_can_receive_messages?(contributor) - - welcome_message = I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) - WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: welcome_message) - end - - def send_unsupported_content_message!(contributor) - return unless contributor_can_receive_messages?(contributor) - - WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, - text: I18n.t('adapter.whats_app.unsupported_content_template', - first_name: contributor.first_name, - contact_person: contributor.organization.contact_person.name)) - end - - def send_more_info_message!(contributor) - return unless contributor_can_receive_messages?(contributor) - - text = [Setting.about, "_#{I18n.t('adapter.whats_app.unsubscribe.instructions')}_"].join("\n\n") - WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text) - end + delegate :send_welcome_message!, to: :business_solution_provider - def send_unsubsribed_successfully_message!(contributor) - return unless contributor_can_receive_messages?(contributor) + delegate :send_more_info_message!, to: :business_solution_provider - text = [I18n.t('adapter.whats_app.unsubscribe.successful'), "_#{I18n.t('adapter.whats_app.subscribe.instructions')}_"].join("\n\n") - WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text) - end - - def contributor_can_receive_messages?(recipient) - recipient&.whats_app_phone_number.present? - end - - def time_of_day - current_time = Time.current - morning = current_time.change(hour: 6) - day = current_time.change(hour: 11) - evening = current_time.change(hour: 17) - night = current_time.change(hour: 23) - - case current_time - when morning..day - 'morning' - when day..evening - 'day' - when evening..night - 'evening' - else - 'night' - end - end - - def freeform_message_permitted?(recipient) - responding_to_template_message = recipient.whats_app_message_template_responded_at.present? && - recipient.whats_app_message_template_responded_at > 24.hours.ago - latest_message_received_within_last_24_hours = recipient.replies.first&.created_at.present? && - recipient.replies.first.created_at > 24.hours.ago - responding_to_template_message || latest_message_received_within_last_24_hours - end - - def send_message_template(recipient, message) - recipient.update!(whats_app_message_template_sent_at: Time.current) - text = I18n.t("adapter.whats_app.request_template.new_request_#{time_of_day}_#{rand(1..3)}", first_name: recipient.first_name, - request_title: message.request.title) - WhatsAppAdapter::Outbound::Text.perform_later(recipient: recipient, text: text) - end + delegate :send_unsubsribed_successfully_message!, to: :business_solution_provider - def send_message(recipient, message) - files = message.files + private - if files.blank? - WhatsAppAdapter::Outbound::Text.perform_later(recipient: recipient, text: message.text) - else - files.each_with_index do |file, index| - WhatsAppAdapter::Outbound::File.perform_later(recipient: recipient, text: index.zero? ? message.text : '', file: file) - end - end + def business_solution_provider + Setting.three_sixty_dialog_client_api_key.present? ? WhatsAppAdapter::ThreeSixtyDialogOutbound : WhatsAppAdapter::TwilioOutbound end end end diff --git a/app/adapters/whats_app_adapter/outbound/three_sixty_dialog_file.rb b/app/adapters/whats_app_adapter/outbound/three_sixty_dialog_file.rb new file mode 100644 index 000000000..41f06647a --- /dev/null +++ b/app/adapters/whats_app_adapter/outbound/three_sixty_dialog_file.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module WhatsAppAdapter + class Outbound + class ThreeSixtyDialogFile < ApplicationJob + queue_as :default + + def perform(message_id:, file_id:) + message = Message.find(message_id) + @recipient = message.recipient + @file_id = file_id + + url = URI.parse("#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/messages") + headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' } + request = Net::HTTP::Post.new(url.to_s, headers) + request.body = payload.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + + private + + attr_reader :recipient, :file_id + + def payload + { + recipient_type: 'individual', + to: recipient.whats_app_phone_number.split('+').last, + type: 'image', + image: { + id: file_id + } + } + end + + def handle_response(response) + case response.code.to_i + when 200 + Rails.logger.debug 'Great!' + when 400..599 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) + ErrorNotifier.report(exception) + end + end + end + end +end diff --git a/app/adapters/whats_app_adapter/outbound/three_sixty_dialog_text.rb b/app/adapters/whats_app_adapter/outbound/three_sixty_dialog_text.rb new file mode 100644 index 000000000..5ea2ce1b3 --- /dev/null +++ b/app/adapters/whats_app_adapter/outbound/three_sixty_dialog_text.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module WhatsAppAdapter + class Outbound + class ThreeSixtyDialogText < ApplicationJob + queue_as :default + + def perform(payload:) + url = URI.parse("#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/messages") + headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' } + request = Net::HTTP::Post.new(url.to_s, headers) + + request.body = payload.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + + private + + def handle_response(response) + case response.code.to_i + when 201 + Rails.logger.debug 'Great!' + when 400..599 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) + ErrorNotifier.report(exception) + end + end + end + end +end diff --git a/app/adapters/whats_app_adapter/three_sixty_dialog_error.rb b/app/adapters/whats_app_adapter/three_sixty_dialog_error.rb new file mode 100644 index 000000000..d5a7c0800 --- /dev/null +++ b/app/adapters/whats_app_adapter/three_sixty_dialog_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WhatsAppAdapter + class ThreeSixtyDialogError < StandardError + def initialize(error_code:, message:) + super("Error occurred for WhatsApp with error code: #{error_code} with message: #{message}") + end + end +end diff --git a/app/adapters/whats_app_adapter/three_sixty_dialog_inbound.rb b/app/adapters/whats_app_adapter/three_sixty_dialog_inbound.rb new file mode 100644 index 000000000..ec973af1e --- /dev/null +++ b/app/adapters/whats_app_adapter/three_sixty_dialog_inbound.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +module WhatsAppAdapter + class ThreeSixtyDialogInbound + UNKNOWN_CONTRIBUTOR = :unknown_contributor + UNSUPPORTED_CONTENT = :unsupported_content + REQUEST_FOR_MORE_INFO = :request_for_more_info + REQUEST_TO_RECEIVE_MESSAGE = :request_to_receive_message + UNSUBSCRIBE_CONTRIBUTOR = :unsubscribe_contributor + SUBSCRIBE_CONTRIBUTOR = :subscribe_contributor + UNSUPPORTED_CONTENT_TYPES = %w[location contacts application].freeze + + attr_reader :sender, :text, :message + + def initialize + @callbacks = {} + end + + def on(callback, &block) + @callbacks[callback] = block + end + + def consume(whats_app_message) + whats_app_message = whats_app_message.with_indifferent_access + + @sender = initialize_sender(whats_app_message) + return unless @sender + + @message = initialize_message(whats_app_message) + return unless @message + + @unsupported_content = initialize_unsupported_content(whats_app_message) + + files = initialize_file(whats_app_message) + @message.files = files + + return unless create_message? + + yield(@message) if block_given? + end + + private + + def trigger(event, *args) + return unless @callbacks.key?(event) + + @callbacks[event].call(*args) + end + + def initialize_sender(whats_app_message) + whats_app_phone_number = whats_app_message[:contacts].first[:wa_id].phony_normalized + sender = Contributor.find_by(whats_app_phone_number: whats_app_phone_number) + + unless sender + trigger(UNKNOWN_CONTRIBUTOR, whats_app_phone_number) + return nil + end + + sender + end + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def initialize_message(whats_app_message) + message = whats_app_message[:messages].first + text = message[:text]&.dig(:body) || message[:button]&.dig(:text) || supported_file(message)&.dig(:caption) + + trigger(REQUEST_FOR_MORE_INFO, sender) if request_for_more_info?(text) + trigger(UNSUBSCRIBE_CONTRIBUTOR, sender) if unsubscribe_text?(text) + trigger(SUBSCRIBE_CONTRIBUTOR, sender) if subscribe_text?(text) + trigger(REQUEST_TO_RECEIVE_MESSAGE, sender) if request_to_receive_message?(sender, text) + + message = Message.new(text: text, sender: sender) + message.raw_data.attach( + io: StringIO.new(JSON.generate(whats_app_message)), + filename: 'whats_app_message.json', + content_type: 'application/json' + ) + message + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def initialize_unsupported_content(whats_app_message) + return unless unsupported_content?(whats_app_message) + + message.unknown_content = true + trigger(UNSUPPORTED_CONTENT, sender) + end + + def initialize_file(whats_app_message) + message = whats_app_message[:messages].first + return [] unless file_type_supported?(message) + + file = Message::File.new + + message_file = supported_file(message) + content_type = message_file[:mime_type] + file_id = message_file[:id] + filename = message_file[:filename] || file_id + + file.attachment.attach( + io: StringIO.new(fetch_file(file_id)), + filename: filename, + content_type: content_type, + identify: false + ) + + [file] + end + + def file_type_supported?(message) + supported_file = message[:image] || message[:voice] || message[:video] || message[:audio] || + (message[:document] && UNSUPPORTED_CONTENT_TYPES.none? { |type| message[:document][:mime_type].include?(type) }) + supported_file.present? + end + + def supported_file(message) + message[:image] || message[:voice] || message[:video] || message[:audio] || message[:document] + end + + def unsupported_content?(whats_app_message) + message = whats_app_message[:messages].first + return unless message + + unsupported_content = message.keys.any? do |key| + UNSUPPORTED_CONTENT_TYPES.include?(key) + end || UNSUPPORTED_CONTENT_TYPES.any? do |type| + message[:document]&.dig(:mime_type) && message[:document][:mime_type].include?(type) + end + errors = message[:errors] + return unsupported_content unless errors + + error_indicating_unsupported_content(errors) + end + + def error_indicating_unsupported_content(errors) + errors.first[:title].match?(/Unsupported message type/) || errors.first[:title].match?(/Received Wrong Message Type/) + end + + def request_for_more_info?(text) + return false if text.blank? + + text.strip.eql?(I18n.t('adapter.whats_app.quick_reply_button_text.more_info')) + end + + def request_to_receive_message?(contributor, text) + return false if request_for_more_info?(text) || unsubscribe_text?(text) || subscribe_text?(text) + + contributor.whats_app_message_template_sent_at.present? + end + + def quick_reply_response?(text) + quick_reply_keys = %w[answer more_info] + quick_reply_texts = [] + quick_reply_keys.each do |key| + quick_reply_texts << I18n.t("adapter.whats_app.quick_reply_button_text.#{key}") + end + text.strip.in?(quick_reply_texts) + end + + def unsubscribe_text?(text) + return false if text.blank? + + text.downcase.strip.eql?(I18n.t('adapter.whats_app.unsubscribe.text')) + end + + def subscribe_text?(text) + return false if text.blank? + + text.downcase.strip.eql?(I18n.t('adapter.whats_app.subscribe.text')) + end + + def create_message? + has_non_text_content = message.files.any? || message.unknown_content + text = message.text + has_non_text_content || (message.text.present? && !quick_reply_response?(text) && !unsubscribe_text?(text) && !subscribe_text?(text)) + end + + def fetch_file(file_id) + url = URI.parse("#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/media/#{file_id}") + headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' } + request = Net::HTTP::Get.new(url.to_s, headers) + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + response.body + end + end +end diff --git a/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb new file mode 100644 index 000000000..d1f24da0a --- /dev/null +++ b/app/adapters/whats_app_adapter/three_sixty_dialog_outbound.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength +module WhatsAppAdapter + class ThreeSixtyDialogOutbound + class << self + def send!(message) + recipient = message&.recipient + return unless contributor_can_receive_messages?(recipient) + + if freeform_message_permitted?(recipient) + send_message(recipient, message) + else + send_message_template(recipient, message) + end + end + + def send_welcome_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + welcome_message = I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) + payload = if freeform_message_permitted?(contributor) + text_payload(contributor, welcome_message) + else + welcome_message_payload(contributor) + end + WhatsAppAdapter::Outbound::ThreeSixtyDialogText.perform_later(payload: payload) + end + + def send_unsupported_content_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + text = I18n.t('adapter.whats_app.unsupported_content_template', first_name: contributor.first_name, + contact_person: contributor.organization.contact_person.name) + WhatsAppAdapter::Outbound::ThreeSixtyDialogText.perform_later(payload: text_payload(contributor, text)) + end + + def send_more_info_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + text = [Setting.about, "_#{I18n.t('adapter.whats_app.unsubscribe.instructions')}_"].join("\n\n") + WhatsAppAdapter::Outbound::ThreeSixtyDialogText.perform_later(payload: text_payload(contributor, text)) + end + + def send_unsubsribed_successfully_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + text = [I18n.t('adapter.whats_app.unsubscribe.successful'), + "_#{I18n.t('adapter.whats_app.subscribe.instructions')}_"].join("\n\n") + WhatsAppAdapter::Outbound::ThreeSixtyDialogText.perform_later(payload: text_payload(contributor, text)) + end + + private + + def contributor_can_receive_messages?(recipient) + recipient&.whats_app_phone_number.present? + end + + def time_of_day + current_time = Time.current + morning = current_time.change(hour: 6) + day = current_time.change(hour: 11) + evening = current_time.change(hour: 17) + night = current_time.change(hour: 23) + + case current_time + when morning..day + 'morning' + when day..evening + 'day' + when evening..night + 'evening' + else + 'night' + end + end + + def freeform_message_permitted?(recipient) + responding_to_template_message = recipient.whats_app_message_template_responded_at.present? && + recipient.whats_app_message_template_responded_at > 24.hours.ago + latest_message_received_within_last_24_hours = recipient.replies.first&.created_at.present? && + recipient.replies.first.created_at > 24.hours.ago + responding_to_template_message || latest_message_received_within_last_24_hours + end + + def send_message_template(recipient, message) + recipient.update(whats_app_message_template_sent_at: Time.current) + WhatsAppAdapter::Outbound::ThreeSixtyDialogText.perform_later(payload: new_request_payload(recipient, message.request)) + end + + def send_message(recipient, message) + files = message.files + + if files.blank? + WhatsAppAdapter::Outbound::ThreeSixtyDialogText.perform_later(payload: text_payload(recipient, message.text)) + else + files.each do |_file| + WhatsAppAdapter::UploadFile.perform_later(message_id: message.id) + end + end + end + + # rubocop:disable Metrics/MethodLength + def new_request_payload(recipient, request) + { + recipient_type: 'individual', + to: recipient.whats_app_phone_number.split('+').last, + type: 'template', + template: { + namespace: Setting.three_sixty_dialog_whats_app_template_namespace, + language: { + policy: 'deterministic', + code: 'de' + }, + name: "new_request_#{time_of_day}_#{rand(1..3)}", + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + text: recipient.first_name + }, + { + type: 'text', + text: request.title + } + ] + } + ] + } + } + end + # rubocop:enable Metrics/MethodLength + + def text_payload(recipient, text) + { + recipient_type: 'individual', + to: recipient.whats_app_phone_number.split('+').last, + type: 'text', + text: { + body: text + } + } + end + + def welcome_message_payload(recipient) + { + recipient_type: 'individual', + to: recipient.whats_app_phone_number.split('+').last, + type: 'template', + template: { + namespace: Setting.three_sixty_dialog_whats_app_template_namespace, + language: { + policy: 'deterministic', + code: 'de' + }, + name: 'welcome_message', + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + text: Setting.project_name + } + ] + } + ] + } + } + end + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/adapters/whats_app_adapter/inbound.rb b/app/adapters/whats_app_adapter/twilio_inbound.rb similarity index 91% rename from app/adapters/whats_app_adapter/inbound.rb rename to app/adapters/whats_app_adapter/twilio_inbound.rb index c733676bb..a5c9d0b05 100644 --- a/app/adapters/whats_app_adapter/inbound.rb +++ b/app/adapters/whats_app_adapter/twilio_inbound.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true module WhatsAppAdapter - UNKNOWN_CONTRIBUTOR = :unknown_contributor - UNSUPPORTED_CONTENT = :unsupported_content - REQUEST_FOR_MORE_INFO = :request_for_more_info - REQUEST_TO_RECEIVE_MESSAGE = :request_to_receive_message - UNSUBSCRIBE_CONTRIBUTOR = :unsubscribe_contributor - SUBSCRIBE_CONTRIBUTOR = :subscribe_contributor - - class Inbound - SUPPORTED_ATTACHMENT_TYPES = %w[image/jpg image/jpeg image/png image/gif audio/ogg video/mp4].freeze + class TwilioInbound + UNKNOWN_CONTRIBUTOR = :unknown_contributor + UNSUPPORTED_CONTENT = :unsupported_content + REQUEST_FOR_MORE_INFO = :request_for_more_info + REQUEST_TO_RECEIVE_MESSAGE = :request_to_receive_message + UNSUBSCRIBE_CONTRIBUTOR = :unsubscribe_contributor + SUBSCRIBE_CONTRIBUTOR = :subscribe_contributor UNSUPPORTED_CONTENT_TYPES = %w[application text/vcard latitude longitude].freeze attr_reader :sender, :text, :message diff --git a/app/adapters/whats_app_adapter/twilio_outbound.rb b/app/adapters/whats_app_adapter/twilio_outbound.rb new file mode 100644 index 000000000..a24705e29 --- /dev/null +++ b/app/adapters/whats_app_adapter/twilio_outbound.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module WhatsAppAdapter + class TwilioOutbound + class << self + def send!(message) + recipient = message&.recipient + return unless contributor_can_receive_messages?(recipient) + + if freeform_message_permitted?(recipient) + send_message(recipient, message) + else + send_message_template(recipient, message) + end + end + + def send_welcome_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + welcome_message = I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) + WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: welcome_message) + end + + def send_unsupported_content_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + text = I18n.t('adapter.whats_app.unsupported_content_template', first_name: contributor.first_name, + contact_person: contributor.organization.contact_person.name) + WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text) + end + + def send_more_info_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + text = [Setting.about, "_#{I18n.t('adapter.whats_app.unsubscribe.instructions')}_"].join("\n\n") + WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text) + end + + def send_unsubsribed_successfully_message!(contributor) + return unless contributor_can_receive_messages?(contributor) + + text = [I18n.t('adapter.whats_app.unsubscribe.successful'), "_#{I18n.t('adapter.whats_app.subscribe.instructions')}_"].join("\n\n") + WhatsAppAdapter::Outbound::Text.perform_later(recipient: contributor, text: text) + end + + private + + def contributor_can_receive_messages?(recipient) + recipient&.whats_app_phone_number.present? + end + + def time_of_day + current_time = Time.current + morning = current_time.change(hour: 6) + day = current_time.change(hour: 11) + evening = current_time.change(hour: 17) + night = current_time.change(hour: 23) + + case current_time + when morning..day + 'morning' + when day..evening + 'day' + when evening..night + 'evening' + else + 'night' + end + end + + def freeform_message_permitted?(recipient) + responding_to_template_message = recipient.whats_app_message_template_responded_at.present? && + recipient.whats_app_message_template_responded_at > 24.hours.ago + latest_message_received_within_last_24_hours = recipient.replies.first&.created_at.present? && + recipient.replies.first.created_at > 24.hours.ago + responding_to_template_message || latest_message_received_within_last_24_hours + end + + def send_message_template(recipient, message) + recipient.update(whats_app_message_template_sent_at: Time.current) + text = I18n.t("adapter.whats_app.request_template.new_request_#{time_of_day}_#{rand(1..3)}", first_name: recipient.first_name, + request_title: message.request.title) + WhatsAppAdapter::Outbound::Text.perform_later(recipient: recipient, text: text) + end + + def send_message(recipient, message) + files = message.files + + if files.blank? + WhatsAppAdapter::Outbound::Text.perform_later(recipient: recipient, text: message.text) + else + files.each_with_index do |file, index| + WhatsAppAdapter::Outbound::File.perform_later(recipient: recipient, text: index.zero? ? message.text : '', file: file) + end + end + end + end + end +end diff --git a/app/components/whats_app_setup/whats_app_setup.css b/app/components/whats_app_setup/whats_app_setup.css new file mode 100644 index 000000000..9bd647e90 --- /dev/null +++ b/app/components/whats_app_setup/whats_app_setup.css @@ -0,0 +1,20 @@ +.WhatsAppSetup { + display: flex; + align-items: center; +} + +.WhatsAppSetup-header { + flex-basis: 50%; +} + +.WhatsAppSetup-openModalButton > svg { + margin-right: var(--spacing-unit-xs); + width: var(--spacing-unit); + color: var(--color-primary); +} + +.WhatsAppSetup-openModalButton { + display: flex; + align-items: center; + margin-left: var(--spacing-unit); +} diff --git a/app/components/whats_app_setup/whats_app_setup.html.erb b/app/components/whats_app_setup/whats_app_setup.html.erb new file mode 100644 index 000000000..12bd11f7b --- /dev/null +++ b/app/components/whats_app_setup/whats_app_setup.html.erb @@ -0,0 +1,20 @@ +<%= c 'section', + styles: [:wide, :xlargeMarginTop, :noSpaceBetween], + **attrs, + data: { controller: 'whats-app-setup', + whats_app_setup_permissions_url_value: permissions_url + } do %> +
+ <%= c 'heading', style: :beta do %> + <%= t('.heading') %> + <% end %> +

<%= t('.setup_explained') %>

+
+ + <%= c 'button', + data: { action: 'whats-app-setup#openModal' }, + class: 'Button--secondary WhatsAppSetup-openModalButton' do %> + <%= svg 'plus-sign' %> + <%= t('.open_modal_button') %> + <% end %> +<% end %> diff --git a/app/components/whats_app_setup/whats_app_setup.js b/app/components/whats_app_setup/whats_app_setup.js new file mode 100644 index 000000000..82b2be0c6 --- /dev/null +++ b/app/components/whats_app_setup/whats_app_setup.js @@ -0,0 +1,17 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + permissionsUrl: String, + }; + + openModal() { + const windowFeatures = + 'toolbar=no, menubar=no, width=600, height=900, top=100, left=100'; + open( + this.permissionsUrlValue, + 'integratedOnboardingWindow', + windowFeatures + ); + } +} diff --git a/app/components/whats_app_setup/whats_app_setup.rb b/app/components/whats_app_setup/whats_app_setup.rb new file mode 100644 index 000000000..69d70ecfe --- /dev/null +++ b/app/components/whats_app_setup/whats_app_setup.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module WhatsAppSetup + class WhatsAppSetup < ApplicationComponent + private + + def permissions_url + "https://hub.360dialog.com/dashboard/app/#{ENV.fetch('THREE_SIXTY_DIALOG_PARTNER_ID', '')}/permissions?redirect_url=#{CGI.escape(whats_app_onboarding_successful_url)}" + end + end +end diff --git a/app/controllers/concerns/whats_app_handle_callbacks.rb b/app/controllers/concerns/whats_app_handle_callbacks.rb new file mode 100644 index 000000000..117f6ec41 --- /dev/null +++ b/app/controllers/concerns/whats_app_handle_callbacks.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module WhatsAppHandleCallbacks + extend ActiveSupport::Concern + + private + + def handle_unknown_contributor(whats_app_phone_number) + exception = WhatsAppAdapter::UnknownContributorError.new(whats_app_phone_number: whats_app_phone_number) + ErrorNotifier.report(exception) + end + + def handle_request_for_more_info(contributor) + contributor.update!(whats_app_message_template_responded_at: Time.current) + + WhatsAppAdapter::Outbound.send_more_info_message!(contributor) + end + + def handle_unsubsribe_contributor(contributor) + contributor.update!(deactivated_at: Time.current) + + WhatsAppAdapter::Outbound.send_unsubsribed_successfully_message!(contributor) + ContributorMarkedInactive.with(contributor_id: contributor.id).deliver_later(User.all) + User.admin.find_each do |admin| + PostmarkAdapter::Outbound.contributor_marked_as_inactive!(admin, contributor) + end + end + + def handle_subscribe_contributor(contributor) + contributor.update!(deactivated_at: nil, whats_app_message_template_responded_at: Time.current) + + WhatsAppAdapter::Outbound.send_welcome_message!(contributor) + ContributorSubscribed.with(contributor_id: contributor.id).deliver_later(User.all) + User.admin.find_each do |admin| + PostmarkAdapter::Outbound.contributor_subscribed!(admin, contributor) + end + end +end diff --git a/app/controllers/onboarding/whats_app_controller.rb b/app/controllers/onboarding/whats_app_controller.rb index 58dbcfb18..ea550ff00 100644 --- a/app/controllers/onboarding/whats_app_controller.rb +++ b/app/controllers/onboarding/whats_app_controller.rb @@ -11,7 +11,7 @@ def attr_name end def ensure_whats_app_is_set_up - return if Setting.whats_app_server_phone_number.present? + return if Setting.whats_app_server_phone_number.present? || Setting.three_sixty_dialog_client_api_key.present? raise ActionController::RoutingError, 'Not Found' end diff --git a/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb b/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb new file mode 100644 index 000000000..c1870d387 --- /dev/null +++ b/app/controllers/whats_app/three_sixty_dialog_webhook_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module WhatsApp + class ThreeSixtyDialogWebhookController < ApplicationController + include WhatsAppHandleCallbacks + + skip_before_action :require_login, :verify_authenticity_token + + # rubocop:disable Metrics/AbcSize + def message + head :ok + return if params['statuses'].present? # TODO: Do we want to handle statuses? + + handle_error(params['messages'].first['errors'].first) if params['messages'].first['errors'].present? + + adapter = WhatsAppAdapter::ThreeSixtyDialogInbound.new + + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::UNKNOWN_CONTRIBUTOR) do |whats_app_phone_number| + handle_unknown_contributor(whats_app_phone_number) + end + + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::REQUEST_FOR_MORE_INFO) do |contributor| + handle_request_for_more_info(contributor) + end + + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::REQUEST_TO_RECEIVE_MESSAGE) do |contributor| + handle_request_to_receive_message(contributor) + end + + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::UNSUPPORTED_CONTENT) do |contributor| + WhatsAppAdapter::ThreeSixtyDialogOutbound.send_unsupported_content_message!(contributor) + end + + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::UNSUBSCRIBE_CONTRIBUTOR) do |contributor| + handle_unsubsribe_contributor(contributor) + end + + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::SUBSCRIBE_CONTRIBUTOR) do |contributor| + handle_subscribe_contributor(contributor) + end + + adapter.consume(message_params.to_h) { |message| message.contributor.reply(adapter) } + end + # rubocop:enable Metrics/AbcSize + + def create_api_key + channel_ids = create_api_key_params[:channels].split('[').last.split(']').last.split(',') + client_id = create_api_key_params[:client] + Setting.three_sixty_dialog_client_id = client_id + channel_ids.each do |channel_id| + WhatsAppAdapter::CreateApiKey.perform_later(channel_id: channel_id) + end + render 'onboarding/success' + end + + private + + def message_params + params.permit({ three_sixty_dialog_webhook: + [contacts: [:wa_id, { profile: [:name] }], + messages: [:from, :id, :type, :timestamp, { text: [:body] }, { context: %i[from id] }, + { button: [:text] }, { image: %i[id mime_type sha256 caption] }, + { voice: %i[id mime_type sha256] }, { video: %i[id mime_type sha256 caption] }, + { audio: %i[id mime_type sha256] }, { errors: %i[code details title] }, + { document: %i[filename id mime_type sha256] }, { location: %i[latitude longitude] }, + { contacts: [{ org: {} }, { addresses: [] }, { emails: [] }, { ims: [] }, + { phones: %i[phone type wa_id] }, { urls: [] }, + { name: %i[first_name formatted_name last_name] }] }]] }, + contacts: [:wa_id, { profile: [:name] }], + messages: [:from, :id, :type, :timestamp, { text: [:body] }, { context: %i[from id] }, + { button: [:text] }, { image: %i[id mime_type sha256 caption] }, + { voice: %i[id mime_type sha256] }, { video: %i[id mime_type sha256 caption] }, + { audio: %i[id mime_type sha256] }, { errors: %i[code details title] }, + { document: %i[filename id mime_type sha256] }, { location: %i[latitude longitude] }, + { contacts: [{ org: {} }, { addresses: [] }, { emails: [] }, { ims: [] }, + { phones: %i[phone type wa_id] }, { urls: [] }, + { name: %i[first_name formatted_name last_name] }] }]) + end + + def create_api_key_params + params.permit(:client, :channels, :revoked) + end + + def handle_error(error) + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: error['code'], message: error['title']) + ErrorNotifier.report(exception, context: { details: error['details'] }) + end + + def handle_request_to_receive_message(contributor) + contributor.update!(whats_app_message_template_responded_at: Time.current, whats_app_message_template_sent_at: nil) + + WhatsAppAdapter::ThreeSixtyDialogOutbound.send!(contributor.received_messages.first) + end + end +end diff --git a/app/controllers/whats_app/webhook_controller.rb b/app/controllers/whats_app/webhook_controller.rb index 07dd3f3d2..ee5dc94a3 100644 --- a/app/controllers/whats_app/webhook_controller.rb +++ b/app/controllers/whats_app/webhook_controller.rb @@ -2,33 +2,35 @@ module WhatsApp class WebhookController < ApplicationController + include WhatsAppHandleCallbacks + skip_before_action :require_login, :verify_authenticity_token UNSUCCESSFUL_DELIVERY = %w[undelivered failed].freeze def message - adapter = WhatsAppAdapter::Inbound.new + adapter = WhatsAppAdapter::TwilioInbound.new - adapter.on(WhatsAppAdapter::UNKNOWN_CONTRIBUTOR) do |whats_app_phone_number| + adapter.on(WhatsAppAdapter::TwilioInbound::UNKNOWN_CONTRIBUTOR) do |whats_app_phone_number| handle_unknown_contributor(whats_app_phone_number) end - adapter.on(WhatsAppAdapter::REQUEST_FOR_MORE_INFO) do |contributor| + adapter.on(WhatsAppAdapter::TwilioInbound::REQUEST_FOR_MORE_INFO) do |contributor| handle_request_for_more_info(contributor) end - adapter.on(WhatsAppAdapter::REQUEST_TO_RECEIVE_MESSAGE) do |contributor, twilio_message_sid| + adapter.on(WhatsAppAdapter::TwilioInbound::REQUEST_TO_RECEIVE_MESSAGE) do |contributor, twilio_message_sid| handle_request_to_receive_message(contributor, twilio_message_sid) end - adapter.on(WhatsAppAdapter::UNSUPPORTED_CONTENT) do |contributor| - WhatsAppAdapter::Outbound.send_unsupported_content_message!(contributor) + adapter.on(WhatsAppAdapter::TwilioInbound::UNSUPPORTED_CONTENT) do |contributor| + WhatsAppAdapter::TwilioOutbound.send_unsupported_content_message!(contributor) end - adapter.on(WhatsAppAdapter::UNSUBSCRIBE_CONTRIBUTOR) do |contributor| + adapter.on(WhatsAppAdapter::TwilioInbound::UNSUBSCRIBE_CONTRIBUTOR) do |contributor| handle_unsubsribe_contributor(contributor) end - adapter.on(WhatsAppAdapter::SUBSCRIBE_CONTRIBUTOR) do |contributor| + adapter.on(WhatsAppAdapter::TwilioInbound::SUBSCRIBE_CONTRIBUTOR) do |contributor| handle_subscribe_contributor(contributor) end @@ -82,37 +84,11 @@ def handle_unknown_contributor(whats_app_phone_number) ErrorNotifier.report(exception) end - def handle_request_for_more_info(contributor) - contributor.update!(whats_app_message_template_responded_at: Time.current) - - WhatsAppAdapter::Outbound.send_more_info_message!(contributor) - end - def handle_request_to_receive_message(contributor, twilio_message_sid) contributor.update!(whats_app_message_template_responded_at: Time.current, whats_app_message_template_sent_at: nil) message = (send_requested_message(contributor, twilio_message_sid) if twilio_message_sid) - WhatsAppAdapter::Outbound.send!(message || contributor.received_messages.first) - end - - def handle_unsubsribe_contributor(contributor) - contributor.update!(deactivated_at: Time.current) - - WhatsAppAdapter::Outbound.send_unsubsribed_successfully_message!(contributor) - ContributorMarkedInactive.with(contributor_id: contributor.id).deliver_later(User.all) - User.admin.find_each do |admin| - PostmarkAdapter::Outbound.contributor_marked_as_inactive!(admin, contributor) - end - end - - def handle_subscribe_contributor(contributor) - contributor.update!(deactivated_at: nil, whats_app_message_template_responded_at: Time.current) - - WhatsAppAdapter::Outbound.send_welcome_message!(contributor) - ContributorSubscribed.with(contributor_id: contributor.id).deliver_later(User.all) - User.admin.find_each do |admin| - PostmarkAdapter::Outbound.contributor_subscribed!(admin, contributor) - end + WhatsAppAdapter::TwilioOutbound.send!(message || contributor.received_messages.first) end def send_requested_message(contributor, twilio_message_sid) diff --git a/app/jobs/whats_app_adapter/create_api_key.rb b/app/jobs/whats_app_adapter/create_api_key.rb new file mode 100644 index 000000000..1e4808f94 --- /dev/null +++ b/app/jobs/whats_app_adapter/create_api_key.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'net/http' + +module WhatsAppAdapter + class CreateApiKey < ApplicationJob + def perform(channel_id:) + @base_uri = Setting.three_sixty_dialog_partner_rest_api_endpoint + + token = Setting.find_by(var: 'three_sixty_dialog_partner_token') + fetch_token unless token&.value && token.updated_at > 24.hours.ago + partner_id = Setting.three_sixty_dialog_partner_id + url = URI.parse( + "#{base_uri}/partners/#{partner_id}/channels/#{channel_id}/api_keys" + ) + headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{Setting.three_sixty_dialog_partner_token}" + } + request = Net::HTTP::Post.new(url.to_s, headers) + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + + private + + attr_reader :base_uri + + def fetch_token + url = URI.parse("#{base_uri}/token") + headers = { + 'Content-Type': 'application/json' + } + request = Net::HTTP::Post.new(url.to_s, headers) + request.body = { + username: Setting.three_sixty_dialog_partner_username, + password: Setting.three_sixty_dialog_partner_password + }.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + token = JSON.parse(response.body)['access_token'] + setting = Setting.find_or_initialize_by(var: :three_sixty_dialog_partner_token) + setting.value = token + setting.save + end + + def handle_response(response) + case response.code.to_i + when 201 + api_key = JSON.parse(response.body)['api_key'] + setting = Setting.find_or_initialize_by(var: :three_sixty_dialog_client_api_key) + setting.value = api_key + setting.save + WhatsAppAdapter::SetWebhookUrl.perform_later + when 400..599 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) + ErrorNotifier.report(exception) + end + end + end +end diff --git a/app/jobs/whats_app_adapter/create_template.rb b/app/jobs/whats_app_adapter/create_template.rb new file mode 100644 index 000000000..957515f7e --- /dev/null +++ b/app/jobs/whats_app_adapter/create_template.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'net/http' + +# rubocop:disable Metrics/ClassLength +module WhatsAppAdapter + class CreateTemplate < ApplicationJob + def perform(template_name:, template_text:) + @base_uri = Setting.three_sixty_dialog_partner_rest_api_endpoint + @partner_id = Setting.three_sixty_dialog_partner_id + @template_name = template_name + @template_text = template_text + + @token = Setting.find_by(var: 'three_sixty_dialog_partner_token') + @token = fetch_token unless token&.value && token.updated_at > 24.hours.ago + + @waba_account_id = Setting.three_sixty_dialog_client_waba_account_id + waba_accont_namespace = Setting.three_sixty_dialog_whats_app_template_namespace + @waba_account_id = fetch_client_info if waba_account_id.blank? || waba_accont_namespace.blank? + + conditionally_create_template + end + + attr_reader :base_uri, :partner_id, :template_name, :template_text, :token, :waba_account_id + + private + + def conditionally_create_template + url = URI.parse( + "#{base_uri}/partners/#{partner_id}/waba_accounts/#{waba_account_id}/waba_templates" + ) + headers = set_headers + request = Net::HTTP::Get.new(url.to_s, headers) + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + waba_templates = JSON.parse(response.body)['waba_templates'] + template_names_array = waba_templates.pluck('name') + return if template_name.in?(template_names_array) + + create_template + end + + def create_template + url = URI.parse( + "#{base_uri}/partners/#{partner_id}/waba_accounts/#{waba_account_id}/waba_templates" + ) + headers = set_headers + + request = Net::HTTP::Post.new(url.to_s, headers) + payload = template_name.match?(/welcome_message/) ? welcome_message_template_payload : new_request_template_payload + request.body = payload.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + + def set_headers + { + 'Content-Type': 'application/json', + Authorization: "Bearer #{Setting.three_sixty_dialog_partner_token}" + } + end + + def fetch_token + url = URI.parse("#{base_uri}/token") + headers = { 'Content-Type': 'application/json' } + request = Net::HTTP::Post.new(url.to_s, headers) + request.body = { + username: Setting.three_sixty_dialog_partner_username, + password: Setting.three_sixty_dialog_partner_password + }.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + token = JSON.parse(response.body)['access_token'] + Setting.three_sixty_dialog_partner_token = token + end + + def fetch_client_info + url = URI.parse("#{base_uri}/partners/#{partner_id}/channels") + headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{Setting.three_sixty_dialog_partner_token}" + } + request = Net::HTTP::Get.new(url.to_s, headers) + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + channels_array = JSON.parse(response.body)['partner_channels'] + client_hash = channels_array.find { |hash| hash['client']['id'] == Setting.three_sixty_dialog_client_id } + waba_account = client_hash['waba_account'] + Setting.three_sixty_dialog_whats_app_template_namespace = waba_account['namespace'] + + Setting.three_sixty_dialog_client_waba_account_id = waba_account['id'] + end + + # rubocop:disable Metrics/MethodLength + def new_request_template_payload + { + name: template_name, + category: 'MARKETING', + components: [ + { + type: 'BODY', + text: template_text, + example: { + body_text: [ + [ + 'Jakob', + 'Familie und Freizeit' + ] + ] + } + }, + { + type: 'BUTTONS', + buttons: [ + { + type: 'QUICK_REPLY', + text: 'Antworten' + }, + { + type: 'QUICK_REPLY', + text: 'Mehr Infos' + } + ] + } + ], + language: 'de', + allow_category_change: true + } + end + # rubocop:enable Metrics/MethodLength + + def welcome_message_template_payload + { + name: template_name, + category: 'MARKETING', + components: [ + { + type: 'BODY', + text: template_text, + example: { + body_text: [ + ['100eyes'] + ] + } + } + ], + language: 'de', + allow_category_change: true + } + end + + def handle_response(response) + case response.code.to_i + when 201 + Rails.logger.debug 'Great!' + when 400..599 + return if response.body.match?(/you have provided is already in use. Please choose a different name for your template./) + + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) + ErrorNotifier.report(exception) + end + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/jobs/whats_app_adapter/set_webhook_url.rb b/app/jobs/whats_app_adapter/set_webhook_url.rb new file mode 100644 index 000000000..3e72db58c --- /dev/null +++ b/app/jobs/whats_app_adapter/set_webhook_url.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'net/http' + +module WhatsAppAdapter + class SetWebhookUrl < ApplicationJob + def perform + return if Setting.three_sixty_dialog_client_api_key.blank? + + base_uri = Setting.three_sixty_dialog_whats_app_rest_api_endpoint + url = URI.parse("#{base_uri}/configs/webhook") + headers = { 'D360-API-KEY' => Setting.three_sixty_dialog_client_api_key, 'Content-Type' => 'application/json' } + request = Net::HTTP::Post.new(url.to_s, headers) + + request.body = { url: "https://#{Setting.application_host}/whats_app/three-sixty-dialog-webhook" }.to_json + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + + private + + def handle_response(response) + case response.code.to_i + when 201 + Rails.logger.debug 'Great!' + when 400..599 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) + ErrorNotifier.report(exception) + end + end + end +end diff --git a/app/jobs/whats_app_adapter/upload_file.rb b/app/jobs/whats_app_adapter/upload_file.rb new file mode 100644 index 000000000..1d682f5f3 --- /dev/null +++ b/app/jobs/whats_app_adapter/upload_file.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'net/http' + +module WhatsAppAdapter + class UploadFile < ApplicationJob + def perform(message_id:) + return if Setting.three_sixty_dialog_client_api_key.blank? + + @message_id = message_id + message = Message.find(message_id) + request = message.request + + request.files.each do |file| + base_uri = Setting.three_sixty_dialog_whats_app_rest_api_endpoint + url = URI.parse("#{base_uri}/media") + headers = { + 'D360-API-KEY': Setting.three_sixty_dialog_client_api_key, + 'Content-Type': file.blob.content_type + } + request = Net::HTTP::Post.new(url.to_s, headers) + request.body = File.read(ActiveStorage::Blob.service.path_for(file.blob.key)) + response = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http| + http.request(request) + end + handle_response(response) + end + end + + private + + attr_reader :message_id + + def handle_response(response) + case response.code.to_i + when 201 + file_id = JSON.parse(response.body)['media'].first['id'] + WhatsAppAdapter::Outbound::ThreeSixtyDialogFile.perform_later(message_id: message_id, file_id: file_id) + when 400..599 + exception = WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: response.code, message: response.body) + ErrorNotifier.report(exception) + end + end + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index a4f5bbe77..908526793 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -72,6 +72,20 @@ def self.onboarding_hero=(blob) field :twilio_api_key_secret, readonly: true, default: ENV.fetch('TWILIO_API_KEY_SECRET', nil) field :whats_app_server_phone_number, readonly: true, default: ENV.fetch('WHATS_APP_SERVER_PHONE_NUMBER', nil) + field :three_sixty_dialog_partner_token, default: '' + field :three_sixty_dialog_partner_id, readonly: true, default: ENV.fetch('THREE_SIXTY_DIALOG_PARTNER_ID', nil) + field :three_sixty_dialog_partner_username, readonly: true, default: ENV.fetch('THREE_SIXTY_DIALOG_PARTNER_USERNAME', nil) + field :three_sixty_dialog_partner_password, readonly: true, default: ENV.fetch('THREE_SIXTY_DIALOG_PARTNER_PASSWORD', nil) + field :three_sixty_dialog_partner_rest_api_endpoint, readonly: true, default: ENV.fetch('THREE_SIXTY_DIALOG_PARTNER_REST_API_ENDPOINT', 'https://stoplight.io/mocks/360dialog/360dialog-partner-api/24588693') + + field :three_sixty_dialog_client_api_key, default: '' + field :three_sixty_dialog_client_id, default: '' + field :three_sixty_dialog_client_waba_account_id, default: '' + + field :three_sixty_dialog_whats_app_rest_api_endpoint, readonly: true, + default: ENV.fetch('THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT', 'https://waba-sandbox.360dialog.io') + field :three_sixty_dialog_whats_app_template_namespace + field :inbound_email_password, readonly: true, default: ENV.fetch('RAILS_INBOUND_EMAIL_PASSWORD', nil) field :email_from_address, readonly: true, default: ENV['EMAIL_FROM_ADDRESS'] || 'redaktion@localhost' field :postmark_api_token, readonly: true, default: ENV.fetch('POSTMARK_API_TOKEN', nil) diff --git a/app/views/profile/index.html.erb b/app/views/profile/index.html.erb index f4b19fa81..4f107089f 100644 --- a/app/views/profile/index.html.erb +++ b/app/views/profile/index.html.erb @@ -2,4 +2,5 @@ <%= c 'profile_header', organization: @organization, business_plans: @business_plans %> <%= c 'user_management', organization: @organization %> <%= c 'profile_contributors_section', organization: @organization %> + <%= c 'whats_app_setup' %> <% end %> diff --git a/app/views/whats_app/onboarding/success.html.erb b/app/views/whats_app/onboarding/success.html.erb new file mode 100644 index 000000000..85a5dc30c --- /dev/null +++ b/app/views/whats_app/onboarding/success.html.erb @@ -0,0 +1,5 @@ +<%= c 'onboarding_response', + style: :success, + heading: Setting.onboarding_success_heading, project_name: Setting.project_name, + text: Setting.onboarding_success_text +%> diff --git a/config/locales/de.yml b/config/locales/de.yml index db950b17a..fac1fda1b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -353,6 +353,10 @@ de: empty_state: no_contributors: Du hast noch keine Mitglieder, verschick du Einladungslinks. filter_active: Keine Mitglieder mit diesen Filtern gefunden + whats_app_setup: + heading: WhatsApp-Integration + setup_explained: Erteilen Sie unserem Business Solutions Provider, 360dialog, die Berechtigung, Ihren WhatsApp Business Manager zu verwalten, um WhatsApp als Kanal hinzuzufügen. + open_modal_button: WhatsApp einrichten onboarding_channels_checkboxes: legend: Aktive Onboarding-Channel help_text: Achtung, hier deaktivieren sie Kanäle. Teilnehmerinnen können sich nur noch auf aktiven Kanälen anmelden. diff --git a/config/routes.rb b/config/routes.rb index 1410bf566..fe4cf5af6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,6 +48,8 @@ post '/webhook', to: 'webhook#message' post '/errors', to: 'webhook#errors' post '/status', to: 'webhook#status' + get '/onboarding-successful', to: 'three_sixty_dialog_webhook#create_api_key' + post '/three-sixty-dialog-webhook', to: 'three_sixty_dialog_webhook#message' end telegram_webhook Telegram::WebhookController diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0b4c6e301..1af465f4a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -31,6 +31,10 @@ x-prod-defaults: &x-prod-defaults TWILIO_AUTH_TOKEN: "${TWILIO_AUTH_TOKEN}" TWILIO_API_KEY_SID: "${TWILIO_API_KEY_SID}" TWILIO_API_KEY_SECRET: "${TWILIO_API_KEY_SECRET}" + THREE_SIXTY_DIALOG_PARTNER_ID: "${THREE_SIXTY_DIALOG_PARTNER_ID}" + THREE_SIXTY_DIALOG_PARTNER_USERNAME: "${THREE_SIXTY_DIALOG_PARTNER_USERNAME}" + THREE_SIXTY_DIALOG_PARTNER_PASSWORD: "${THREE_SIXTY_DIALOG_PARTNER_PASSWORD}" + volumes: - ./log:/app/log - ./storage:/app/storage diff --git a/docker-compose.yml b/docker-compose.yml index 9c777d8b9..61060eb64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ x-defaults: &x-defaults POSTGRES_PORT: "${POSTGRES_PORT:-5432}" SIGNAL_CLI_REST_API_ENDPOINT: "http://signal:8080" SIGNAL_CLI_REST_API_ATTACHMENT_PATH: "signal-cli-config/attachments/" + THREE_SIXTY_DIALOG_PARTNER_REST_API_ENDPOINT: "https://hub.360dialog.io/api/v2" + THREE_SIXTY_DIALOG_WHATS_APP_REST_API_ENDPOINT: "https://waba.360dialog.io/v1" services: app: diff --git a/lib/tasks/whats_app/create_templates.rake b/lib/tasks/whats_app/create_templates.rake new file mode 100644 index 000000000..ab3381f33 --- /dev/null +++ b/lib/tasks/whats_app/create_templates.rake @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# rubocop:disable Style/FormatStringToken +desc 'Create WhatsApp templates' +task create_whats_app_templates: :environment do + welcome_message_hash = { welcome_message: I18n.t('.')[:adapter][:whats_app][:welcome_message].gsub('%{project_name}', '{{1}}') } + requests_hash = I18n.t('.')[:adapter][:whats_app][:request_template].transform_values do |value| + value.gsub('%{first_name}', '{{1}}').gsub('%{request_title}', '{{2}}') + end + template_hash = welcome_message_hash.merge(requests_hash) + template_hash.each do |key, value| + WhatsAppAdapter::CreateTemplate.perform_later(template_name: key, template_text: value) + end +end +# rubocop:enable Style/FormatStringToken diff --git a/spec/adapters/whats_app_adapter/outbound_spec.rb b/spec/adapters/whats_app_adapter/outbound_spec.rb index 1fcd181bc..b5fb19550 100644 --- a/spec/adapters/whats_app_adapter/outbound_spec.rb +++ b/spec/adapters/whats_app_adapter/outbound_spec.rb @@ -1,136 +1,184 @@ # frozen_string_literal: true require 'rails_helper' -require 'webmock/rspec' RSpec.describe WhatsAppAdapter::Outbound do let(:adapter) { described_class.new } - let(:message) { create(:message, text: 'WhatsApp as a channel is great, no?', broadcasted: true, recipient: contributor) } + let!(:message) { create(:message, text: 'Tell me your favorite color, and why.', broadcasted: true, recipient: contributor) } let(:contributor) { create(:contributor, email: nil) } - describe '::send_welcome_message!' do - let(:expected_job_args) do - { recipient: contributor, text: I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) } + describe '::send!' do + subject { -> { described_class.send!(message) } } + + context 'with 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return('valid_api_key') + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send!) + end + + it 'it is expected to send the message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send!).with(message) + + subject.call + end + + it 'it is expected not to send it with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).not_to receive(:send!) + + subject.call + end end + + context 'without 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return(nil) + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send!) + end + + it 'it is expected not to send the message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).not_to receive(:send!) + + subject.call + end + + it 'it is expected to send it with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).to receive(:send!).with(message) + + subject.call + end + end + end + + describe '::send_welcome_message!' do subject { -> { described_class.send_welcome_message!(contributor) } } - before { message } # we don't count the extra ::send here - it { should_not enqueue_job(described_class::Text) } + context 'with 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return('valid_api_key') + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_welcome_message!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send_welcome_message!) + end + + it 'it is expected to send the welcome message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_welcome_message!).with(contributor) - context 'contributor has a phone number' do - let(:contributor) do - create( - :contributor, - whats_app_phone_number: '+491511234567', - email: nil - ) + subject.call end - it { should enqueue_job(described_class::Text).with(expected_job_args) } + it 'it is expected not to send the welcome message with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).not_to receive(:send_welcome_message!) + + subject.call + end + end + + context 'without 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return(nil) + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_welcome_message!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send_welcome_message!) + end + + it 'it is expected not to send the welcome message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).not_to receive(:send_welcome_message!) + + subject.call + end + + it 'it is expected to send the welcome message with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).to receive(:send_welcome_message!).with(contributor) + + subject.call + end end end - describe '::send!' do - subject { -> { described_class.send!(message) } } - before { message } # we don't count the extra ::send here + describe '::send_more_info_message!' do + subject { -> { described_class.send_more_info_message!(contributor) } } + + context 'with 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return('valid_api_key') + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_more_info_message!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send_more_info_message!) + end + + it 'it is expected to send the more info message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_more_info_message!).with(contributor) + + subject.call + end - context '`whats_app_phone_number` blank' do - it { should_not enqueue_job(described_class::Text) } + it 'it is expected not to send the more info message with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).not_to receive(:send_more_info_message!) + + subject.call + end end - context 'given a WhatsApp contributor' do - let(:contributor) do - create( - :contributor, - email: nil, - whats_app_phone_number: '+491511234567' - ) - end - - describe 'contributor has not sent a message within 24 hours' do - it 'enqueues the Text job with WhatsApp template' do - expect { subject.call }.to(have_enqueued_job(described_class::Text).on_queue('default').with do |params| - expect(params[:recipient]).to eq(contributor) - expect(params[:text]).to include(contributor.first_name) - expect(params[:text]).to include(message.request.title) - end) - end - end - - describe 'contributor has responded to a template' do - before { contributor.update(whats_app_message_template_responded_at: Time.current) } - - it 'enqueues the Text job with the request text' do - expect { subject.call }.to(have_enqueued_job(described_class::Text).on_queue('default').with do |params| - expect(params[:recipient]).to eq(contributor) - expect(params[:text]).to eq(message.text) - end) - end - end - - describe 'contributor has sent a reply within 24 hours' do - before { create(:message, sender: contributor) } - - it 'enqueues the Text job with the request text' do - expect { subject.call }.to(have_enqueued_job(described_class::Text).on_queue('default').with do |params| - expect(params[:recipient]).to eq(contributor) - expect(params[:text]).to eq(message.text) - end) - end - end - - describe 'message with files' do - let(:file) { create(:file) } - before { message.update(files: [file]) } - - context 'contributor has not sent a message within 24 hours' do - it 'enqueues the Text job with WhatsApp template' do - expect { subject.call }.to(have_enqueued_job(described_class::Text).on_queue('default').with do |params| - expect(params[:recipient]).to eq(contributor) - expect(params[:text]).to include(contributor.first_name) - expect(params[:text]).to include(message.request.title) - end) - end - end - - context 'contributor has sent a reply within 24 hours' do - before { create(:message, sender: contributor) } - it 'enqueues a File job with file, contributor, text' do - expect { subject.call }.to(have_enqueued_job(described_class::File).on_queue('default').with do |params| - expect(params[:file]).to eq(message.files.first) - expect(params[:recipient]).to eq(contributor) - expect(params[:text]).to eq(message.text) - end) - end - end + context 'without 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return(nil) + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_more_info_message!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send_more_info_message!) + end + + it 'it is expected not to send the more info message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).not_to receive(:send_more_info_message!) + + subject.call + end + + it 'it is expected to send the more info message with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).to receive(:send_more_info_message!).with(contributor) + + subject.call end end end - describe '::freeform_message_permitted?(recipient)' do - subject { described_class.freeform_message_permitted?(contributor) } + describe '::send_unsubsribed_successfully_message!' do + subject { -> { described_class.send_unsubsribed_successfully_message!(contributor) } } + + context 'with 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return('valid_api_key') + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_unsubsribed_successfully_message!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send_unsubsribed_successfully_message!) + end - describe 'template message' do - context 'contributor has responded' do - before { contributor.update(whats_app_message_template_responded_at: 1.second.ago) } + it 'it is expected to send the unsubscribed successfully message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_unsubsribed_successfully_message!).with(contributor) - it { is_expected.to eq(true) } + subject.call end - context 'contributor has not responded, and has no messages within 24 hours' do - it { is_expected.to eq(false) } + it 'it is expected not to send the unsubscribed successfully message with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).not_to receive(:send_unsubsribed_successfully_message!) + + subject.call end end - describe 'message from contributor within 24 hours' do - context 'has been received' do - before { create(:message, sender: contributor) } + context 'without 360dialog configured' do + before do + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return(nil) + allow(WhatsAppAdapter::ThreeSixtyDialogOutbound).to receive(:send_unsubsribed_successfully_message!) + allow(WhatsAppAdapter::TwilioOutbound).to receive(:send_unsubsribed_successfully_message!) + end + + it 'it is expected to send the unsubscribed successfully message with 360dialog' do + expect(WhatsAppAdapter::ThreeSixtyDialogOutbound).not_to receive(:send_unsubsribed_successfully_message!) - it { is_expected.to eq(true) } + subject.call end - context 'has not been received' do - it { is_expected.to eq(false) } + it 'it is expected not to send the unsubscribed successfully message with Twilio' do + expect(WhatsAppAdapter::TwilioOutbound).to receive(:send_unsubsribed_successfully_message!).with(contributor) + + subject.call end end end diff --git a/spec/adapters/whats_app_adapter/three_sixty_dialog_inbound_spec.rb b/spec/adapters/whats_app_adapter/three_sixty_dialog_inbound_spec.rb new file mode 100644 index 000000000..55750d2e9 --- /dev/null +++ b/spec/adapters/whats_app_adapter/three_sixty_dialog_inbound_spec.rb @@ -0,0 +1,488 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe WhatsAppAdapter::ThreeSixtyDialogInbound do + let(:adapter) { described_class.new } + let(:phone_number) { '+491511234567' } + let(:whats_app_message) do + { contacts: [{ profile: { name: 'Matthew Rider' }, + wa_id: '491511234567' }], + messages: [{ from: '491511234567', + id: 'some_valid_id', + text: { body: 'Hey' }, + timestamp: '1692118778', + type: 'text' }], + three_sixty_dialog_webhook: { contacts: [{ profile: { name: 'Matthew Rider' }, + wa_id: '491511234567' }], + messages: [{ from: '491511234567', + id: 'some_valid_id', + text: { body: 'Hey' }, + timestamp: '1692118778', + type: 'text' }] } } + end + let(:whats_app_message_with_attachment) do + { contacts: [{ profile: { name: 'Matthew Rider' }, + wa_id: '491511234567' }], + messages: [{ from: '491511234567', + id: 'some_valid_id', + image: { + caption: 'Look how cute', + id: 'some_valid_id', + mime_type: 'image/jpeg', + sha256: 'sha256_hash' + }, + timestamp: '1692118778', + type: 'image' }], + three_sixty_dialog_webhook: { contacts: [{ profile: { name: 'Matthew Rider' }, + wa_id: '491511234567' }], + messages: [{ from: '491511234567', + id: 'some_valid_id', + image: { + caption: 'Look how cute', + id: 'some_valid_id', + mime_type: 'image/jpeg', + sha256: 'sha256_hash' + }, + timestamp: '1692118778', + type: 'image' }] } } + end + + let!(:contributor) { create(:contributor, whats_app_phone_number: phone_number) } + let(:fetch_file_url) { "#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/media/some_valid_id" } + + describe '#consume' do + let(:message) do + adapter.consume(whats_app_message) do |message| + message + end + end + + describe '|message| block argument' do + subject { message } + it { is_expected.to be_a(Message) } + + context 'from an unknown contributor' do + let!(:phone_number) { '+495555555' } + + it { is_expected.to be(nil) } + end + + context 'given a message with text and an attachment' do + let(:whats_app_message) { whats_app_message_with_attachment } + + before { stub_request(:get, fetch_file_url).to_return(status: 200, body: 'downloaded_file') } + + it 'is expected to store message text and attached file' do + expect(message.text).to eq('Look how cute') + expect(message.files.first.attachment).to be_attached + end + end + end + + describe '|message|text' do + subject { message.text } + + context 'given a whats_app_message with a `message`' do + it { is_expected.to eq('Hey') } + end + + context 'given a whats_app_message without a `message` and with an attachment' do + let(:whats_app_message) { whats_app_message_with_attachment } + before do + whats_app_message[:messages].first[:image][:caption] = nil + stub_request(:get, fetch_file_url).to_return(status: 200, body: 'downloaded_file') + end + + it { is_expected.to be(nil) } + end + end + + describe '|message|raw_data' do + subject { message.raw_data } + it { is_expected.to be_attached } + end + + describe '#sender' do + subject { message.sender } + + it { is_expected.to eq(contributor) } + end + + describe '|message|files' do + let(:whats_app_message) { whats_app_message_with_attachment } + + before do + stub_request(:get, fetch_file_url).to_return(status: 200, body: 'downloaded_file') + end + + describe 'handling different content types' do + let(:file) { message.files.first } + subject { file.attachment } + + context 'given an audio file' do + before do + first_message = whats_app_message[:messages].first + first_message[:type] = 'voice' + first_message.delete(:image) + first_message[:audio] = { + id: 'some_valid_id', + mime_type: 'audio/ogg', + sha256: 'sha256_hash' + } + end + + it { is_expected.to be_attached } + + it 'preserves the content_type' do + expect(subject.blob.content_type).to eq('audio/ogg') + end + end + + context 'given an audio/mpeg file' do + before do + first_message = whats_app_message[:messages].first + first_message[:type] = 'audio' + first_message.delete(:image) + first_message[:audio] = { + id: 'some_valid_id', + mime_type: 'audio/mpeg', + sha256: 'sha256_hash' + } + end + + it { is_expected.to be_attached } + + it 'preserves the content_type' do + expect(subject.blob.content_type).to eq('audio/mpeg') + end + end + + context 'given an image file' do + it { is_expected.to be_attached } + + it 'preserves the content_type' do + expect(subject.blob.content_type).to eq('image/jpeg') + end + end + + context 'given attachment without filename' do + it { is_expected.to be_attached } + + it 'sets a fallback filename based on external file id' do + expect(subject.filename.to_s).to eq('some_valid_id') + end + end + + context 'given a supported document' do + before do + first_message = whats_app_message[:messages].first + first_message[:type] = 'document' + first_message.delete(:image) + first_message[:document] = { + filename: 'AUD-12345.mpeg', + id: 'some_valid_id', + mime_type: 'audio/mpeg', + sha256: 'sha256_hash' + } + end + + context 'with a filename' do + it { is_expected.to be_attached } + + it 'favors the filename' do + expect(subject.filename.to_s).to eq('AUD-12345.mpeg') + end + end + end + + context 'given an unsupported document' do + subject { message.files } + + before do + first_message = whats_app_message[:messages].first + first_message[:type] = 'document' + first_message.delete(:image) + first_message[:document] = { + filename: 'Comprovante.pdf', + id: 'some_valid_id', + mime_type: 'application/pdf', + sha256: 'sha256_hash' + } + end + + it { is_expected.to be_empty } + end + end + end + end + + describe '#on' do + describe 'UNKNOWN_CONTRIBUTOR' do + let(:unknown_contributor_callback) { spy('unknown_contributor_callback') } + + before do + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::UNKNOWN_CONTRIBUTOR) do |whats_app_phone_number| + unknown_contributor_callback.call(whats_app_phone_number) + end + end + + subject do + adapter.consume(whats_app_message) + unknown_contributor_callback + end + + describe 'if the sender is a contributor ' do + it { should_not have_received(:call) } + end + + describe 'if the sender is unknown' do + before { whats_app_message[:contacts].first[:wa_id] = '4955443322' } + it { should have_received(:call).with('+4955443322') } + end + end + + describe 'UNSUPPORTED_CONTENT' do + let(:unsupported_content_callback) { spy('unsupported_content_callback') } + + before do + adapter.on(WhatsAppAdapter::ThreeSixtyDialogInbound::UNSUPPORTED_CONTENT) do |sender| + unsupported_content_callback.call(sender) + end + end + + subject do + adapter.consume(whats_app_message) + unsupported_content_callback + end + + describe 'supported content' do + context 'if the message is a plaintext message' do + it { should_not have_received(:call) } + end + + context 'files' do + let(:message) { whats_app_message[:messages].first } + + before do + message.delete(:text) + stub_request(:get, fetch_file_url).to_return(status: 200, body: 'downloaded_file') + end + + context 'image' do + let(:image) do + { + id: 'some_valid_id', + mime_type: 'image/jpeg', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'image' + message[:image] = image + end + + it { should_not have_received(:call) } + end + + context 'voice' do + let(:voice) do + { + id: 'some_valid_id', + mime_type: 'audio/ogg; codecs=opus', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'voice' + message[:voice] = voice + end + + it { should_not have_received(:call) } + end + + context 'video' do + let(:video) do + { + id: 'some_valid_id', + mime_type: 'video/mp4', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'video' + message[:video] = video + end + + it { should_not have_received(:call) } + end + + context 'audio' do + let(:audio) do + { + id: 'some_valid_id', + mime_type: 'audio/ogg', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'audio' + message[:audio] = audio + end + + it { should_not have_received(:call) } + end + + context 'document' do + context 'image' do + let(:document) do + { + filename: 'animated-cat-image-0056.gif', + id: 'some_valid_id', + mime_type: 'image/gif', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'document' + message[:document] = document + end + + it { should_not have_received(:call) } + end + + context 'audio' do + let(:document) do + { + filename: 'AUD-12345.opus', + id: 'some_valid_id', + mime_type: 'audio/ogg', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'document' + message[:document] = document + end + + it { should_not have_received(:call) } + end + + context 'video' do + let(:document) do + { + filename: 'VID_12345.mp4', + id: 'some_valid_id', + mime_type: 'video/mp4', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'document' + message[:document] = document + end + + it { should_not have_received(:call) } + end + end + end + end + + describe 'unsupported content' do + let(:message) { whats_app_message[:messages].first } + + before do + message.delete(:text) + stub_request(:get, fetch_file_url).to_return(status: 200, body: 'downloaded_file') + end + + context 'document|pdf|' do + let(:document) do + { + filename: 'Comprovante.pdf', + id: 'some_valid_id', + mime_type: 'application/pdf', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'document' + message[:document] = document + end + + it { should have_received(:call).with(contributor) } + end + + context 'document|docx|' do + let(:document) do + { + filename: 'price-list.docx', + id: 'some_valid_id', + mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'document' + message[:document] = document + end + + it { should have_received(:call).with(contributor) } + end + + context 'location' do + let(:location) do + { + latitude: '22.9871', + longitude: '43.2048' + } + end + before do + message[:type] = 'location' + message[:location] = location + end + + it { should have_received(:call).with(contributor) } + end + + context 'contacts' do + let(:contacts) do + { + contacts: [ + { addresses: [], + emails: [], + ims: [], + name: { + first_name: '360dialog', + formatted_name: '360dialog Sandbox', + last_name: 'Sandbox' + }, + org: {}, + phones: [ + { phone: '+49 30 609859535', + type: 'Mobile', + wa_id: '4930609859535' } + ], urls: [] } + ], + from: '4915143416265', + id: 'some_valid_id', + timestamp: '1692123428', + type: 'contacts' + } + end + before do + message[:type] = 'contacts' + message[:contacts] = contacts + end + + it { should have_received(:call).with(contributor) } + end + end + end + end +end diff --git a/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb b/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb new file mode 100644 index 000000000..dd5c3fe6d --- /dev/null +++ b/spec/adapters/whats_app_adapter/three_sixty_dialog_outbound_spec.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe WhatsAppAdapter::ThreeSixtyDialogOutbound do + let(:adapter) { described_class.new } + let!(:message) { create(:message, text: '360dialog is great!', broadcasted: true, recipient: contributor) } + let(:contributor) { create(:contributor, email: nil) } + let(:new_request_payload) do + { + recipient_type: 'individual', + to: contributor.whats_app_phone_number.split('+').last, + type: 'template', + template: { + namespace: Setting.three_sixty_dialog_whats_app_template_namespace, + language: { + policy: 'deterministic', + code: 'de' + }, + name: kind_of(String), + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + text: contributor.first_name + }, + { + type: 'text', + text: message.request.title + } + ] + } + ] + } + } + end + let(:text_payload) do + { + recipient_type: 'individual', + to: contributor.whats_app_phone_number.split('+').last, + type: 'text', + text: { + body: message.text + } + } + end + + let(:welcome_message_payload) do + { + recipient_type: 'individual', + to: contributor.whats_app_phone_number.split('+').last, + type: 'template', + template: { + namespace: Setting.three_sixty_dialog_whats_app_template_namespace, + language: { + policy: 'deterministic', + code: 'de' + }, + name: 'welcome_message', + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + text: Setting.project_name + } + ] + } + ] + } + } + end + + describe '::send!' do + subject { -> { described_class.send!(message) } } + before { message } # we don't count the extra ::send here + + context '`whats_app_phone_number` blank' do + it { should_not enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText) } + end + + context 'given a WhatsApp contributor' do + let(:contributor) do + create( + :contributor, + email: nil, + whats_app_phone_number: '+491511234567' + ) + end + + describe 'contributor has not sent a message within 24 hours' do + it 'enqueues the Text job with WhatsApp template' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with do |params| + expect(params[:payload]).to include(new_request_payload) + end) + end + end + + describe 'contributor has responded to a template' do + before { contributor.update(whats_app_message_template_responded_at: Time.current) } + + it 'enqueues the Text job with the request text' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with do |params| + expect(params[:payload]).to eq(text_payload) + end) + end + end + + describe 'contributor has sent a reply within 24 hours' do + before { create(:message, sender: contributor) } + + it 'enqueues the Text job with the request text' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with do |params| + expect(params[:payload]).to eq(text_payload) + end) + end + end + + describe 'message with files' do + let(:file) { create(:file) } + before { message.update(files: [file]) } + + context 'contributor has not sent a message within 24 hours' do + it 'enqueues the Text job with WhatsApp template' do + expect do + subject.call + end.to(have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with do |params| + expect(params[:payload]).to include(new_request_payload) + end) + end + end + + context 'contributor has sent a reply within 24 hours' do + before { create(:message, sender: contributor) } + + it 'enqueues a File job with file, contributor, text' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::UploadFile).on_queue('default').with do |params| + expect(params[:message_id]).to eq(message.id) + end) + end + end + end + end + end + + describe '#send_welcome_message!' do + subject { -> { described_class.send_welcome_message!(contributor) } } + + it { is_expected.not_to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText) } + + context 'contributor has a phone number' do + let(:contributor) do + create( + :contributor, + whats_app_phone_number: '+491511234567', + email: nil + ) + end + + context 'and no replies sent(new contributor)' do + it { is_expected.to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with({ payload: welcome_message_payload }) } + end + + context 'with replies sent within 24 hours' do + before do + create(:message, sender: contributor) + text_payload[:text][:body] = I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) + end + + it { is_expected.to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with({ payload: text_payload }) } + end + end + end + + describe '#send_unsupported_content_message!' do + subject { -> { described_class.send_unsupported_content_message!(contributor) } } + + it { is_expected.not_to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText) } + + context 'contributor has a phone number' do + let(:contributor) do + create( + :contributor, + whats_app_phone_number: '+491511234567', + email: nil, + organization: organization + ) + end + let(:contact_person) { create(:user) } + let(:organization) { create(:organization, contact_person: contact_person) } + + before do + text_payload[:text][:body] = I18n.t('adapter.whats_app.unsupported_content_template', + first_name: contributor.first_name, + contact_person: contributor.organization.contact_person.name) + end + + it { is_expected.to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with({ payload: text_payload }) } + end + end + + describe '#send_more_info_message!' do + subject { -> { described_class.send_more_info_message!(contributor) } } + + it { is_expected.not_to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText) } + + context 'contributor has a phone number' do + let(:contributor) do + create( + :contributor, + whats_app_phone_number: '+491511234567', + email: nil + ) + end + + before do + text_payload[:text][:body] = [Setting.about, "_#{I18n.t('adapter.whats_app.unsubscribe.instructions')}_"].join("\n\n") + end + + it { is_expected.to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with({ payload: text_payload }) } + end + end + + describe '#send_unsubsribed_successfully_message!' do + subject { -> { described_class.send_unsubsribed_successfully_message!(contributor) } } + + it { is_expected.not_to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText) } + + context 'contributor has a phone number' do + let(:contributor) do + create( + :contributor, + whats_app_phone_number: '+491511234567', + email: nil + ) + end + + before do + text_payload[:text][:body] = [I18n.t('adapter.whats_app.unsubscribe.successful'), + "_#{I18n.t('adapter.whats_app.subscribe.instructions')}_"].join("\n\n") + end + + it { is_expected.to enqueue_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with({ payload: text_payload }) } + end + end + + describe '::freeform_message_permitted?(recipient)' do + subject { described_class.send(:freeform_message_permitted?, contributor) } + + describe 'template message' do + context 'contributor has responded' do + before { contributor.update(whats_app_message_template_responded_at: 1.second.ago) } + + it { is_expected.to eq(true) } + end + + context 'contributor has not responded, and has no messages within 24 hours' do + it { is_expected.to eq(false) } + end + end + + describe 'message from contributor within 24 hours' do + context 'has been received' do + before { create(:message, sender: contributor) } + + it { is_expected.to eq(true) } + end + + context 'has not been received' do + it { is_expected.to eq(false) } + end + end + end +end diff --git a/spec/adapters/whats_app_adapter/twilio_outbound_spec.rb b/spec/adapters/whats_app_adapter/twilio_outbound_spec.rb new file mode 100644 index 000000000..48dc0c729 --- /dev/null +++ b/spec/adapters/whats_app_adapter/twilio_outbound_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe WhatsAppAdapter::TwilioOutbound do + let(:adapter) { described_class.new } + let(:message) { create(:message, text: 'WhatsApp as a channel is great, no?', broadcasted: true, recipient: contributor) } + let(:contributor) { create(:contributor, email: nil) } + + describe '::send_welcome_message!' do + let(:expected_job_args) do + { recipient: contributor, text: I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) } + end + subject { -> { described_class.send_welcome_message!(contributor) } } + before { message } # we don't count the extra ::send here + + it { should_not enqueue_job(WhatsAppAdapter::Outbound::Text) } + + context 'contributor has a phone number' do + let(:contributor) do + create( + :contributor, + whats_app_phone_number: '+491511234567', + email: nil + ) + end + + it { should enqueue_job(WhatsAppAdapter::Outbound::Text).with(expected_job_args) } + end + end + + describe '::send!' do + subject { -> { described_class.send!(message) } } + before { message } # we don't count the extra ::send here + + context '`whats_app_phone_number` blank' do + it { should_not enqueue_job(WhatsAppAdapter::Outbound::Text) } + end + + context 'given a WhatsApp contributor' do + let(:contributor) do + create( + :contributor, + email: nil, + whats_app_phone_number: '+491511234567' + ) + end + + describe 'contributor has not sent a message within 24 hours' do + it 'enqueues the Text job with WhatsApp template' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::Text).on_queue('default').with do |params| + expect(params[:recipient]).to eq(contributor) + expect(params[:text]).to include(contributor.first_name) + expect(params[:text]).to include(message.request.title) + end) + end + end + + describe 'contributor has responded to a template' do + before { contributor.update(whats_app_message_template_responded_at: Time.current) } + + it 'enqueues the Text job with the request text' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::Text).on_queue('default').with do |params| + expect(params[:recipient]).to eq(contributor) + expect(params[:text]).to eq(message.text) + end) + end + end + + describe 'contributor has sent a reply within 24 hours' do + before { create(:message, sender: contributor) } + + it 'enqueues the Text job with the request text' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::Text).on_queue('default').with do |params| + expect(params[:recipient]).to eq(contributor) + expect(params[:text]).to eq(message.text) + end) + end + end + + describe 'message with files' do + let(:file) { create(:file) } + before { message.update(files: [file]) } + + context 'contributor has not sent a message within 24 hours' do + it 'enqueues the Text job with WhatsApp template' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::Text).on_queue('default').with do |params| + expect(params[:recipient]).to eq(contributor) + expect(params[:text]).to include(contributor.first_name) + expect(params[:text]).to include(message.request.title) + end) + end + end + + context 'contributor has sent a reply within 24 hours' do + before { create(:message, sender: contributor) } + it 'enqueues a File job with file, contributor, text' do + expect { subject.call }.to(have_enqueued_job(WhatsAppAdapter::Outbound::File).on_queue('default').with do |params| + expect(params[:file]).to eq(message.files.first) + expect(params[:recipient]).to eq(contributor) + expect(params[:text]).to eq(message.text) + end) + end + end + end + end + end + + describe '::freeform_message_permitted?(recipient)' do + subject { described_class.send(:freeform_message_permitted?, contributor) } + + describe 'template message' do + context 'contributor has responded' do + before { contributor.update(whats_app_message_template_responded_at: 1.second.ago) } + + it { is_expected.to eq(true) } + end + + context 'contributor has not responded, and has no messages within 24 hours' do + it { is_expected.to eq(false) } + end + end + + describe 'message from contributor within 24 hours' do + context 'has been received' do + before { create(:message, sender: contributor) } + + it { is_expected.to eq(true) } + end + + context 'has not been received' do + it { is_expected.to eq(false) } + end + end + end +end diff --git a/spec/requests/whats_app/three_sixty_dialog_webhook_spec.rb b/spec/requests/whats_app/three_sixty_dialog_webhook_spec.rb new file mode 100644 index 000000000..1a016b0f6 --- /dev/null +++ b/spec/requests/whats_app/three_sixty_dialog_webhook_spec.rb @@ -0,0 +1,544 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe WhatsApp::ThreeSixtyDialogWebhookController do + let(:whats_app_phone_number) { '+491511234567' } + let(:params) do + { contacts: [{ profile: { name: 'Matthew Rider' }, + wa_id: '491511234567' }], + messages: [{ from: '491511234567', + id: 'some_valid_id', + text: { body: 'Hey' }, + timestamp: '1692118778', + type: 'text' }], + three_sixty_dialog_webhook: { contacts: [{ profile: { name: 'Matthew Rider' }, + wa_id: '491511234567' }], + messages: [{ from: '491511234567', + id: 'some_valid_id', + text: { body: 'Hey' }, + timestamp: '1692118778', + type: 'text' }] } } + end + let(:text_payload) do + { + payload: { + recipient_type: 'individual', + to: contributor.whats_app_phone_number.split('+').last, + type: 'text', + text: { + body: text + } + } + } + end + let(:latest_message) { contributor.received_messages.first.text } + + subject { -> { post whats_app_three_sixty_dialog_webhook_path, params: params } } + + describe '#messages' do + before do + allow(Sentry).to receive(:capture_exception) + allow(Setting).to receive(:whats_app_server_phone_number).and_return('4915133311445') + allow(Request).to receive(:broadcast!).and_call_original + allow(Setting).to receive(:three_sixty_dialog_client_api_key).and_return('valid_api_key') + end + + describe 'statuses' do + let(:params) do + { + statuses: [{ id: 'some_valid_id', + message: { recipient_id: '491511234567' }, + status: 'read', + timestamp: '1691405467', + type: 'message' }], + three_sixty_dialog_webhook: { statuses: [{ id: 'some_valid_id', + message: { recipient_id: '491511234567' }, + status: 'read', timestamp: '1691405467', + type: 'message' }] } + } + end + + it 'ignores statuses' do + expect(WhatsAppAdapter::ThreeSixtyDialogInbound).not_to receive(:new) + + subject.call + end + end + + describe 'errors' do + let(:exception) { WhatsAppAdapter::ThreeSixtyDialogError.new(error_code: '501', message: 'Unsupported message type') } + before do + params[:messages] = [ + { errors: [{ + code: 501, + details: 'Message type is not currently supported', + title: 'Unsupported message type' + }], + from: '491511234567', + id: 'some_valid_id', + timestamp: '1691066820', + type: 'unknown' } + ] + allow(ErrorNotifier).to receive(:report) + end + + it 'reports the error' do + expect(ErrorNotifier).to receive(:report).with(exception, context: { details: 'Message type is not currently supported' }) + + subject.call + end + end + + describe 'unknown contributor' do + it 'does not create a message' do + expect { subject.call }.not_to change(Message, :count) + end + + it 'raises an error' do + expect(Sentry).to receive(:capture_exception).with( + WhatsAppAdapter::UnknownContributorError.new(whats_app_phone_number: '+491511234567') + ) + + subject.call + end + end + + describe 'given a contributor' do + let!(:contributor) { create(:contributor, whats_app_phone_number: whats_app_phone_number) } + let(:request) { create(:request) } + + before do + create(:message, request: request, recipient: contributor) + end + + context 'no message template sent' do + it 'creates a messsage' do + expect { subject.call }.to change(Message, :count).from(2).to(3) + end + end + + context 'responding to template' do + before { contributor.update(whats_app_message_template_sent_at: Time.current) } + let(:text) { latest_message } + + context 'request to receive latest message' do + it 'enqueues a job to send the latest received message' do + expect do + subject.call + end.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with(text_payload) + end + + it 'marks that contributor has responded to template message' do + expect { subject.call }.to change { + contributor.reload.whats_app_message_template_responded_at + }.from(nil).to(kind_of(ActiveSupport::TimeWithZone)) + end + end + end + + context 'request for more info' do + before { params[:messages].first[:text][:body] = 'Mehr Infos' } + let(:text) { [Setting.about, "_#{I18n.t('adapter.whats_app.unsubscribe.instructions')}_"].join("\n\n") } + + it 'marks that contributor has responded to template message' do + expect { subject.call }.to change { + contributor.reload.whats_app_message_template_responded_at + }.from(nil).to(kind_of(ActiveSupport::TimeWithZone)) + end + + it 'enqueues a job to send more info message' do + expect do + subject.call + end.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with(text_payload) + end + + context 'does not enqueue a job' do + let(:text) { latest_message } + + it 'to send the latest received message' do + expect { subject.call }.not_to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with(text_payload) + end + end + end + + context 'request to unsubscribe' do + let!(:admin) { create_list(:user, 2, admin: true) } + let!(:non_admin_user) { create(:user) } + + before { params[:messages].first[:text][:body] = 'Abbestellen' } + let(:text) do + [I18n.t('adapter.whats_app.unsubscribe.successful'), "_#{I18n.t('adapter.whats_app.subscribe.instructions')}_"].join("\n\n") + end + + it 'marks contributor as inactive' do + expect { subject.call }.to change { contributor.reload.deactivated_at }.from(nil).to(kind_of(ActiveSupport::TimeWithZone)) + end + + it 'enqueues a job to inform the contributor of successful unsubscribe' do + expect do + subject.call + end.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with(text_payload) + end + + it_behaves_like 'an ActivityNotification', 'ContributorMarkedInactive' + + it 'enqueues a job to inform admin' do + expect { subject.call }.to have_enqueued_job.on_queue('default').with( + 'PostmarkAdapter::Outbound', + 'contributor_marked_as_inactive_email', + 'deliver_now', # How ActionMailer works in test environment, even though in production we call deliver_later + { + params: { admin: an_instance_of(User), contributor: contributor }, + args: [] + } + ).exactly(2).times + end + + context 'does not enqueue a job' do + let(:text) { latest_message } + + it 'to send the latest received message' do + expect { subject.call }.not_to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with(text_payload) + end + end + end + + context 'request to re-subscribe' do + let!(:admin) { create_list(:user, 2, admin: true) } + let!(:non_admin_user) { create(:user) } + + before do + contributor.update(deactivated_at: Time.current) + params[:messages].first[:text][:body] = 'Bestellen' + end + + let(:text) do + I18n.t('adapter.whats_app.welcome_message', project_name: Setting.project_name) + end + + it 'marks contributor as active' do + expect { subject.call }.to change { contributor.reload.deactivated_at }.from(kind_of(ActiveSupport::TimeWithZone)).to(nil) + end + + it 'marks that contributor has responded to template message' do + expect { subject.call }.to change { + contributor.reload.whats_app_message_template_responded_at + }.from(nil).to(kind_of(ActiveSupport::TimeWithZone)) + end + + it 'enqueues a job to welcome contributor' do + expect do + subject.call + end.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).on_queue('default').with(text_payload) + end + + it_behaves_like 'an ActivityNotification', 'ContributorSubscribed' + + it 'enqueues a job to inform admin' do + expect { subject.call }.to have_enqueued_job.on_queue('default').with( + 'PostmarkAdapter::Outbound', + 'contributor_subscribed_email', + 'deliver_now', # How ActionMailer works in test environment, even though in production we call deliver_later + { + params: { admin: an_instance_of(User), contributor: contributor }, + args: [] + } + ).exactly(2).times + end + + context 'does not enqueue a job' do + let(:text) { latest_message } + + it 'to send the latest received message' do + expect { subject.call }.not_to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with(text_payload) + end + end + + context 'files' do + let(:message) { params[:messages].first } + let(:fetch_file_url) { "#{Setting.three_sixty_dialog_whats_app_rest_api_endpoint}/media/some_valid_id" } + + before { message.delete(:text) } + + context 'supported content' do + before { stub_request(:get, fetch_file_url).to_return(status: 200, body: 'downloaded_file') } + + context 'image' do + let(:image) do + { + id: 'some_valid_id', + mime_type: 'image/jpeg', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'image' + message[:image] = image + end + + it 'creates a new Message::File' do + expect { subject.call }.to change(Message::File, :count).from(0).to(1) + end + + it 'creates a new Message' do + expect { subject.call }.to change(Message, :count).from(2).to(3) + end + + it 'attaches the file to the message with its mime_type' do + subject.call + + latest_message = Message.where(sender: contributor).first + expect(latest_message.files.first).to eq(Message::File.first) + expect(latest_message.files.first.attachment.content_type).to eq(message[:image][:mime_type]) + end + end + + context 'voice' do + let(:voice) do + { + id: 'some_valid_id', + mime_type: 'audio/ogg; codecs=opus', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'voice' + message[:voice] = voice + end + + it 'creates a new Message::File' do + expect { subject.call }.to change(Message::File, :count).from(0).to(1) + end + + it 'creates a new Message' do + expect { subject.call }.to change(Message, :count).from(2).to(3) + end + + it 'attaches the file to the message' do + subject.call + + expect(Message.first.files.first).to eq(Message::File.first) + end + end + + context 'video' do + let(:video) do + { + id: 'some_valid_id', + mime_type: 'video/mp4', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'video' + message[:video] = video + end + + it 'creates a new Message::File' do + expect { subject.call }.to change(Message::File, :count).from(0).to(1) + end + + it 'creates a new Message' do + expect { subject.call }.to change(Message, :count).from(2).to(3) + end + + it 'attaches the file to the message with its mime_type' do + subject.call + + latest_message = Message.where(sender: contributor).first + expect(latest_message.files.first).to eq(Message::File.first) + expect(latest_message.files.first.attachment.content_type).to eq(message[:video][:mime_type]) + end + end + + context 'audio' do + let(:audio) do + { + id: 'some_valid_id', + mime_type: 'audio/ogg', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'audio' + message[:audio] = audio + end + + it 'creates a new Message::File' do + expect { subject.call }.to change(Message::File, :count).from(0).to(1) + end + + it 'creates a new Message' do + expect { subject.call }.to change(Message, :count).from(2).to(3) + end + + it 'attaches the file to the message with its mime_type' do + subject.call + + latest_message = Message.where(sender: contributor).first + expect(latest_message.files.first).to eq(Message::File.first) + expect(latest_message.files.first.attachment.content_type).to eq(message[:audio][:mime_type]) + end + end + + context 'document' do + context 'image' do + let(:document) do + { + filename: 'animated-cat-image-0056.gif', + id: 'some_valid_id', + mime_type: 'image/gif', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'document' + message[:document] = document + end + + it 'attaches the file to the message with its mime_type' do + subject.call + + latest_message = Message.where(sender: contributor).first + expect(latest_message.files.first.attachment).to be_attached + expect(latest_message.files.first.attachment.content_type).to eq(message[:document][:mime_type]) + end + end + + context 'audio' do + let(:document) do + { + filename: 'AUD-12345.opus', + id: 'some_valid_id', + mime_type: 'audio/ogg', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'document' + message[:document] = document + end + + it 'attaches the file to the message with its mime_type' do + subject.call + + latest_message = Message.where(sender: contributor).first + expect(latest_message.files.first.attachment).to be_attached + expect(latest_message.files.first.attachment.content_type).to eq(message[:document][:mime_type]) + end + end + + context 'video' do + let(:document) do + { + filename: 'VID_12345.mp4', + id: 'some_valid_id', + mime_type: 'video/mp4', + sha256: 'sha256_hash' + } + end + + before do + message[:type] = 'document' + message[:document] = document + end + + it 'attaches the file to the message with its mime_type' do + subject.call + + latest_message = Message.where(sender: contributor).first + expect(latest_message.files.first.attachment).to be_attached + expect(latest_message.files.first.attachment.content_type).to eq(message[:document][:mime_type]) + end + end + end + end + + context 'unsupported content' do + let(:contact_person) { create(:user) } + let!(:organization) { create(:organization, contact_person: contact_person, contributors: [contributor]) } + let(:text) do + I18n.t('adapter.whats_app.unsupported_content_template', first_name: contributor.first_name, + contact_person: contributor.organization.contact_person.name) + end + + context 'document' do + let(:document) do + { + filename: 'Comprovante.pdf', + id: 'some_valid_id', + mime_type: 'application/pdf', + sha256: 'sha256_hash' + } + end + before do + message[:type] = 'document' + message[:document] = document + end + + it 'sends a message to contributor to let them know the message type is not supported' do + expect { subject.call }.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with(text_payload) + end + end + + context 'location' do + let(:location) do + { + latitude: '22.9871', + longitude: '43.2048' + } + end + before do + message[:type] = 'location' + message[:location] = location + end + + it 'sends a message to contributor to let them know the message type is not supported' do + expect { subject.call }.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with(text_payload) + end + end + + context 'contacts' do + let(:contacts) do + { + contacts: [ + { addresses: [], + emails: [], + ims: [], + name: { + first_name: '360dialog', + formatted_name: '360dialog Sandbox', + last_name: 'Sandbox' + }, + org: {}, + phones: [ + { phone: '+49 30 609859535', + type: 'Mobile', + wa_id: '4930609859535' } + ], urls: [] } + ], + from: '4915143416265', + id: 'some_valid_id', + timestamp: '1692123428', + type: 'contacts' + } + end + before do + message[:type] = 'contacts' + message[:contacts] = contacts + end + + it 'sends a message to contributor to let them know the message type is not supported' do + expect { subject.call }.to have_enqueued_job(WhatsAppAdapter::Outbound::ThreeSixtyDialogText).with(text_payload) + end + end + end + end + end + end + end +end