From 0a38632f1490e856abb958d3173c79f9f9b19829 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Sat, 11 Sep 2021 01:31:17 +0530 Subject: [PATCH] feat: Line Channel (#2904) - Ability to configure line bots as a channel in chatwoot - Receive a message sent to the line bot in chatwoot - Ability to reply to line users from chatwoot fixes: #2738 --- Gemfile | 1 + Gemfile.lock | 2 + .../api/v1/accounts/inboxes_controller.rb | 4 + app/controllers/webhooks/line_controller.rb | 6 + .../components/widgets/ChannelItem.vue | 11 +- .../components/widgets/Thumbnail.vue | 7 + .../dashboard/i18n/locale/en/inboxMgmt.json | 27 +++- .../dashboard/settings/inbox/FinishSetup.vue | 13 +- .../settings/inbox/channel-factory.js | 2 + .../settings/inbox/channels/Line.vue | 140 ++++++++++++++++++ app/javascript/shared/mixins/configMixin.js | 3 - app/jobs/send_reply_job.rb | 2 + app/jobs/webhooks/line_events_job.rb | 24 +++ app/models/account.rb | 1 + app/models/channel/api.rb | 11 +- app/models/channel/email.rb | 9 +- app/models/channel/facebook_page.rb | 9 +- app/models/channel/line.rb | 39 +++++ app/models/channel/telegram.rb | 10 +- app/models/channel/twilio_sms.rb | 7 +- app/models/channel/twitter_profile.rb | 10 +- app/models/channel/web_widget.rb | 8 +- app/models/concerns/channelable.rb | 12 ++ app/models/inbox.rb | 9 ++ app/services/line/incoming_message_service.rb | 65 ++++++++ app/services/line/send_on_line_service.rb | 11 ++ app/views/api/v1/models/_inbox.json.jbuilder | 1 + config/routes.rb | 1 + db/migrate/20210829124254_add_line_channel.rb | 11 ++ db/schema.rb | 12 +- .../v1/accounts/inboxes_controller_spec.rb | 22 +++ .../webhooks/line_controller_spec.rb | 12 ++ spec/factories/channel/channel_line.rb | 11 ++ spec/jobs/send_reply_job_spec.rb | 9 ++ spec/jobs/webhooks/line_events_job_spec.rb | 38 +++++ .../line/incoming_message_service_spec.rb | 59 ++++++++ .../line/send_on_line_service_spec.rb | 18 +++ 37 files changed, 581 insertions(+), 56 deletions(-) create mode 100644 app/controllers/webhooks/line_controller.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Line.vue create mode 100644 app/jobs/webhooks/line_events_job.rb create mode 100644 app/models/channel/line.rb create mode 100644 app/models/concerns/channelable.rb create mode 100644 app/services/line/incoming_message_service.rb create mode 100644 app/services/line/send_on_line_service.rb create mode 100644 db/migrate/20210829124254_add_line_channel.rb create mode 100644 spec/controllers/webhooks/line_controller_spec.rb create mode 100644 spec/factories/channel/channel_line.rb create mode 100644 spec/jobs/webhooks/line_events_job_spec.rb create mode 100644 spec/services/line/incoming_message_service_spec.rb create mode 100644 spec/services/line/send_on_line_service_spec.rb diff --git a/Gemfile b/Gemfile index 7f0933b37..334602aad 100644 --- a/Gemfile +++ b/Gemfile @@ -78,6 +78,7 @@ gem 'wisper', '2.0.0' ##--- gems for channels ---## # TODO: bump up gem to 2.0 gem 'facebook-messenger' +gem 'line-bot-api' gem 'twilio-ruby', '~> 5.32.0' # twitty will handle subscription of twitter account events # gem 'twitty', git: 'https://github.com/chatwoot/twitty' diff --git a/Gemfile.lock b/Gemfile.lock index 31b9f7bbc..8e102838d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -322,6 +322,7 @@ GEM addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) + line-bot-api (1.21.0) liquid (5.0.1) listen (3.6.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -661,6 +662,7 @@ DEPENDENCIES kaminari koala letter_opener + line-bot-api liquid listen maxminddb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 3b21b70bb..f9a6ce154 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -92,6 +92,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController Current.account.api_channels.create!(permitted_params(Channel::Api::EDITABLE_ATTRS)[:channel].except(:type)) when 'email' Current.account.email_channels.create!(permitted_params(Channel::Email::EDITABLE_ATTRS)[:channel].except(:type)) + when 'line' + Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type)) when 'telegram' Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) end @@ -122,6 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController Channel::Email::EDITABLE_ATTRS when 'Channel::Telegram' Channel::Telegram::EDITABLE_ATTRS + when 'Channel::Line' + Channel::Line::EDITABLE_ATTRS else [] end diff --git a/app/controllers/webhooks/line_controller.rb b/app/controllers/webhooks/line_controller.rb new file mode 100644 index 000000000..74e22f119 --- /dev/null +++ b/app/controllers/webhooks/line_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::LineController < ActionController::API + def process_payload + Webhooks::LineEventsJob.perform_later(params: params.to_unsafe_hash, signature: request.headers['x-line-signature'], post_body: request.raw_post) + head :ok + end +end diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 819d912bd..183f42c10 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -76,7 +76,16 @@ export default { if (key === 'email') { return this.enabledFeatures.channel_email; } - return ['website', 'twilio', 'api', 'whatsapp', 'sms', 'telegram'].includes(key); + + return [ + 'website', + 'twilio', + 'api', + 'whatsapp', + 'sms', + 'telegram', + 'line', + ].includes(key); }, }, methods: { diff --git a/app/javascript/dashboard/components/widgets/Thumbnail.vue b/app/javascript/dashboard/components/widgets/Thumbnail.vue index e43d48a4b..6fe83720b 100644 --- a/app/javascript/dashboard/components/widgets/Thumbnail.vue +++ b/app/javascript/dashboard/components/widgets/Thumbnail.vue @@ -35,6 +35,13 @@ :style="badgeStyle" src="~dashboard/assets/images/channels/whatsapp.png" /> + + + +
+
@@ -75,6 +83,9 @@ export default { isAEmailInbox() { return this.currentInbox.channel_type === 'Channel::Email'; }, + isALineInbox() { + return this.currentInbox.channel_type === 'Channel::Line'; + }, message() { if (this.isATwilioInbox) { return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t( diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js index db7fb0c43..0842f9f6b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js @@ -5,6 +5,7 @@ import Api from './channels/Api'; import Email from './channels/Email'; import Sms from './channels/Sms'; import Whatsapp from './channels/Whatsapp'; +import Line from './channels/Line'; import Telegram from './channels/Telegram'; const channelViewList = { @@ -15,6 +16,7 @@ const channelViewList = { email: Email, sms: Sms, whatsapp: Whatsapp, + line: Line, telegram: Telegram, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Line.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Line.vue new file mode 100644 index 000000000..339ded821 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Line.vue @@ -0,0 +1,140 @@ + + + diff --git a/app/javascript/shared/mixins/configMixin.js b/app/javascript/shared/mixins/configMixin.js index a4e6c62b2..acbe98511 100644 --- a/app/javascript/shared/mixins/configMixin.js +++ b/app/javascript/shared/mixins/configMixin.js @@ -3,9 +3,6 @@ export default { hostURL() { return window.chatwootConfig.hostURL; }, - twilioCallbackURL() { - return `${this.hostURL}/twilio/callback`; - }, vapidPublicKey() { return window.chatwootConfig.vapidPublicKey; }, diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index 77d17cc22..b834c41ea 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -11,6 +11,8 @@ class SendReplyJob < ApplicationJob ::Twitter::SendOnTwitterService.new(message: message).perform when 'Channel::TwilioSms' ::Twilio::SendOnTwilioService.new(message: message).perform + when 'Channel::Line' + ::Line::SendOnLineService.new(message: message).perform when 'Channel::Telegram' ::Telegram::SendOnTelegramService.new(message: message).perform end diff --git a/app/jobs/webhooks/line_events_job.rb b/app/jobs/webhooks/line_events_job.rb new file mode 100644 index 000000000..3c17eddb3 --- /dev/null +++ b/app/jobs/webhooks/line_events_job.rb @@ -0,0 +1,24 @@ +class Webhooks::LineEventsJob < ApplicationJob + queue_as :default + + def perform(params: {}, signature: '', post_body: '') + @params = params + return unless valid_event_payload? + return unless valid_post_body?(post_body, signature) + + Line::IncomingMessageService.new(inbox: @channel.inbox, params: @params['line'].with_indifferent_access).perform + end + + private + + def valid_event_payload? + @channel = Channel::Line.find_by(line_channel_id: @params[:line_channel_id]) if @params[:line_channel_id] + end + + # https://developers.line.biz/en/reference/messaging-api/#signature-validation + # validate the line payload + def valid_post_body?(post_body, signature) + hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @channel.line_channel_secret, post_body) + Base64.strict_encode64(hash) == signature + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 817013373..0b8287be5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -51,6 +51,7 @@ class Account < ApplicationRecord has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' has_many :email_channels, dependent: :destroy, class_name: '::Channel::Email' has_many :api_channels, dependent: :destroy, class_name: '::Channel::Api' + has_many :line_channels, dependent: :destroy, class_name: '::Channel::Line' has_many :telegram_channels, dependent: :destroy, class_name: '::Channel::Telegram' has_many :canned_responses, dependent: :destroy has_many :webhooks, dependent: :destroy diff --git a/app/models/channel/api.rb b/app/models/channel/api.rb index f3d1a9b82..fd8abb594 100644 --- a/app/models/channel/api.rb +++ b/app/models/channel/api.rb @@ -18,22 +18,15 @@ # class Channel::Api < ApplicationRecord + include Channelable + self.table_name = 'channel_api' EDITABLE_ATTRS = [:webhook_url].freeze - validates :account_id, presence: true - belongs_to :account - has_secure_token :identifier has_secure_token :hmac_token - has_one :inbox, as: :channel, dependent: :destroy - def name 'API' end - - def has_24_hour_messaging_window? - false - end end diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index 806ae0f08..fe711b01e 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -16,25 +16,20 @@ # class Channel::Email < ApplicationRecord + include Channelable + self.table_name = 'channel_email' EDITABLE_ATTRS = [:email].freeze - validates :account_id, presence: true - belongs_to :account validates :email, uniqueness: true validates :forward_to_email, uniqueness: true - has_one :inbox, as: :channel, dependent: :destroy before_validation :ensure_forward_to_email, on: :create def name 'Email' end - def has_24_hour_messaging_window? - false - end - private def ensure_forward_to_email diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index ade13990e..d564d0048 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -17,15 +17,12 @@ # class Channel::FacebookPage < ApplicationRecord - self.table_name = 'channel_facebook_pages' - + include Channelable include Reauthorizable - validates :account_id, presence: true - validates :page_id, uniqueness: { scope: :account_id } - belongs_to :account + self.table_name = 'channel_facebook_pages' - has_one :inbox, as: :channel, dependent: :destroy + validates :page_id, uniqueness: { scope: :account_id } after_create_commit :subscribe before_destroy :unsubscribe diff --git a/app/models/channel/line.rb b/app/models/channel/line.rb new file mode 100644 index 000000000..a417dbf64 --- /dev/null +++ b/app/models/channel/line.rb @@ -0,0 +1,39 @@ +# == Schema Information +# +# Table name: channel_line +# +# id :bigint not null, primary key +# line_channel_secret :string not null +# line_channel_token :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# line_channel_id :string not null +# +# Indexes +# +# index_channel_line_on_line_channel_id (line_channel_id) UNIQUE +# + +class Channel::Line < ApplicationRecord + include Channelable + + self.table_name = 'channel_line' + EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze + + validates :line_channel_id, uniqueness: true, presence: true + validates :line_channel_secret, presence: true + validates :line_channel_token, presence: true + + def name + 'LINE' + end + + def client + @client ||= Line::Bot::Client.new do |config| + config.channel_id = line_channel_id + config.channel_secret = line_channel_secret + config.channel_token = line_channel_token + end + end +end diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index 3dd66dfd3..e0d020f2f 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -15,14 +15,12 @@ # class Channel::Telegram < ApplicationRecord + include Channelable + self.table_name = 'channel_telegram' EDITABLE_ATTRS = [:bot_token].freeze - has_one :inbox, as: :channel, dependent: :destroy - belongs_to :account - before_validation :ensure_valid_bot_token, on: :create - validates :account_id, presence: true validates :bot_token, presence: true, uniqueness: true before_save :setup_telegram_webhook @@ -30,10 +28,6 @@ class Channel::Telegram < ApplicationRecord 'Telegram' end - def has_24_hour_messaging_window? - false - end - def telegram_api_url "https://api.telegram.org/bot#{bot_token}" end diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 8f25fec2e..89e9cbdc8 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -17,19 +17,16 @@ # class Channel::TwilioSms < ApplicationRecord + include Channelable + self.table_name = 'channel_twilio_sms' - validates :account_id, presence: true validates :account_sid, presence: true validates :auth_token, presence: true validates :phone_number, uniqueness: { scope: :account_id }, presence: true enum medium: { sms: 0, whatsapp: 1 } - belongs_to :account - - has_one :inbox, as: :channel, dependent: :destroy - def name medium == 'sms' ? 'Twilio SMS' : 'Whatsapp' end diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb index 1b1011014..ff2b7c19d 100644 --- a/app/models/channel/twitter_profile.rb +++ b/app/models/channel/twitter_profile.rb @@ -16,13 +16,11 @@ # class Channel::TwitterProfile < ApplicationRecord + include Channelable + self.table_name = 'channel_twitter_profiles' - validates :account_id, presence: true validates :profile_id, uniqueness: { scope: :account_id } - belongs_to :account - - has_one :inbox, as: :channel, dependent: :destroy before_destroy :unsubscribe @@ -30,10 +28,6 @@ class Channel::TwitterProfile < ApplicationRecord 'Twitter' end - def has_24_hour_messaging_window? - false - end - def create_contact_inbox(profile_id, name, additional_attributes) ActiveRecord::Base.transaction do contact = inbox.account.contacts.create!(additional_attributes: additional_attributes, name: name) diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index 58f9b9ec3..38f6e2eb5 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -25,7 +25,9 @@ # class Channel::WebWidget < ApplicationRecord + include Channelable include FlagShihTzu + self.table_name = 'channel_web_widgets' EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled, { pre_chat_form_options: [:pre_chat_message, :require_email] }, @@ -34,8 +36,6 @@ class Channel::WebWidget < ApplicationRecord validates :website_url, presence: true validates :widget_color, presence: true - belongs_to :account - has_one :inbox, as: :channel, dependent: :destroy has_secure_token :website_token has_secure_token :hmac_token @@ -50,10 +50,6 @@ class Channel::WebWidget < ApplicationRecord 'Website' end - def has_24_hour_messaging_window? - false - end - def web_widget_script "