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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 581 additions and 56 deletions

View file

@ -78,6 +78,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---## ##--- gems for channels ---##
# TODO: bump up gem to 2.0 # TODO: bump up gem to 2.0
gem 'facebook-messenger' gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.32.0' gem 'twilio-ruby', '~> 5.32.0'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty' # gem 'twitty', git: 'https://github.com/chatwoot/twitty'

View file

@ -322,6 +322,7 @@ GEM
addressable (~> 2.7) addressable (~> 2.7)
letter_opener (1.7.0) letter_opener (1.7.0)
launchy (~> 2.2) launchy (~> 2.2)
line-bot-api (1.21.0)
liquid (5.0.1) liquid (5.0.1)
listen (3.6.0) listen (3.6.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
@ -661,6 +662,7 @@ DEPENDENCIES
kaminari kaminari
koala koala
letter_opener letter_opener
line-bot-api
liquid liquid
listen listen
maxminddb maxminddb

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)) Current.account.api_channels.create!(permitted_params(Channel::Api::EDITABLE_ATTRS)[:channel].except(:type))
when 'email' when 'email'
Current.account.email_channels.create!(permitted_params(Channel::Email::EDITABLE_ATTRS)[:channel].except(:type)) 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' when 'telegram'
Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type))
end end
@ -122,6 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
Channel::Email::EDITABLE_ATTRS Channel::Email::EDITABLE_ATTRS
when 'Channel::Telegram' when 'Channel::Telegram'
Channel::Telegram::EDITABLE_ATTRS Channel::Telegram::EDITABLE_ATTRS
when 'Channel::Line'
Channel::Line::EDITABLE_ATTRS
else else
[] []
end 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') { if (key === 'email') {
return this.enabledFeatures.channel_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: { methods: {

View file

@ -35,6 +35,13 @@
:style="badgeStyle" :style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png" 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 <img
v-if="badge === 'Channel::Telegram'" v-if="badge === 'Channel::Telegram'"
id="badge" id="badge"

View file

@ -172,6 +172,31 @@
}, },
"FINISH_MESSAGE": "Start forwarding your emails to the following email address." "FINISH_MESSAGE": "Start forwarding your emails to the following email address."
}, },
"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": { "TELEGRAM_CHANNEL": {
"TITLE": "Telegram Channel", "TITLE": "Telegram Channel",
"DESC": "Integrate with Telegram channel and start supporting your customers.", "DESC": "Integrate with Telegram channel and start supporting your customers.",

View file

@ -17,7 +17,15 @@
<woot-code <woot-code
v-if="isATwilioInbox" v-if="isATwilioInbox"
lang="html" 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> </woot-code>
</div> </div>
@ -75,6 +83,9 @@ export default {
isAEmailInbox() { isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email'; return this.currentInbox.channel_type === 'Channel::Email';
}, },
isALineInbox() {
return this.currentInbox.channel_type === 'Channel::Line';
},
message() { message() {
if (this.isATwilioInbox) { if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t( 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 Email from './channels/Email';
import Sms from './channels/Sms'; import Sms from './channels/Sms';
import Whatsapp from './channels/Whatsapp'; import Whatsapp from './channels/Whatsapp';
import Line from './channels/Line';
import Telegram from './channels/Telegram'; import Telegram from './channels/Telegram';
const channelViewList = { const channelViewList = {
@ -15,6 +16,7 @@ const channelViewList = {
email: Email, email: Email,
sms: Sms, sms: Sms,
whatsapp: Whatsapp, whatsapp: Whatsapp,
line: Line,
telegram: Telegram, 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() { hostURL() {
return window.chatwootConfig.hostURL; return window.chatwootConfig.hostURL;
}, },
twilioCallbackURL() {
return `${this.hostURL}/twilio/callback`;
},
vapidPublicKey() { vapidPublicKey() {
return window.chatwootConfig.vapidPublicKey; return window.chatwootConfig.vapidPublicKey;
}, },

View file

@ -11,6 +11,8 @@ class SendReplyJob < ApplicationJob
::Twitter::SendOnTwitterService.new(message: message).perform ::Twitter::SendOnTwitterService.new(message: message).perform
when 'Channel::TwilioSms' when 'Channel::TwilioSms'
::Twilio::SendOnTwilioService.new(message: message).perform ::Twilio::SendOnTwilioService.new(message: message).perform
when 'Channel::Line'
::Line::SendOnLineService.new(message: message).perform
when 'Channel::Telegram' when 'Channel::Telegram'
::Telegram::SendOnTelegramService.new(message: message).perform ::Telegram::SendOnTelegramService.new(message: message).perform
end 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 :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget'
has_many :email_channels, dependent: :destroy, class_name: '::Channel::Email' has_many :email_channels, dependent: :destroy, class_name: '::Channel::Email'
has_many :api_channels, dependent: :destroy, class_name: '::Channel::Api' 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 :telegram_channels, dependent: :destroy, class_name: '::Channel::Telegram'
has_many :canned_responses, dependent: :destroy has_many :canned_responses, dependent: :destroy
has_many :webhooks, dependent: :destroy has_many :webhooks, dependent: :destroy

View file

@ -18,22 +18,15 @@
# #
class Channel::Api < ApplicationRecord class Channel::Api < ApplicationRecord
include Channelable
self.table_name = 'channel_api' self.table_name = 'channel_api'
EDITABLE_ATTRS = [:webhook_url].freeze EDITABLE_ATTRS = [:webhook_url].freeze
validates :account_id, presence: true
belongs_to :account
has_secure_token :identifier has_secure_token :identifier
has_secure_token :hmac_token has_secure_token :hmac_token
has_one :inbox, as: :channel, dependent: :destroy
def name def name
'API' 'API'
end end
def has_24_hour_messaging_window?
false
end
end end

View file

@ -16,25 +16,20 @@
# #
class Channel::Email < ApplicationRecord class Channel::Email < ApplicationRecord
include Channelable
self.table_name = 'channel_email' self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email].freeze EDITABLE_ATTRS = [:email].freeze
validates :account_id, presence: true
belongs_to :account
validates :email, uniqueness: true validates :email, uniqueness: true
validates :forward_to_email, uniqueness: true validates :forward_to_email, uniqueness: true
has_one :inbox, as: :channel, dependent: :destroy
before_validation :ensure_forward_to_email, on: :create before_validation :ensure_forward_to_email, on: :create
def name def name
'Email' 'Email'
end end
def has_24_hour_messaging_window?
false
end
private private
def ensure_forward_to_email def ensure_forward_to_email

View file

@ -17,15 +17,12 @@
# #
class Channel::FacebookPage < ApplicationRecord class Channel::FacebookPage < ApplicationRecord
self.table_name = 'channel_facebook_pages' include Channelable
include Reauthorizable include Reauthorizable
validates :account_id, presence: true self.table_name = 'channel_facebook_pages'
validates :page_id, uniqueness: { scope: :account_id }
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy validates :page_id, uniqueness: { scope: :account_id }
after_create_commit :subscribe after_create_commit :subscribe
before_destroy :unsubscribe 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 class Channel::Telegram < ApplicationRecord
include Channelable
self.table_name = 'channel_telegram' self.table_name = 'channel_telegram'
EDITABLE_ATTRS = [:bot_token].freeze EDITABLE_ATTRS = [:bot_token].freeze
has_one :inbox, as: :channel, dependent: :destroy
belongs_to :account
before_validation :ensure_valid_bot_token, on: :create before_validation :ensure_valid_bot_token, on: :create
validates :account_id, presence: true
validates :bot_token, presence: true, uniqueness: true validates :bot_token, presence: true, uniqueness: true
before_save :setup_telegram_webhook before_save :setup_telegram_webhook
@ -30,10 +28,6 @@ class Channel::Telegram < ApplicationRecord
'Telegram' 'Telegram'
end end
def has_24_hour_messaging_window?
false
end
def telegram_api_url def telegram_api_url
"https://api.telegram.org/bot#{bot_token}" "https://api.telegram.org/bot#{bot_token}"
end end

View file

@ -17,19 +17,16 @@
# #
class Channel::TwilioSms < ApplicationRecord class Channel::TwilioSms < ApplicationRecord
include Channelable
self.table_name = 'channel_twilio_sms' self.table_name = 'channel_twilio_sms'
validates :account_id, presence: true
validates :account_sid, presence: true validates :account_sid, presence: true
validates :auth_token, presence: true validates :auth_token, presence: true
validates :phone_number, uniqueness: { scope: :account_id }, presence: true validates :phone_number, uniqueness: { scope: :account_id }, presence: true
enum medium: { sms: 0, whatsapp: 1 } enum medium: { sms: 0, whatsapp: 1 }
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
def name def name
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp' medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end end

View file

@ -16,13 +16,11 @@
# #
class Channel::TwitterProfile < ApplicationRecord class Channel::TwitterProfile < ApplicationRecord
include Channelable
self.table_name = 'channel_twitter_profiles' self.table_name = 'channel_twitter_profiles'
validates :account_id, presence: true
validates :profile_id, uniqueness: { scope: :account_id } validates :profile_id, uniqueness: { scope: :account_id }
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
before_destroy :unsubscribe before_destroy :unsubscribe
@ -30,10 +28,6 @@ class Channel::TwitterProfile < ApplicationRecord
'Twitter' 'Twitter'
end end
def has_24_hour_messaging_window?
false
end
def create_contact_inbox(profile_id, name, additional_attributes) def create_contact_inbox(profile_id, name, additional_attributes)
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
contact = inbox.account.contacts.create!(additional_attributes: additional_attributes, name: name) contact = inbox.account.contacts.create!(additional_attributes: additional_attributes, name: name)

View file

@ -25,7 +25,9 @@
# #
class Channel::WebWidget < ApplicationRecord class Channel::WebWidget < ApplicationRecord
include Channelable
include FlagShihTzu include FlagShihTzu
self.table_name = 'channel_web_widgets' self.table_name = 'channel_web_widgets'
EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled, 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] }, { pre_chat_form_options: [:pre_chat_message, :require_email] },
@ -34,8 +36,6 @@ class Channel::WebWidget < ApplicationRecord
validates :website_url, presence: true validates :website_url, presence: true
validates :widget_color, 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 :website_token
has_secure_token :hmac_token has_secure_token :hmac_token
@ -50,10 +50,6 @@ class Channel::WebWidget < ApplicationRecord
'Website' 'Website'
end end
def has_24_hour_messaging_window?
false
end
def web_widget_script def web_widget_script
" "
<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 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 private
def delete_round_robin_agents 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.csat_survey_enabled resource.csat_survey_enabled
json.working_hours resource.weekly_schedule json.working_hours resource.weekly_schedule
json.timezone resource.timezone json.timezone resource.timezone
json.webhook_url resource.webhook_url
json.avatar_url resource.try(:avatar_url) json.avatar_url resource.try(:avatar_url)
json.page_id resource.channel.try(:page_id) json.page_id resource.channel.try(:page_id)
json.widget_color resource.channel.try(:widget_color) json.widget_color resource.channel.try(:widget_color)

View file

@ -242,6 +242,7 @@ Rails.application.routes.draw do
mount Facebook::Messenger::Server, at: 'bot' mount Facebook::Messenger::Server, at: 'bot'
get 'webhooks/twitter', to: 'api/v1/webhooks#twitter_crc' get 'webhooks/twitter', to: 'api/v1/webhooks#twitter_crc'
post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events' post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events'
post 'webhooks/line/:line_channel_id', to: 'webhooks/line#process_payload'
post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload' post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload'
namespace :twitter do namespace :twitter do

View file

@ -0,0 +1,11 @@
class AddLineChannel < ActiveRecord::Migration[6.1]
def change
create_table :channel_line do |t|
t.integer :account_id, null: false
t.string :line_channel_id, null: false, index: { unique: true }
t.string :line_channel_secret, null: false
t.string :line_channel_token, null: false
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_08_28_124043) do ActiveRecord::Schema.define(version: 2021_08_29_124254) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -185,6 +185,16 @@ ActiveRecord::Schema.define(version: 2021_08_28_124043) do
t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id" t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id"
end end
create_table "channel_line", force: :cascade do |t|
t.integer "account_id", null: false
t.string "line_channel_id", null: false
t.string "line_channel_secret", null: false
t.string "line_channel_token", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["line_channel_id"], name: "index_channel_line_on_line_channel_id", unique: true
end
create_table "channel_telegram", force: :cascade do |t| create_table "channel_telegram", force: :cascade do |t|
t.string "bot_name" t.string "bot_name"
t.integer "account_id", null: false t.integer "account_id", null: false

View file

@ -285,6 +285,28 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.body).to include('test@test.com') expect(response.body).to include('test@test.com')
end end
it 'creates an api inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'API Inbox', channel: { type: 'api', webhook_url: 'http://test.com' } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('API Inbox')
end
it 'creates a line inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'Line Inbox',
channel: { type: 'line', line_channel_id: SecureRandom.uuid, line_channel_secret: SecureRandom.uuid,
line_channel_token: SecureRandom.uuid } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('Line Inbox')
end
end end
end end

View file

@ -0,0 +1,12 @@
require 'rails_helper'
RSpec.describe 'Webhooks::LineController', type: :request do
describe 'POST /webhooks/line/{:line_channel_id}' do
it 'call the line events job with the params' do
allow(Webhooks::LineEventsJob).to receive(:perform_later)
expect(Webhooks::LineEventsJob).to receive(:perform_later)
post '/webhooks/line/line_channel_id', params: { content: 'hello' }
expect(response).to have_http_status(:success)
end
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_line, class: 'Channel::Line' do
line_channel_id { SecureRandom.uuid }
line_channel_secret { SecureRandom.uuid }
line_channel_token { SecureRandom.uuid }
inbox
account
end
end

View file

@ -55,5 +55,14 @@ RSpec.describe SendReplyJob, type: :job do
expect(process_service).to receive(:perform) expect(process_service).to receive(:perform)
described_class.perform_now(message.id) described_class.perform_now(message.id)
end end
it 'calls ::Line:SendOnLineService when its line message' do
line_channel = create(:channel_line)
message = create(:message, conversation: create(:conversation, inbox: line_channel.inbox))
allow(::Line::SendOnLineService).to receive(:new).with(message: message).and_return(process_service)
expect(::Line::SendOnLineService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end end
end end

View file

@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Webhooks::LineEventsJob, type: :job do
subject(:job) { described_class.perform_later(params: params) }
let!(:line_channel) { create(:channel_line) }
let!(:params) { { line_channel_id: line_channel.line_channel_id, 'line' => { test: 'test' } } }
let(:post_body) { params.to_json }
let(:signature) { Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), line_channel.line_channel_secret, post_body)) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params: params)
.on_queue('default')
end
context 'when invalid params' do
it 'returns nil when no line_channel_id' do
expect(described_class.perform_now(params: {})).to be_nil
end
it 'returns nil when invalid bot_token' do
expect(described_class.perform_now(params: { 'line_channel_id' => 'invalid_id', 'line' => { test: 'test' } })).to be_nil
end
end
context 'when valid params' do
it 'calls Line::IncomingMessageService' do
process_service = double
allow(Line::IncomingMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Line::IncomingMessageService).to receive(:new).with(inbox: line_channel.inbox,
params: params['line'].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params: params, post_body: post_body, signature: signature)
end
end
end

View file

@ -0,0 +1,59 @@
require 'rails_helper'
describe Line::IncomingMessageService do
let!(:line_channel) { create(:channel_line) }
let(:params) do
{
'destination': '2342234234',
'events': [
{
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
'type': 'message',
'mode': 'active',
'timestamp': 1_462_629_479_859,
'source': {
'type': 'user',
'userId': 'U4af4980629'
},
'message': {
'id': '325708',
'type': 'text',
'text': 'Hello, world'
}
},
{
'replyToken': '8cf9239d56244f4197887e939187e19e',
'type': 'follow',
'mode': 'active',
'timestamp': 1_462_629_479_859,
'source': {
'type': 'user',
'userId': 'U4af4980629'
}
}
]
}.with_indifferent_access
end
describe '#perform' do
context 'when valid text message params' do
it 'creates appropriate conversations, message and contacts' do
line_bot = double
line_user_profile = double
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
allow(line_user_profile).to receive(:body).and_return(
{
'displayName': 'LINE Test',
'userId': 'U4af4980629',
'pictureUrl': 'https://test.com'
}.to_json
)
described_class.new(inbox: line_channel.inbox, params: params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE Test')
expect(line_channel.inbox.messages.first.content).to eq('Hello, world')
end
end
end
end

View file

@ -0,0 +1,18 @@
require 'rails_helper'
describe Line::SendOnLineService do
describe '#perform' do
context 'when a valid message' do
it 'calls @channel.client.push_message' do
line_client = double
line_channel = create(:channel_line)
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: line_channel.inbox))
allow(line_client).to receive(:push_message)
allow(Line::Bot::Client).to receive(:new).and_return(line_client)
expect(line_client).to receive(:push_message)
described_class.new(message: message).perform
end
end
end
end