Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
70
research/chatwoot/lib/integrations/slack/channel_builder.rb
Normal file
70
research/chatwoot/lib/integrations/slack/channel_builder.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
class Integrations::Slack::ChannelBuilder
|
||||
attr_reader :params, :channel
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def fetch_channels
|
||||
channels
|
||||
end
|
||||
|
||||
def update(reference_id)
|
||||
update_reference_id(reference_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= params[:hook]
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
|
||||
end
|
||||
|
||||
def channels
|
||||
# Split channel fetching into separate API calls to avoid rate limiting issues.
|
||||
# Slack's API handles single-type requests (public OR private) much more efficiently
|
||||
# than mixed-type requests (public AND private). This approach eliminates rate limits
|
||||
# that occur when requesting both channel types simultaneously.
|
||||
channel_list = []
|
||||
|
||||
# Step 1: Fetch all private channels in one call (expect very few)
|
||||
private_channels = fetch_channels_by_type('private_channel')
|
||||
channel_list.concat(private_channels)
|
||||
|
||||
# Step 2: Fetch public channels with pagination
|
||||
public_channels = fetch_channels_by_type('public_channel')
|
||||
channel_list.concat(public_channels)
|
||||
channel_list
|
||||
end
|
||||
|
||||
def fetch_channels_by_type(channel_type, limit: 1000)
|
||||
conversations_list = slack_client.conversations_list(types: channel_type, exclude_archived: true, limit: limit)
|
||||
channel_list = conversations_list.channels
|
||||
while conversations_list.response_metadata.next_cursor.present?
|
||||
conversations_list = slack_client.conversations_list(
|
||||
cursor: conversations_list.response_metadata.next_cursor,
|
||||
types: channel_type,
|
||||
exclude_archived: true,
|
||||
limit: limit
|
||||
)
|
||||
channel_list.concat(conversations_list.channels)
|
||||
end
|
||||
channel_list
|
||||
end
|
||||
|
||||
def find_channel(reference_id)
|
||||
channels.find { |channel| channel['id'] == reference_id }
|
||||
end
|
||||
|
||||
def update_reference_id(reference_id)
|
||||
channel = find_channel(reference_id)
|
||||
return if channel.blank?
|
||||
|
||||
slack_client.conversations_join(channel: channel[:id]) if channel[:is_private] == false
|
||||
@hook.update!(reference_id: channel[:id], settings: { channel_name: channel[:name] }, status: 'enabled')
|
||||
@hook
|
||||
end
|
||||
end
|
||||
42
research/chatwoot/lib/integrations/slack/hook_builder.rb
Normal file
42
research/chatwoot/lib/integrations/slack/hook_builder.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Integrations::Slack::HookBuilder
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def perform
|
||||
token = fetch_access_token
|
||||
|
||||
hook = account.hooks.new(
|
||||
access_token: token,
|
||||
status: 'disabled',
|
||||
inbox_id: params[:inbox_id],
|
||||
app_id: 'slack'
|
||||
)
|
||||
|
||||
hook.save!
|
||||
hook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
params[:account]
|
||||
end
|
||||
|
||||
def hook_type
|
||||
params[:inbox_id] ? 'inbox' : 'account'
|
||||
end
|
||||
|
||||
def fetch_access_token
|
||||
client = Slack::Web::Client.new
|
||||
slack_access = client.oauth_v2_access(
|
||||
client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
|
||||
client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
|
||||
code: params[:code],
|
||||
redirect_uri: Integrations::App.slack_integration_url
|
||||
)
|
||||
slack_access['access_token']
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,103 @@
|
||||
class Integrations::Slack::IncomingMessageBuilder
|
||||
include Integrations::Slack::SlackMessageHelper
|
||||
|
||||
attr_reader :params
|
||||
|
||||
SUPPORTED_EVENT_TYPES = %w[event_callback url_verification].freeze
|
||||
SUPPORTED_EVENTS = %w[message link_shared].freeze
|
||||
SUPPORTED_MESSAGE_TYPES = %w[rich_text].freeze
|
||||
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless valid_event?
|
||||
|
||||
if hook_verification?
|
||||
verify_hook
|
||||
elsif process_message_payload?
|
||||
process_message_payload
|
||||
elsif link_shared?
|
||||
SlackUnfurlJob.perform_later(params)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_event?
|
||||
supported_event_type? && supported_event? && should_process_event?
|
||||
end
|
||||
|
||||
def supported_event_type?
|
||||
SUPPORTED_EVENT_TYPES.include?(params[:type])
|
||||
end
|
||||
|
||||
# Discard all the subtype of a message event
|
||||
# We are only considering the actual message sent by a Slack user
|
||||
# Any reactions or messages sent by the bot will be ignored.
|
||||
# https://api.slack.com/events/message#subtypes
|
||||
def should_process_event?
|
||||
return true if params[:type] != 'event_callback'
|
||||
|
||||
params[:event][:user].present? && valid_event_subtype?
|
||||
end
|
||||
|
||||
def valid_event_subtype?
|
||||
params[:event][:subtype].blank? || params[:event][:subtype] == 'file_share'
|
||||
end
|
||||
|
||||
def supported_event?
|
||||
hook_verification? || SUPPORTED_EVENTS.include?(params[:event][:type])
|
||||
end
|
||||
|
||||
def supported_message?
|
||||
if message.present?
|
||||
SUPPORTED_MESSAGE_TYPES.include?(message[:type]) && !attached_file_message?
|
||||
else
|
||||
params[:event][:files].present? && !attached_file_message?
|
||||
end
|
||||
end
|
||||
|
||||
def hook_verification?
|
||||
params[:type] == 'url_verification'
|
||||
end
|
||||
|
||||
def thread_timestamp_available?
|
||||
params[:event][:thread_ts].present?
|
||||
end
|
||||
|
||||
def process_message_payload?
|
||||
thread_timestamp_available? && supported_message? && integration_hook
|
||||
end
|
||||
|
||||
def link_shared?
|
||||
params[:event][:type] == 'link_shared'
|
||||
end
|
||||
|
||||
def message
|
||||
params[:event][:blocks]&.first
|
||||
end
|
||||
|
||||
def verify_hook
|
||||
{
|
||||
challenge: params[:challenge]
|
||||
}
|
||||
end
|
||||
|
||||
def integration_hook
|
||||
@integration_hook ||= Integrations::Hook.find_by(reference_id: params[:event][:channel])
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token)
|
||||
end
|
||||
|
||||
# Ignoring the changes added here https://github.com/chatwoot/chatwoot/blob/5b5a6d89c0cf7f3148a1439d6fcd847784a79b94/lib/integrations/slack/send_on_slack_service.rb#L69
|
||||
# This make sure 'Attached File!' comment is not visible on CW dashboard.
|
||||
# This is showing because of https://github.com/chatwoot/chatwoot/pull/4494/commits/07a1c0da1e522d76e37b5f0cecdb4613389ab9b6 change.
|
||||
# As now we consider the postback message with event[:files]
|
||||
def attached_file_message?
|
||||
params[:event][:text] == 'Attached File!'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Integrations::Slack::LinkUnfurlFormatter
|
||||
pattr_initialize [:url!, :user_info!, :inbox_name!, :inbox_type!]
|
||||
|
||||
def perform
|
||||
return {} if url.blank?
|
||||
|
||||
{
|
||||
url => {
|
||||
'blocks' => preivew_blocks(user_info) +
|
||||
open_conversation_button(url)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preivew_blocks(user_info)
|
||||
[
|
||||
{
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
preview_field(I18n.t('slack_unfurl.fields.name'), user_info[:user_name]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.email'), user_info[:email]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.phone_number'), user_info[:phone_number]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.company_name'), user_info[:company_name]),
|
||||
preview_field(I18n.t('slack_unfurl.fields.inbox_name'), inbox_name),
|
||||
preview_field(I18n.t('slack_unfurl.fields.inbox_type'), inbox_type)
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def preview_field(label, value)
|
||||
{
|
||||
'type' => 'mrkdwn',
|
||||
'text' => "*#{label}:*\n#{value}"
|
||||
}
|
||||
end
|
||||
|
||||
def open_conversation_button(url)
|
||||
[
|
||||
{
|
||||
'type' => 'actions',
|
||||
'elements' => [
|
||||
{
|
||||
'type' => 'button',
|
||||
'text' => {
|
||||
'type' => 'plain_text',
|
||||
'text' => I18n.t('slack_unfurl.button'),
|
||||
'emoji' => true
|
||||
},
|
||||
'url' => url,
|
||||
'action_id' => 'button-action'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,215 @@
|
||||
class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
include RegexHelper
|
||||
pattr_initialize [:message!, :hook!]
|
||||
|
||||
def perform
|
||||
# overriding the base class logic since the validations are different in this case.
|
||||
# FIXME: for now we will only send messages from widget to slack
|
||||
return unless valid_channel_for_slack?
|
||||
# we don't want message loop in slack
|
||||
return if message.external_source_id_slack.present?
|
||||
# we don't want to start slack thread from agent conversation as of now
|
||||
return if invalid_message?
|
||||
|
||||
perform_reply
|
||||
end
|
||||
|
||||
def link_unfurl(event)
|
||||
slack_client.chat_unfurl(
|
||||
event
|
||||
)
|
||||
# You may wonder why we're not requesting reauthorization and disabling hooks when scope errors occur.
|
||||
# Since link unfurling is just a nice-to-have feature that doesn't affect core functionality, we will silently ignore these errors.
|
||||
rescue Slack::Web::Api::Errors::MissingScope => e
|
||||
Rails.logger.warn "Slack: Missing scope error: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_channel_for_slack?
|
||||
# slack wouldn't be an ideal interface to reply to tweets, hence disabling that case
|
||||
return false if channel.is_a?(Channel::TwitterProfile) && conversation.additional_attributes['type'] == 'tweet'
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def invalid_message?
|
||||
(message.outgoing? || message.template?) && conversation.identifier.blank?
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
send_message
|
||||
|
||||
return unless @slack_message
|
||||
|
||||
update_reference_id
|
||||
update_external_source_id_slack
|
||||
end
|
||||
|
||||
def message_content
|
||||
private_indicator = message.private? ? 'private: ' : ''
|
||||
sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content)
|
||||
|
||||
if conversation.identifier.present?
|
||||
"#{private_indicator}#{sanitized_content}"
|
||||
else
|
||||
"#{formatted_inbox_name}#{formatted_conversation_link}#{email_subject_line}\n#{sanitized_content}"
|
||||
end
|
||||
end
|
||||
|
||||
def format_message_content
|
||||
message.message_type == 'activity' ? "_#{message_text}_" : message_text
|
||||
end
|
||||
|
||||
def message_text
|
||||
content = message.processed_message_content || message.content
|
||||
|
||||
if content.present?
|
||||
content.gsub(MENTION_REGEX, '\1')
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def formatted_inbox_name
|
||||
"\n*Inbox:* #{message.inbox.name} (#{message.inbox.inbox_type})\n"
|
||||
end
|
||||
|
||||
def formatted_conversation_link
|
||||
"#{link_to_conversation} to view the conversation.\n"
|
||||
end
|
||||
|
||||
def email_subject_line
|
||||
return '' unless message.inbox.email?
|
||||
|
||||
email_payload = message.content_attributes['email']
|
||||
return "*Subject:* #{email_payload['subject']}\n\n" if email_payload.present? && email_payload['subject'].present?
|
||||
|
||||
''
|
||||
end
|
||||
|
||||
def avatar_url(sender)
|
||||
sender_type = sender_type(sender).downcase
|
||||
blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil
|
||||
generate_url(sender_type, blob_key)
|
||||
end
|
||||
|
||||
def generate_url(sender_type, blob_key)
|
||||
base_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
"#{base_url}/slack_uploads?blob_key=#{blob_key}&sender_type=#{sender_type}"
|
||||
end
|
||||
|
||||
def send_message
|
||||
post_message if message_content.present?
|
||||
upload_files if message.attachments.any?
|
||||
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
|
||||
Slack::Web::Api::Errors::InvalidAuth,
|
||||
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
|
||||
Rails.logger.error e
|
||||
hook.prompt_reauthorization!
|
||||
hook.disable
|
||||
end
|
||||
|
||||
def post_message
|
||||
@slack_message = slack_client.chat_postMessage(
|
||||
channel: hook.reference_id,
|
||||
text: message_content,
|
||||
username: sender_name(message.sender),
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: avatar_url(message.sender),
|
||||
unfurl_links: conversation.identifier.present?
|
||||
)
|
||||
end
|
||||
|
||||
def upload_files
|
||||
files = build_files_array
|
||||
return if files.empty?
|
||||
|
||||
begin
|
||||
result = slack_client.files_upload_v2(
|
||||
files: files,
|
||||
initial_comment: 'Attached File!',
|
||||
thread_ts: conversation.identifier,
|
||||
channel_id: hook.reference_id
|
||||
)
|
||||
Rails.logger.info "slack_upload_result: #{result}"
|
||||
rescue Slack::Web::Api::Errors::SlackError => e
|
||||
Rails.logger.error "Failed to upload files: #{e.message}"
|
||||
ensure
|
||||
files.each { |file| file[:content]&.clear }
|
||||
end
|
||||
end
|
||||
|
||||
def build_files_array
|
||||
message.attachments.filter_map do |attachment|
|
||||
next unless attachment.with_attached_file?
|
||||
|
||||
build_file_payload(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def build_file_payload(attachment)
|
||||
content = download_attachment_content(attachment)
|
||||
return if content.blank?
|
||||
|
||||
{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: content,
|
||||
title: attachment.file.filename.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def download_attachment_content(attachment)
|
||||
buffer = +''
|
||||
attachment.file.blob.open do |file|
|
||||
while (chunk = file.read(64.kilobytes))
|
||||
buffer << chunk
|
||||
end
|
||||
end
|
||||
buffer
|
||||
end
|
||||
|
||||
def sender_name(sender)
|
||||
sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender)
|
||||
end
|
||||
|
||||
def sender_type(sender)
|
||||
if sender.instance_of?(Contact)
|
||||
'Contact'
|
||||
elsif sender.instance_of?(User)
|
||||
'Agent'
|
||||
elsif message.message_type == 'activity' && sender.nil?
|
||||
'System'
|
||||
else
|
||||
'Bot'
|
||||
end
|
||||
end
|
||||
|
||||
def update_reference_id
|
||||
return unless should_update_reference_id?
|
||||
|
||||
conversation.update!(identifier: @slack_message['ts'])
|
||||
end
|
||||
|
||||
def update_external_source_id_slack
|
||||
return unless @slack_message['message']
|
||||
|
||||
message.update!(external_source_id_slack: "cw-origin-#{@slack_message['message']['ts']}")
|
||||
end
|
||||
|
||||
def slack_client
|
||||
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
|
||||
end
|
||||
|
||||
def link_to_conversation
|
||||
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{conversation.account_id}/conversations/#{conversation.display_id}|Click here>"
|
||||
end
|
||||
|
||||
# Determines whether the conversation identifier should be updated with the ts value.
|
||||
# The identifier should be updated in the following cases:
|
||||
# - If the conversation identifier is blank, it means a new conversation is being created.
|
||||
# - If the thread_ts is blank, it means that the conversation was previously connected in a different channel.
|
||||
def should_update_reference_id?
|
||||
conversation.identifier.blank? || @slack_message['message']['thread_ts'].blank?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class Integrations::Slack::SlackLinkUnfurlService
|
||||
pattr_initialize [:params!, :integration_hook!]
|
||||
|
||||
def perform
|
||||
event_links = params.dig(:event, :links)
|
||||
return unless event_links
|
||||
|
||||
event_links.each do |link_info|
|
||||
url = link_info[:url]
|
||||
# Unfurl only if the account id is same as the integration hook account id
|
||||
unfurl_link(url) if url && valid_account?(url)
|
||||
end
|
||||
end
|
||||
|
||||
def unfurl_link(url)
|
||||
conversation = conversation_from_url(url)
|
||||
return unless conversation
|
||||
|
||||
send_unfurls(url, conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contact_attributes(conversation)
|
||||
contact = conversation.contact
|
||||
{
|
||||
user_name: contact.name.presence || '---',
|
||||
email: contact.email.presence || '---',
|
||||
phone_number: contact.phone_number.presence || '---',
|
||||
company_name: contact.additional_attributes&.dig('company_name').presence || '---'
|
||||
}
|
||||
end
|
||||
|
||||
def generate_unfurls(url, user_info, inbox)
|
||||
Integrations::Slack::LinkUnfurlFormatter.new(
|
||||
url: url,
|
||||
user_info: user_info,
|
||||
inbox_name: inbox.name,
|
||||
inbox_type: inbox.channel.name
|
||||
).perform
|
||||
end
|
||||
|
||||
def send_unfurls(url, conversation)
|
||||
user_info = contact_attributes(conversation)
|
||||
unfurls = generate_unfurls(url, user_info, conversation.inbox)
|
||||
unfurl_params = {
|
||||
unfurl_id: params.dig(:event, :unfurl_id),
|
||||
source: params.dig(:event, :source),
|
||||
unfurls: JSON.generate(unfurls)
|
||||
}
|
||||
|
||||
slack_service = Integrations::Slack::SendOnSlackService.new(
|
||||
message: nil,
|
||||
hook: integration_hook
|
||||
)
|
||||
slack_service.link_unfurl(unfurl_params)
|
||||
end
|
||||
|
||||
def conversation_from_url(url)
|
||||
conversation_id = extract_conversation_id(url)
|
||||
find_conversation_by_id(conversation_id) if conversation_id
|
||||
end
|
||||
|
||||
def find_conversation_by_id(conversation_id)
|
||||
Conversation.find_by(display_id: conversation_id, account_id: integration_hook.account_id)
|
||||
end
|
||||
|
||||
def valid_account?(url)
|
||||
account_id = extract_account_id(url)
|
||||
account_id == integration_hook.account_id.to_s
|
||||
end
|
||||
|
||||
def extract_account_id(url)
|
||||
account_id_regex = %r{/accounts/(\d+)}
|
||||
match_data = url.match(account_id_regex)
|
||||
match_data[1] if match_data
|
||||
end
|
||||
|
||||
def extract_conversation_id(url)
|
||||
conversation_id_regex = %r{/conversations/(\d+)}
|
||||
match_data = url.match(conversation_id_regex)
|
||||
match_data[1] if match_data
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
module Integrations::Slack::SlackMessageHelper
|
||||
def process_message_payload
|
||||
return unless conversation
|
||||
|
||||
handle_conversation
|
||||
success_response
|
||||
rescue Slack::Web::Api::Errors::MissingScope => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
disable_and_reauthorize
|
||||
end
|
||||
|
||||
def handle_conversation
|
||||
create_message unless message_exists?
|
||||
end
|
||||
|
||||
def success_response
|
||||
{ status: 'success' }
|
||||
end
|
||||
|
||||
def disable_and_reauthorize
|
||||
integration_hook.prompt_reauthorization!
|
||||
integration_hook.disable
|
||||
end
|
||||
|
||||
def message_exists?
|
||||
conversation.messages.exists?(external_source_ids: { slack: params[:event][:ts] })
|
||||
end
|
||||
|
||||
def create_message
|
||||
@message = conversation.messages.build(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
|
||||
external_source_id_slack: params[:event][:ts],
|
||||
private: private_note?,
|
||||
sender: sender
|
||||
)
|
||||
process_attachments(params[:event][:files]) if attachments_present?
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def attachments_present?
|
||||
params[:event][:files].present?
|
||||
end
|
||||
|
||||
def process_attachments(attachments)
|
||||
attachments.each do |attachment|
|
||||
tempfile = Down::NetHttp.download(attachment[:url_private], headers: { 'Authorization' => "Bearer #{integration_hook.access_token}" })
|
||||
|
||||
attachment_params = {
|
||||
file_type: file_type(attachment),
|
||||
account_id: @message.account_id,
|
||||
external_url: attachment[:url_private],
|
||||
file: {
|
||||
io: tempfile,
|
||||
filename: tempfile.original_filename,
|
||||
content_type: tempfile.content_type
|
||||
}
|
||||
}
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params)
|
||||
attachment_obj.file.content_type = attachment[:mimetype]
|
||||
end
|
||||
end
|
||||
|
||||
def file_type(attachment)
|
||||
return if attachment[:mimetype] == 'text/plain'
|
||||
|
||||
case attachment[:filetype]
|
||||
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
|
||||
:image
|
||||
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
|
||||
:video
|
||||
else
|
||||
:file
|
||||
end
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
|
||||
end
|
||||
|
||||
def sender
|
||||
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
|
||||
conversation.account.users.from_email(user_email)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user