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
This commit is contained in:
Sojan Jose
2021-09-11 01:31:17 +05:30
committed by GitHub
parent 671c5c931f
commit 0a38632f14
37 changed files with 581 additions and 56 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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: {

View File

@@ -35,6 +35,13 @@
:style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png"
/>
<img
v-if="badge === 'Channel::Line'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/line.png"
/>
<img
v-if="badge === 'Channel::Telegram'"
id="badge"

View File

@@ -172,7 +172,32 @@
},
"FINISH_MESSAGE": "Start forwarding your emails to the following email address."
},
"TELEGRAM_CHANNEL": {
"LINE_CHANNEL": {
"TITLE": "LINE Channel",
"DESC": "Integrate with LINE channel and start supporting your customers.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
"ERROR": "This field is required"
},
"LINE_CHANNEL_ID": {
"LABEL": "LINE Channel ID",
"PLACEHOLDER": "LINE Channel ID"
},
"LINE_CHANNEL_SECRET": {
"LABEL": "LINE Channel Secret",
"PLACEHOLDER": "LINE Channel Secret"
},
"LINE_CHANNEL_TOKEN": {
"LABEL": "LINE Channel Token",
"PLACEHOLDER": "LINE Channel Token"
},
"SUBMIT_BUTTON": "Create LINE Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the LINE channel"
}
},
"TELEGRAM_CHANNEL": {
"TITLE": "Telegram Channel",
"DESC": "Integrate with Telegram channel and start supporting your customers.",
"BOT_TOKEN": {

View File

@@ -17,7 +17,15 @@
<woot-code
v-if="isATwilioInbox"
lang="html"
:script="twilioCallbackURL"
:script="currentInbox.webhook_url"
>
</woot-code>
</div>
<div class="medium-6 small-offset-3">
<woot-code
v-if="isALineInbox"
lang="html"
:script="currentInbox.webhook_url"
>
</woot-code>
</div>
@@ -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(

View File

@@ -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,
};

View File

@@ -0,0 +1,140 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.LINE_CHANNEL.DESC')"
/>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.LINE_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
@blur="$v.channelName.$touch"
/>
<span v-if="$v.channelName.$error" class="message">{{
$t('INBOX_MGMT.ADD.LINE_CHANNEL.CHANNEL_NAME.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.lineChannelId.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL') }}
<input
v-model.trim="lineChannelId"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER')
"
@blur="$v.lineChannelId.$touch"
/>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.lineChannelSecret.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL') }}
<input
v-model.trim="lineChannelSecret"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER')
"
@blur="$v.lineChannelSecret.$touch"
/>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.lineChannelToken.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL') }}
<input
v-model.trim="lineChannelToken"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER')
"
@blur="$v.lineChannelToken.$touch"
/>
</label>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
data() {
return {
channelName: '',
lineChannelId: '',
lineChannelSecret: '',
lineChannelToken: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
lineChannelId: { required },
lineChannelSecret: { required },
lineChannelToken: { required },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const lineChannel = await this.$store.dispatch('inboxes/createChannel', {
name: this.channelName,
channel: {
type: 'line',
line_channel_id: this.lineChannelId,
line_channel_secret: this.lineChannelSecret,
line_channel_token: this.lineChannelToken,
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: lineChannel.id,
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'));
}
},
},
};
</script>

View File

@@ -3,9 +3,6 @@ export default {
hostURL() {
return window.chatwootConfig.hostURL;
},
twilioCallbackURL() {
return `${this.hostURL}/twilio/callback`;
},
vapidPublicKey() {
return window.chatwootConfig.vapidPublicKey;
},

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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
"
<script>

View File

@@ -0,0 +1,12 @@
module Channelable
extend ActiveSupport::Concern
included do
validates :account_id, presence: true
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
end
def has_24_hour_messaging_window?
false
end
end

View File

@@ -93,6 +93,15 @@ class Inbox < ApplicationRecord
}
end
def webhook_url
case channel_type
when 'Channel::TwilioSMS'
"#{ENV['FRONTEND_URL']}/twilio/callback"
when 'Channel::Line'
"#{ENV['FRONTEND_URL']}/webhooks/line/#{channel.line_channel_id}"
end
end
private
def delete_round_robin_agents

View File

@@ -0,0 +1,65 @@
class Line::IncomingMessageService
include ::FileTypeHelper
pattr_initialize [:inbox!, :params!]
def perform
line_contact_info
set_contact
set_conversation
# TODO: iterate over the events and handle the attachments in future
# https://github.com/line/line-bot-sdk-ruby#synopsis
@message = @conversation.messages.create(
content: params[:events].first['message']['text'],
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: (params[:events].first['message']['id']).to_s
)
@message.save!
end
private
def account
@account ||= inbox.account
end
def line_contact_info
@line_contact_info ||= JSON.parse(inbox.channel.client.get_profile(params[:events].first['source']['userId']).body)
end
def set_contact
contact_inbox = ::ContactBuilder.new(
source_id: line_contact_info['userId'],
inbox: inbox,
contact_attributes: contact_attributes
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id
}
end
def set_conversation
@conversation = @contact_inbox.conversations.first
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
end
def contact_attributes
{
name: line_contact_info['displayName'],
avatar_url: line_contact_info['pictureUrl']
}
end
end

View File

@@ -0,0 +1,11 @@
class Line::SendOnLineService < Base::SendOnChannelService
private
def channel_class
Channel::Line
end
def perform_reply
channel.client.push_message(message.conversation.contact_inbox.source_id, [{ type: 'text', text: message.content }])
end
end

View File

@@ -10,6 +10,7 @@ json.out_of_office_message resource.out_of_office_message
json.csat_survey_enabled resource.csat_survey_enabled
json.working_hours resource.weekly_schedule
json.timezone resource.timezone
json.webhook_url resource.webhook_url
json.avatar_url resource.try(:avatar_url)
json.page_id resource.channel.try(:page_id)
json.widget_color resource.channel.try(:widget_color)