Delta chat WIP

This commit is contained in:
Darren Clarke 2026-02-14 21:37:50 +01:00
parent 40c14ece94
commit 9601e179bc
32 changed files with 2037 additions and 1 deletions

View file

@ -0,0 +1,249 @@
class ChannelCdrDeltachat extends App.ControllerSubContent
requiredPermission: 'admin.channel_cdr_deltachat'
events:
'click .js-new': 'new'
'click .js-edit': 'edit'
'click .js-delete': 'delete'
'click .js-disable': 'disable'
'click .js-enable': 'enable'
'click .js-rotate-token': 'rotateToken'
constructor: ->
super
#@interval(@load, 60000)
@load()
load: =>
@startLoading()
@ajax(
id: 'cdr_deltachat_index'
type: 'GET'
url: "#{@apiPath}/channels_cdr_deltachat"
processData: true
success: (data) =>
@stopLoading()
App.Collection.loadAssets(data.assets)
@render(data)
)
render: (data) =>
channels = []
for channel_id in data.channel_ids
channel = App.Channel.find(channel_id)
if channel && channel.options
displayName = '-'
if channel.group_id
group = App.Group.find(channel.group_id)
displayName = group.displayName()
channel.options.groupName = displayName
channels.push channel
@html App.view('cdr_deltachat/index')(
channels: channels
)
new: (e) =>
e.preventDefault()
new FormAdd(
container: @el.parents('.content')
load: @load
)
edit: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
channel = App.Channel.find(id)
new FormEdit(
channel: channel
container: @el.parents('.content')
load: @load
)
delete: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
new App.ControllerConfirm(
message: 'Sure?'
callback: =>
@ajax(
id: 'cdr_deltachat_delete'
type: 'DELETE'
url: "#{@apiPath}/channels_cdr_deltachat"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
container: @el.closest('.content')
)
rotateToken: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
new App.ControllerConfirm(
message: 'This will break the submission form!'
buttonSubmit: 'Reset token'
head: 'Reset the submission token?'
callback: =>
@ajax(
id: 'cdr_deltachat_disable'
type: 'POST'
url: "#{@apiPath}/channels_cdr_deltachat_rotate_token"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
container: @el.closest('.content')
)
disable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'cdr_deltachat_disable'
type: 'POST'
url: "#{@apiPath}/channels_cdr_deltachat_disable"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
enable: (e) =>
e.preventDefault()
id = $(e.target).closest('.action').data('id')
@ajax(
id: 'cdr_deltachat_enable'
type: 'POST'
url: "#{@apiPath}/channels_cdr_deltachat_enable"
data: JSON.stringify(id: id)
processData: true
success: =>
@load()
)
class FormAdd extends App.ControllerModal
head: 'Add DeltaChat Bot'
shown: true
button: 'Add'
buttonCancel: true
small: true
content: ->
content = $(App.view('cdr_deltachat/form_add')())
createOrgSelection = (selected_id) ->
return App.UiElement.select.render(
name: 'organization_id'
multiple: false
limit: 100
null: false
relation: 'Organization'
nulloption: true
value: selected_id
class: 'form-control--small'
)
createGroupSelection = (selected_id) ->
return App.UiElement.select.render(
name: 'group_id'
multiple: false
limit: 100
null: false
relation: 'Group'
nulloption: true
value: selected_id
class: 'form-control--small'
)
content.find('.js-select').on('click', (e) =>
@selectAll(e)
)
content.find('.js-messagesGroup').replaceWith createGroupSelection(1)
content.find('.js-organization').replaceWith createOrgSelection(null)
content
onClosed: =>
return if !@isChanged
@isChanged = false
@load()
onSubmit: (e) =>
@formDisable(e)
@ajax(
id: 'cdr_deltachat_app_verify'
type: 'POST'
url: "#{@apiPath}/channels_cdr_deltachat"
data: JSON.stringify(@formParams())
processData: true
success: =>
@isChanged = true
@close()
error: (xhr) =>
data = JSON.parse(xhr.responseText)
@formEnable(e)
error_message = App.i18n.translateContent(data.error || 'Unable to save DeltaChat bot.')
@el.find('.alert').removeClass('hidden').text(error_message)
)
class FormEdit extends App.ControllerModal
head: 'DeltaChat Bot Info'
shown: true
buttonCancel: true
content: ->
content = $(App.view('cdr_deltachat/form_edit')(channel: @channel))
createOrgSelection = (selected_id) ->
return App.UiElement.select.render(
name: 'organization_id'
multiple: false
limit: 100
null: false
relation: 'Organization'
nulloption: true
value: selected_id
class: 'form-control--small'
)
createGroupSelection = (selected_id) ->
return App.UiElement.select.render(
name: 'group_id'
multiple: false
limit: 100
null: false
relation: 'Group'
nulloption: true
value: selected_id
class: 'form-control--small'
)
content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id)
content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id)
content
onClosed: =>
return if !@isChanged
@isChanged = false
@load()
onSubmit: (e) =>
@formDisable(e)
params = @formParams()
@channel.options = params
@ajax(
id: 'channel_cdr_deltachat_update'
type: 'PUT'
url: "#{@apiPath}/channels_cdr_deltachat/#{@channel.id}"
data: JSON.stringify(@formParams())
processData: true
success: =>
@isChanged = true
@close()
error: (xhr) =>
data = JSON.parse(xhr.responseText)
@formEnable(e)
error_message = App.i18n.translateContent(data.error || 'Unable to save changes.')
@el.find('.alert').removeClass('hidden').text(error_message)
)
App.Config.set('cdr_deltachat', { prio: 5200, name: 'DeltaChat', parent: '#channels', target: '#channels/cdr_deltachat', controller: ChannelCdrDeltachat, permission: ['admin.channel_cdr_deltachat'] }, 'NavBarAdmin')

View file

@ -0,0 +1,79 @@
class CdrDeltachatReply
@action: (actions, ticket, article, ui) ->
return actions if ui.permissionCheck('ticket.customer')
if article.sender.name is 'Customer' && article.type.name is 'cdr_deltachat'
actions.push {
name: 'reply'
type: 'cdrDeltachatMessageReply'
icon: 'reply'
href: '#'
}
actions
@perform: (articleContainer, type, ticket, article, ui) ->
return true if type isnt 'cdrDeltachatMessageReply'
ui.scrollToCompose()
# get reference article
type = App.TicketArticleType.find(article.type_id)
articleNew = {
to: ''
cc: ''
body: ''
in_reply_to: ''
}
if article.message_id
articleNew.in_reply_to = article.message_id
# get current body
articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || ''
App.Event.trigger('ui::ticket::setArticleType', {
ticket: ticket
type: type
article: articleNew
position: 'end'
})
true
@articleTypes: (articleTypes, ticket, ui) ->
return articleTypes if !ui.permissionCheck('ticket.agent')
return articleTypes if !ticket || !ticket.create_article_type_id
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
return articleTypes if articleTypeCreate isnt 'cdr_deltachat'
articleTypes.push {
name: 'cdr_deltachat'
icon: 'cdr-deltachat'
attributes: []
internal: false,
features: ['attachment']
maxTextLength: 10000
warningTextLength: 5000
}
articleTypes
@setArticleTypePost: (type, ticket, ui) ->
return if type isnt 'cdr_deltachat'
rawHTML = ui.$('[data-name=body]').html()
cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML)
if cleanHTML && cleanHTML.html() != rawHTML
ui.$('[data-name=body]').html(cleanHTML)
@params: (type, params, ui) ->
if type is 'cdr_deltachat'
App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false)
params.content_type = 'text/plain'
params.body = App.Utils.html2text(params.body, true)
params
App.Config.set('310-CdrDeltachatReply', CdrDeltachatReply, 'TicketZoomArticleAction')

View file

@ -0,0 +1,47 @@
<div class="alert alert--danger hidden" role="alert"></div>
<fieldset>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Email Address') %> <span>*</span></label>
</div>
<div class="controls">
<input id="email_address" type="text" name="email_address" value="" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_endpoint" type="text" name="bot_endpoint" value="" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the group in which form submissions will get added to.') %> <span>*</span></label>
</div>
<div class="controls">
<div class="js-messagesGroup"></div>
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the organization to which submitters will be added to when they submit via this form.') %> <span>*</span></label>
</div>
<div class="controls">
<div class="profile-organization js-organization"></div>
</div>
</div>
</fieldset>

View file

@ -0,0 +1,55 @@
<div class="alert alert--danger hidden" role="alert"></div>
<fieldset>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Email Address') %> <span>*</span></label>
</div>
<div class="controls">
<input id="email_address" type="text" name="email_address" value="<%= @channel.options.email_address %>" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
</div>
<div class="controls">
<input id="bot_endpoint" type="text" name="bot_endpoint" value="<%= @channel.options.bot_endpoint %>" class="form-control" required autocomplete="off">
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the group in which incoming messages will be added to.') %> <span>*</span></label>
</div>
<div class="controls">
<div class="js-messagesGroup"></div>
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this bot.') %> <span>*</span></label>
</div>
<div class="controls">
<div class="profile-organization js-organization"></div>
</div>
</div>
<div class="input form-group">
<div class="formGroup-label">
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
</div>
<div class="controls">
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_deltachat_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
</div>
</div>
</fieldset>

View file

@ -0,0 +1,49 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('DeltaChat') %></h1>
</div>
<div class="page-header-meta">
<a class="btn btn--success js-new"><%- @T('Add DeltaChat bot') %></a>
</div>
</div>
<div class="page-content">
<% if _.isEmpty(@channels): %>
<div class="page-description">
<p><%- @T('You have no configured %s right now.', 'DeltaChat bots') %></p>
</div>
<% else: %>
<% for channel in @channels: %>
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
<div class="action-block action-row">
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.email_address %></h2>
</div>
<div class="action-flow action-flow--row">
<div class="action-block">
<h3><%- @T('Group') %></h3>
<% if channel.options: %>
<%= channel.options.groupName %>
<% end %>
</div>
<div class="action-block">
<h3><%- @T('Endpoint URL') %></h3>
<%- @T('Click the edit button to view the endpoint details ') %>
</div>
</div>
<div class="action-controls">
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
<div class="btn btn--danger btn--secondary js-rotate-token"><%- @T('Reset Token') %></div>
<% if channel.active is true: %>
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
<% else: %>
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
<% end %>
<div class="btn js-edit"><%- @T('Edit') %></div>
</div>
</div>
<% end %>
</div>

View file

@ -0,0 +1,4 @@
.icon-cdr-deltachat {
width: 17px;
height: 17px;
}

View file

@ -0,0 +1,290 @@
# frozen_string_literal: true
class ChannelsCdrDeltachatController < ApplicationController
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook, :bot_webhook]
skip_before_action :verify_csrf_token, only: [:webhook, :bot_webhook]
include CreatesTicketArticles
def index
assets = {}
channel_ids = []
Channel.where(area: 'DeltaChat::Account').order(:id).each do |channel|
assets = channel.assets(assets)
channel_ids.push channel.id
end
render json: {
assets: assets,
channel_ids: channel_ids
}
end
def add
begin
errors = {}
errors['group_id'] = 'required' if params[:group_id].blank?
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
channel = Channel.create(
area: 'DeltaChat::Account',
options: {
adapter: 'cdr_deltachat',
email_address: params[:email_address],
bot_token: params[:bot_token],
bot_endpoint: params[:bot_endpoint],
token: SecureRandom.urlsafe_base64(48),
organization_id: params[:organization_id]
},
group_id: params[:group_id],
active: true
)
rescue StandardError => e
raise Exceptions::UnprocessableEntity, e.message
end
render json: channel
end
def update
errors = {}
errors['group_id'] = 'required' if params[:group_id].blank?
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
begin
channel.options[:email_address] = params[:email_address]
channel.options[:bot_token] = params[:bot_token]
channel.options[:bot_endpoint] = params[:bot_endpoint]
channel.options[:organization_id] = params[:organization_id]
channel.group_id = params[:group_id]
channel.save!
rescue StandardError => e
raise Exceptions::UnprocessableEntity, e.message
end
render json: channel
end
def rotate_token
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
channel.options[:token] = SecureRandom.urlsafe_base64(48)
channel.save!
render json: {}
end
def enable
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
channel.active = true
channel.save!
render json: {}
end
def disable
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
channel.active = false
channel.save!
render json: {}
end
def destroy
channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account')
channel.destroy
render json: {}
end
def channel_for_token(token)
return false unless token
Channel.where(area: 'DeltaChat::Account').each do |channel|
return channel if channel.options[:token] == token
end
false
end
def channel_for_bot_token(bot_token)
return false unless bot_token
Channel.where(area: 'DeltaChat::Account').each do |channel|
return channel if channel.options[:bot_token] == bot_token
end
false
end
def bot_webhook
bot_token = params['bot_token']
return render json: {}, status: :unauthorized unless bot_token
channel = channel_for_bot_token(bot_token)
return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active
# Use the channel's webhook token to reuse existing logic
params[:token] = channel.options[:token]
webhook
end
def webhook
token = params['token']
return render json: {}, status: :unauthorized unless token
channel = channel_for_token(token)
return render json: {}, status: :unauthorized if !channel || !channel.active
return render json: {}, status: :unauthorized if channel.options[:token] != token
channel_id = channel.id
# validate input
errors = {}
%i[from
to
message_id
sent_at].each do |field|
errors[field] = 'required' if params[field].blank?
end
if errors.present?
render json: {
errors: errors
}, status: :bad_request
return
end
message_id = params[:message_id]
return if Ticket::Article.exists?(message_id: "cdr_deltachat.#{message_id}")
sender_email = params[:from].strip
bot_email = params[:to].strip
# Customer lookup by email
customer = User.find_by(email: sender_email)
unless customer
role_ids = Role.signup_role_ids
customer = User.create(
firstname: '',
lastname: '',
email: sender_email,
password: '',
note: 'CDR DeltaChat',
active: true,
role_ids: role_ids,
updated_by_id: 1,
created_by_id: 1
)
end
# set current user
UserInfo.current_user_id = customer.id
current_user_set(customer, 'token_auth')
group = Group.find_by(id: channel.group_id)
if group.blank?
Rails.logger.error "DeltaChat channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!"
return render json: { error: 'There was an error during DeltaChat submission' }, status: :internal_server_error
end
organization_id = channel.options['organization_id']
if organization_id.present?
organization = Organization.find_by(id: organization_id)
if organization.blank?
Rails.logger.error "DeltaChat channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!"
return render json: { error: 'There was an error during DeltaChat submission' }, status: :internal_server_error
end
if customer.organization_id.blank?
customer.organization_id = organization.id
customer.save!
end
end
message = params[:message] ||= 'No text content'
sent_at = params[:sent_at]
attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename]
attachment_mimetype = params[:mime_type]
title = "Message from #{sender_email} at #{sent_at}"
body = message
# find ticket or create one
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
if ticket
# check if title need to be updated
ticket.title = title if ticket.title == '-'
new_state = Ticket::State.find_by(default_create: true)
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
else
ticket = Ticket.new(
group_id: channel.group_id,
title: title,
customer_id: customer.id,
preferences: {
channel_id: channel.id,
cdr_deltachat: {
bot_token: channel.options[:bot_token],
chat_id: sender_email
}
}
)
end
ticket.save!
article_params = {
from: sender_email,
to: bot_email,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title,
body: body,
content_type: 'text/plain',
message_id: "cdr_deltachat.#{message_id}",
ticket_id: ticket.id,
internal: false,
preferences: {
cdr_deltachat: {
timestamp: sent_at,
message_id: message_id,
from: sender_email
}
}
}
if attachment_data_base64.present?
article_params[:attachments] = [
{
'filename' => attachment_filename,
:filename => attachment_filename,
:data => attachment_data_base64,
'data' => attachment_data_base64,
'mime-type' => attachment_mimetype
}
]
end
# setting the article type after saving seems to be the only way to get it to stick
ticket.with_lock do
ta = article_create(ticket, article_params)
ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_deltachat').id)
end
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_deltachat').id)
result = {
ticket: {
id: ticket.id,
number: ticket.number
}
}
render json: result, status: :ok
end
end

View file

@ -0,0 +1,7 @@
import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts";
export default <ChannelModule>{
name: "deltachat message",
label: __("DeltaChat Message"),
icon: "cdr-deltachat",
};

View file

@ -0,0 +1,69 @@
import { EnumTicketArticleSenderName } from '#shared/graphql/types.ts'
import type { TicketArticleAction, TicketArticleActionPlugin, TicketArticleType } from './types.ts'
const actionPlugin: TicketArticleActionPlugin = {
order: 370,
addActions(ticket, article) {
const sender = article.sender?.name
const type = article.type?.name
if (sender !== EnumTicketArticleSenderName.Customer || type !== 'deltachat message')
return []
const action: TicketArticleAction = {
apps: ['mobile', 'desktop'],
label: __('Reply'),
name: 'deltachat message',
icon: 'cdr-deltachat',
view: {
agent: ['change'],
},
perform(ticket, article, { openReplyForm }) {
const articleData = {
articleType: type,
inReplyTo: article.messageId,
}
openReplyForm(articleData)
},
}
return [action]
},
addTypes(ticket) {
const descriptionType = ticket.createArticleType?.name
if (descriptionType !== 'deltachat message') return []
const type: TicketArticleType = {
apps: ['mobile', 'desktop'],
value: 'deltachat message',
label: __('DeltaChat'),
buttonLabel: __('Add DeltaChat message'),
icon: 'cdr-deltachat',
view: {
agent: ['change'],
},
internal: false,
contentType: 'text/plain',
fields: {
body: {
required: true,
validation: 'length:1,10000',
},
attachments: {},
},
editorMeta: {
footer: {
maxlength: 10000,
warningLength: 5000,
},
},
}
return [type]
},
}
export default actionPlugin

View file

@ -0,0 +1,114 @@
# frozen_string_literal: true
class CommunicateCdrDeltachatJob < ApplicationJob
retry_on StandardError, attempts: 4, wait: lambda { |executions|
executions * 120.seconds
}
def perform(article_id)
article = Ticket::Article.find(article_id)
# set retry count
article.preferences['delivery_retry'] ||= 0
article.preferences['delivery_retry'] += 1
ticket = Ticket.lookup(id: article.ticket_id)
unless ticket.preferences
log_error(article,
"Can't find ticket.preferences for Ticket.find(#{article.ticket_id})")
end
unless ticket.preferences['cdr_deltachat']
log_error(article,
"Can't find ticket.preferences['cdr_deltachat'] for Ticket.find(#{article.ticket_id})")
end
unless ticket.preferences['cdr_deltachat']['bot_token']
log_error(article,
"Can't find ticket.preferences['cdr_deltachat']['bot_token'] for Ticket.find(#{article.ticket_id})")
end
unless ticket.preferences['cdr_deltachat']['chat_id']
log_error(article,
"Can't find ticket.preferences['cdr_deltachat']['chat_id'] for Ticket.find(#{article.ticket_id})")
end
channel = ::CdrDeltachat.bot_by_bot_token(ticket.preferences['cdr_deltachat']['bot_token'])
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
unless channel
log_error(article,
"No such channel for bot #{ticket.preferences['cdr_deltachat']['bot_token']} or channel id #{ticket.preferences['channel_id']}")
end
if channel.options[:bot_token].blank?
log_error(article,
"Channel.find(#{channel.id}) has no cdr deltachat api token!")
end
has_error = false
begin
result = channel.deliver(article)
rescue StandardError => e
log_error(article, e.message)
has_error = true
end
Rails.logger.debug { "send result: #{result}" }
if result.nil? || result[:error].present?
log_error(article, 'Delivering deltachat message failed!')
has_error = true
end
return if has_error
article.to = result['result']['to']
article.from = result['result']['from']
message_id = format('%<from>s@%<timestamp>s', from: result['result']['from'],
timestamp: result['result']['timestamp'])
article.preferences['cdr_deltachat'] = {
timestamp: result['result']['timestamp'],
message_id: message_id,
from: result['result']['from'],
to: result['result']['to']
}
# set delivery status
article.preferences['delivery_status_message'] = nil
article.preferences['delivery_status'] = 'success'
article.preferences['delivery_status_date'] = Time.zone.now
article.message_id = "cdr_deltachat.#{message_id}"
article.save!
Rails.logger.info "Sent deltachat message to: '#{article.to}' (from #{article.from})"
article
end
def log_error(local_record, message)
local_record.preferences['delivery_status'] = 'fail'
local_record.preferences['delivery_status_message'] =
message.encode!('UTF-8', 'UTF-8', invalid: :replace, replace: '?')
local_record.preferences['delivery_status_date'] = Time.zone.now
local_record.save
Rails.logger.error message
if local_record.preferences['delivery_retry'] > 3
Ticket::Article.create(
ticket_id: local_record.ticket_id,
content_type: 'text/plain',
body: "Unable to send cdr deltachat message: #{message}",
internal: true,
sender: Ticket::Article::Sender.find_by(name: 'System'),
type: Ticket::Article::Type.find_by(name: 'note'),
preferences: {
delivery_article_id_related: local_record.id,
delivery_message: true
},
updated_by_id: 1,
created_by_id: 1
)
end
raise message
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Channel::Driver::CdrDeltachat
def fetchable?(_channel)
false
end
def disconnect; end
def deliver(options, article, _notification = false)
# return if we run import mode
return if Setting.get('import_mode')
options = check_external_credential(options)
Rails.logger.debug { 'deltachat send started' }
Rails.logger.debug { options.inspect }
@deltachat = ::CdrDeltachat.new(options[:bot_endpoint], options[:bot_token])
@deltachat.from_article(article)
end
private
def check_external_credential(options)
if options[:auth] && options[:auth][:external_credential_id]
external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id])
raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential
options[:auth][:api_key] = external_credential.credentials['api_key']
end
options
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Ticket::Article::EnqueueCommunicateCdrDeltachatJob
extend ActiveSupport::Concern
included do
after_create :ticket_article_enqueue_communicate_cdr_deltachat_job
end
private
def ticket_article_enqueue_communicate_cdr_deltachat_job
# return if we run import mode
return true if Setting.get('import_mode')
# if sender is customer, do not communicate
return true unless sender_id
sender = Ticket::Article::Sender.lookup(id: sender_id)
return true if sender.nil?
return true if sender.name == 'Customer'
# only apply on cdr deltachat messages
return true unless type_id
type = Ticket::Article::Type.lookup(id: type_id)
return true unless type.name.match?(/\Acdr_deltachat/i)
CommunicateCdrDeltachatJob.perform_later(id)
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Controllers
class ChannelsCdrDeltachatControllerPolicy < Controllers::ApplicationControllerPolicy
default_permit!('admin.channel_cdr_deltachat')
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
Rails.application.config.after_initialize do
class Ticket::Article
include Ticket::Article::EnqueueCommunicateCdrDeltachatJob
end
icon = File.read('public/assets/images/icons/cdr_deltachat.svg')
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
if !doc.at_css('#icon-cdr-deltachat')
doc.at('svg').add_child(icon)
Rails.logger.debug 'deltachat icon added to icon set'
else
Rails.logger.debug 'deltachat icon already in icon set'
end
File.write('public/assets/images/icons.svg', doc.to_xml)
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#index', via: :get
match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#add', via: :post
match "#{api_path}/channels_cdr_deltachat/:id", to: 'channels_cdr_deltachat#update', via: :put
match "#{api_path}/channels_cdr_deltachat_webhook/:token", to: 'channels_cdr_deltachat#webhook', via: :post
match "#{api_path}/channels_cdr_deltachat_bot_webhook/:bot_token", to: 'channels_cdr_deltachat#bot_webhook', via: :post
match "#{api_path}/channels_cdr_deltachat_disable", to: 'channels_cdr_deltachat#disable', via: :post
match "#{api_path}/channels_cdr_deltachat_enable", to: 'channels_cdr_deltachat#enable', via: :post
match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#destroy', via: :delete
match "#{api_path}/channels_cdr_deltachat_rotate_token", to: 'channels_cdr_deltachat#rotate_token', via: :post
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class CdrDeltachatChannel < ActiveRecord::Migration[5.2]
def self.up
Ticket::Article::Type.create_if_not_exists(
name: 'cdr_deltachat',
communication: true,
updated_by_id: 1,
created_by_id: 1
)
Permission.create_if_not_exists(
name: 'admin.channel_cdr_deltachat',
description: 'Manage %s',
preferences: {
translations: ['Channel - DeltaChat']
}
)
end
def self.down
t = Ticket::Article::Type.find_by(name: 'cdr_deltachat')
t&.destroy
p = Permission.find_by(name: 'admin.channel_cdr_deltachat')
p&.destroy
end
end

View file

@ -0,0 +1,75 @@
# frozen_string_literal: true
require 'cdr_deltachat_api'
class CdrDeltachat
attr_accessor :client
def self.check_token(api_url, token)
api = CdrDeltachatApi.new(api_url, token)
begin
bot = api.fetch_self
rescue StandardError => e
raise "invalid api token: #{e.message}"
end
bot
end
def self.bot_by_bot_token(bot_token)
Channel.where(area: 'DeltaChat::Account').each do |channel|
next unless channel.options
next unless channel.options[:bot_token]
return channel if channel.options[:bot_token].to_s == bot_token.to_s
end
nil
end
def self.timestamp_to_date(timestamp_str)
Time.at(timestamp_str.to_i).utc.to_datetime
end
def self.message_id(message_raw)
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
end
def initialize(api_url, token)
@token = token
@api_url = api_url
@api = CdrDeltachatApi.new(api_url, token)
end
def send_message(recipient, message)
return if Rails.env.test?
@api.send_message(recipient, message)
end
def from_article(article)
Rails.logger.debug { "Create deltachat message from article..." }
ticket = Ticket.find_by(id: article.ticket_id)
raise "No ticket found for article #{article.id}" unless ticket
recipient = ticket.preferences.dig('cdr_deltachat', 'chat_id')
raise "No DeltaChat chat_id found in ticket preferences" unless recipient
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
options = {}
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
if attachments.any?
attachment_data = attachments.map do |attachment|
{
data: Base64.strict_encode64(attachment.content),
filename: attachment.filename,
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
}
end
options[:attachments] = attachment_data
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
end
@api.send_message(recipient, article[:body], options)
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
require 'json'
require 'net/http'
require 'net/https'
require 'uri'
class CdrDeltachatApi
def initialize(api_url, token)
@token = token
@last_update = 0
@api_url = ENV.fetch('BRIDGE_DELTACHAT_URL', api_url || 'http://bridge-deltachat:5001')
end
def get(api)
url = "#{@api_url}/api/bots/#{@token}/#{api}"
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
return {} unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError, Faraday::Error => e
Rails.logger.error "CdrDeltachatApi: GET #{api} failed: #{e.message}"
{}
end
def post(api, params = {})
url = "#{@api_url}/api/bots/#{@token}/#{api}"
response = Faraday.post(url, params.to_json, {
'Content-Type' => 'application/json',
'Accept' => 'application/json'
})
unless response.success?
Rails.logger.error "CdrDeltachatApi: POST #{api} failed: #{response.status} #{response.body}"
raise "Failed to call DeltaChat API: #{response.status}"
end
JSON.parse(response.body)
rescue JSON::ParserError => e
Rails.logger.error "CdrDeltachatApi: Failed to parse response: #{e.message}"
{}
rescue Faraday::Error => e
Rails.logger.error "CdrDeltachatApi: POST #{api} failed: #{e.message}"
raise "Failed to call DeltaChat API: #{e.message}"
end
def fetch_self
get('')
end
def send_message(recipient, text, options = {})
params = {
email: recipient.to_s,
message: text
}
if options[:attachments].present?
params[:attachments] = options[:attachments].map do |att|
{
data: att[:data],
filename: att[:filename],
mime_type: att[:mime_type]
}
end
end
result = post('send', params)
{
'result' => {
'to' => result.dig('result', 'recipient') || recipient,
'from' => result.dig('result', 'source') || @token,
'timestamp' => result.dig('result', 'timestamp') || Time.current.iso8601
}
}
end
end

View file

@ -0,0 +1,5 @@
<symbol id="icon-cdr-deltachat" viewBox="0 0 17 17"><title>deltachat</title>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 48 48">
<g><path fill="#C6C7C8" d="M24,2C11.85,2,2,11.85,2,24c0,4.22,1.19,8.16,3.25,11.51L2.08,45.92l10.41-3.17C15.84,44.81,19.78,46,24,46 c12.15,0,22-9.85,22-22S36.15,2,24,2z M24,40c-3.44,0-6.65-1.08-9.28-2.92l-6.53,1.99l1.99-6.53C8.34,29.91,7.26,26.71,7.26,23.26 c0-9.24,7.5-16.74,16.74-16.74s16.74,7.5,16.74,16.74S33.24,40,24,40z"/><path fill="#C6C7C8" d="M24,11.5c-6.9,0-12.5,5.6-12.5,12.5c0,2.76,0.9,5.31,2.41,7.38l-0.3,3.72l3.72-0.3 C19.35,36.31,21.57,37,24,37c6.9,0,12.5-5.6,12.5-12.5S30.9,11.5,24,11.5z M24,33c-1.2,0-2.35-0.24-3.4-0.67l-0.73,0.06 l0.06-0.73C18.24,30.35,17,28.3,17,26c0-3.87,3.13-7,7-7s7,3.13,7,7S27.87,33,24,33z"/></g>
</svg>
</symbol>