intercom_export 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.md +7 -0
- data/README.md +44 -0
- data/bin/intercom_export +5 -0
- data/lib/intercom_export/cli.rb +126 -0
- data/lib/intercom_export/coordinator.rb +47 -0
- data/lib/intercom_export/differ/intercom_zendesk.rb +109 -0
- data/lib/intercom_export/executor/dry_run.rb +19 -0
- data/lib/intercom_export/executor/zendesk.rb +70 -0
- data/lib/intercom_export/finder/intercom_zendesk.rb +42 -0
- data/lib/intercom_export/listener/std.rb +19 -0
- data/lib/intercom_export/model/intercom_admin.rb +21 -0
- data/lib/intercom_export/model/intercom_conversation.rb +28 -0
- data/lib/intercom_export/model/intercom_user.rb +21 -0
- data/lib/intercom_export/model/zendesk_ticket.rb +42 -0
- data/lib/intercom_export/model/zendesk_user.rb +45 -0
- data/lib/intercom_export/reference.rb +18 -0
- data/lib/intercom_export/source/intercom_conversations.rb +19 -0
- data/lib/intercom_export/splitter/intercom.rb +81 -0
- data/lib/intercom_export/version.rb +3 -0
- data/spec/intercom_export/cli_spec.rb +94 -0
- data/spec/intercom_export/coordinator_spec.rb +63 -0
- data/spec/intercom_export/differ/intercom_zendesk_spec.rb +226 -0
- data/spec/intercom_export/executor/zendesk_spec.rb +99 -0
- data/spec/intercom_export/finder/intercom_zendesk_spec.rb +89 -0
- data/spec/intercom_export/splitter/intercom_spec.rb +162 -0
- data/spec/spec_helper.rb +18 -0
- metadata +168 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 8bfa036eb91da48e7b33e0a4259435df6174a24a
|
4
|
+
data.tar.gz: defb9580a26925e535c00468a9c307d071cb0e6c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0ab1c73df7e26588d6e19d9f71d4d628614b5e0370baadd93f062dd7de03c7bc81f4f88bfc34f6b02cb626d3c266962dec038433fdadc7f1a54653f3fea6011e
|
7
|
+
data.tar.gz: 3eaf29055fbab7c1cdb5f03dac77041afd388615d1b776d9e0e617fd44d9b2f81ed1c4515dea5c62ca92d6861721ee2bb9f6d928a6988c66bd724283147cc5a0
|
data/LICENSE.md
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
Copyright (c) 2016 Ignition Works Limited
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
4
|
+
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
6
|
+
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
Intercom Export
|
2
|
+
===============
|
3
|
+
|
4
|
+
System to help export Intercom.io conversations into Zendesk tickets. The codebase is designed to be adaptable
|
5
|
+
for importing into other systems or even exporting from things other than Intercom.
|
6
|
+
|
7
|
+
Usage
|
8
|
+
-----
|
9
|
+
|
10
|
+
```
|
11
|
+
$ intercom_export --intercom-app-id <APP ID> --intercom-api-key <APP KEY> \
|
12
|
+
--zendesk-address <DOMAIN>.zendesk.com --zendesk-username <USERNAME> --zendesk-token <TOKEN>
|
13
|
+
```
|
14
|
+
|
15
|
+
Design
|
16
|
+
------
|
17
|
+
|
18
|
+
The `coordinator` is the heart of the import. This breaks the problem down into several discrete stages.
|
19
|
+
|
20
|
+
1. `source` - This is simple an enumerable, currently this is an enumerable of all Intercom conversations
|
21
|
+
2. `splitter` - This takes an item from the source and splits it into several `parts` that make syncying
|
22
|
+
easier. For instance an Intecom conversation will be split into all of the users involved in the
|
23
|
+
conversation, and the conversation itself with the users replaced by references.
|
24
|
+
3. `finder` - This takes a part (something in the land of Intercom), and tries to find it's equivalent in
|
25
|
+
Zendesk
|
26
|
+
4. `differ` - This compares the Intercom part, with the search result from Zendesk and then creates commands.
|
27
|
+
5. `executor` - This executes each command, replacing references to Intercom items, with ids from Zendesk.
|
28
|
+
|
29
|
+
The idea of breaking it into these components is to allow other front-ends (Intercom), to be slotted in by
|
30
|
+
only adding a few classes. It should also be possible to slot in other back-ends (Zendesk) with a small amount
|
31
|
+
of modification.
|
32
|
+
|
33
|
+
Tests
|
34
|
+
-----
|
35
|
+
|
36
|
+
```
|
37
|
+
$ rspec
|
38
|
+
```
|
39
|
+
|
40
|
+
Status
|
41
|
+
------
|
42
|
+
|
43
|
+
This has worked for us importing around 5000 tickets from Intercom to Zendesk. Performance is slow due to the
|
44
|
+
number of queries required.
|
data/bin/intercom_export
ADDED
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'intercom'
|
3
|
+
require 'zendesk_api'
|
4
|
+
|
5
|
+
require 'intercom_export/coordinator'
|
6
|
+
require 'intercom_export/differ/intercom_zendesk'
|
7
|
+
require 'intercom_export/executor/zendesk'
|
8
|
+
require 'intercom_export/executor/dry_run'
|
9
|
+
require 'intercom_export/finder/intercom_zendesk'
|
10
|
+
require 'intercom_export/source/intercom_conversations'
|
11
|
+
require 'intercom_export/splitter/intercom'
|
12
|
+
require 'intercom_export/listener/std'
|
13
|
+
|
14
|
+
module IntercomExport
|
15
|
+
class Cli
|
16
|
+
def initialize(
|
17
|
+
program_name,
|
18
|
+
argv,
|
19
|
+
coordinator_class: IntercomExport::Coordinator,
|
20
|
+
stdout: STDOUT,
|
21
|
+
stderr: STDERR
|
22
|
+
)
|
23
|
+
@program_name = program_name
|
24
|
+
@argv = argv
|
25
|
+
@coordinator_class = coordinator_class
|
26
|
+
@stdout = stdout
|
27
|
+
@stderr = stderr
|
28
|
+
end
|
29
|
+
|
30
|
+
def run
|
31
|
+
coordinator_class.new(
|
32
|
+
source: IntercomExport::Source::IntercomConversations.new(intercom_client),
|
33
|
+
splitter: IntercomExport::Splitter::Intercom.new(intercom_client),
|
34
|
+
finder: IntercomExport::Finder::IntercomZendesk.new(zendesk_client),
|
35
|
+
differ: IntercomExport::Differ::IntercomZendesk.new,
|
36
|
+
executor: executor
|
37
|
+
).run
|
38
|
+
rescue KeyError
|
39
|
+
stderr.puts options_parser
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
attr_reader :coordinator_class, :argv, :program_name, :stdout, :stderr
|
45
|
+
|
46
|
+
def listener
|
47
|
+
@listener ||= IntercomExport::Listener::Std.new(stdout: stdout, stderr: stderr)
|
48
|
+
end
|
49
|
+
|
50
|
+
def executor
|
51
|
+
if options.fetch(:dry_run, false)
|
52
|
+
IntercomExport::Executor::DryRun.new(listener)
|
53
|
+
else
|
54
|
+
IntercomExport::Executor::Zendesk.new(zendesk_client, listener)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def zendesk_client
|
59
|
+
@zendesk_client ||= ZendeskAPI::Client.new do |c|
|
60
|
+
c.url = "https://#{options.fetch(:zendesk_address)}/api/v2"
|
61
|
+
c.username = options.fetch(:zendesk_username)
|
62
|
+
c.token = options.fetch(:zendesk_token)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def intercom_client
|
67
|
+
@intercom_client ||= Intercom::Client.new(
|
68
|
+
api_key: options.fetch(:intercom_api_key),
|
69
|
+
app_id: options.fetch(:intercom_app_id)
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
def options
|
74
|
+
# If a method has side-effects but no one can see it - is it really a query?
|
75
|
+
@options ||= begin
|
76
|
+
@opts = {}
|
77
|
+
options_parser.parse(argv)
|
78
|
+
@opts
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def options_parser
|
83
|
+
@options_parser ||= OptionParser.new do |opts|
|
84
|
+
opts.banner = "Usage: #{program_name} [options]"
|
85
|
+
|
86
|
+
options_intercom(opts)
|
87
|
+
options_zendesk(opts)
|
88
|
+
options_generic(opts)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def options_generic(opts)
|
93
|
+
opts.on('-d', '--dry-run') do
|
94
|
+
@opts[:dry_run] = true
|
95
|
+
end
|
96
|
+
|
97
|
+
opts.on('-h', '--help') do
|
98
|
+
stderr.puts opts
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def options_intercom(opts)
|
103
|
+
opts.on('--intercom-app-id APP_ID', 'Intercom App Id') do |v|
|
104
|
+
@opts[:intercom_app_id] = v
|
105
|
+
end
|
106
|
+
|
107
|
+
opts.on('--intercom-api-key API_KEY', 'Intercom API Key') do |v|
|
108
|
+
@opts[:intercom_api_key] = v
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def options_zendesk(opts)
|
113
|
+
opts.on('--zendesk-address ADDRESS', 'Zendesk address e.g. example.zendesk.com') do |v|
|
114
|
+
@opts[:zendesk_address] = v
|
115
|
+
end
|
116
|
+
|
117
|
+
opts.on('--zendesk-username USERNAME', 'Zendesk username e.g. admin@example.com') do |v|
|
118
|
+
@opts[:zendesk_username] = v
|
119
|
+
end
|
120
|
+
|
121
|
+
opts.on('--zendesk-token TOKEN', 'Zendesk token') do |v|
|
122
|
+
@opts[:zendesk_token] = v
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module IntercomExport
|
2
|
+
class Coordinator
|
3
|
+
|
4
|
+
module EnumeratorLazyUniqParts
|
5
|
+
refine Enumerator::Lazy do
|
6
|
+
require 'set'
|
7
|
+
def uniq
|
8
|
+
set = Set.new
|
9
|
+
select { |part|
|
10
|
+
val = "#{part.class.to_s}-#{part.id}"
|
11
|
+
!set.include?(val).tap { |exists| set << val unless exists }
|
12
|
+
}
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
using EnumeratorLazyUniqParts
|
18
|
+
|
19
|
+
def initialize(source:, splitter:, finder:, differ:, executor:)
|
20
|
+
@source = source
|
21
|
+
@splitter = splitter
|
22
|
+
@finder = finder
|
23
|
+
@differ = differ
|
24
|
+
@executor = executor
|
25
|
+
end
|
26
|
+
|
27
|
+
def run
|
28
|
+
source.lazy.flat_map { |source|
|
29
|
+
splitter.split(source)
|
30
|
+
}.uniq.map { |source_object|
|
31
|
+
[source_object, finder.find(source_object)]
|
32
|
+
}.map { |source_object, remote_object|
|
33
|
+
differ.diff(source_object, remote_object)
|
34
|
+
}.each { |commands|
|
35
|
+
executor.call(commands)
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
attr_reader :source, :finder, :splitter, :differ, :executor
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
|
47
|
+
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'intercom_export/model/intercom_admin'
|
2
|
+
require 'intercom_export/model/intercom_user'
|
3
|
+
require 'intercom_export/model/intercom_conversation'
|
4
|
+
|
5
|
+
require 'nokogiri'
|
6
|
+
|
7
|
+
module IntercomExport
|
8
|
+
module Differ
|
9
|
+
class IntercomZendesk
|
10
|
+
def diff(intercom_source, zendesk_destination)
|
11
|
+
case intercom_source
|
12
|
+
when Model::IntercomUser, Model::IntercomAdmin
|
13
|
+
diff_user(intercom_source, zendesk_destination)
|
14
|
+
when Model::IntercomConversation
|
15
|
+
diff_ticket(intercom_source, zendesk_destination)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def diff_user(intercom_user, zendesk_user)
|
22
|
+
if zendesk_user
|
23
|
+
[reference(intercom_user, zendesk_user)]
|
24
|
+
else
|
25
|
+
[import_user(intercom_user)]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def diff_ticket(intercom_conversation, zendesk_ticket)
|
30
|
+
if zendesk_ticket
|
31
|
+
[]
|
32
|
+
else
|
33
|
+
[import_ticket(intercom_conversation)]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def reference(intercom_source, zendesk_destination)
|
38
|
+
{ name: :reference, details: zendesk_destination.id, reference: intercom_source.reference }
|
39
|
+
end
|
40
|
+
|
41
|
+
def import_user(intercom_user)
|
42
|
+
{
|
43
|
+
name: :import_user,
|
44
|
+
details: {
|
45
|
+
external_id: intercom_user.reference.value,
|
46
|
+
name: intercom_user.name || intercom_user.email,
|
47
|
+
email: intercom_user.email
|
48
|
+
},
|
49
|
+
reference: intercom_user.reference
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
def import_ticket(intercom_conversation)
|
54
|
+
{
|
55
|
+
name: :import_ticket,
|
56
|
+
details: {
|
57
|
+
external_id: intercom_conversation.reference.value,
|
58
|
+
tags: intercom_conversation.tags,
|
59
|
+
status: intercom_conversation.open ? 'pending' : 'solved',
|
60
|
+
requester_id: intercom_conversation.user,
|
61
|
+
assignee_id: intercom_conversation.assignee,
|
62
|
+
subject: strip_html(intercom_conversation.conversation_message.fetch(:subject)),
|
63
|
+
comments: [
|
64
|
+
author_id: intercom_conversation.user,
|
65
|
+
html_body: intercom_conversation.conversation_message.fetch(:body),
|
66
|
+
created_at: time(intercom_conversation.created_at)
|
67
|
+
] + intercom_conversation.conversation_parts.map { |part|
|
68
|
+
{
|
69
|
+
author_id: part.fetch(:author),
|
70
|
+
value: html_to_ascii(part.fetch(:body)),
|
71
|
+
public: part.fetch(:part_type) != 'note',
|
72
|
+
created_at: time(part.fetch(:created_at))
|
73
|
+
}
|
74
|
+
},
|
75
|
+
created_at: time(intercom_conversation.created_at),
|
76
|
+
updated_at: time(intercom_conversation.updated_at)
|
77
|
+
}
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
def time(posix)
|
82
|
+
Time.at(posix).strftime('%Y-%m-%dT%H:%M:%SZ')
|
83
|
+
end
|
84
|
+
|
85
|
+
def strip_html(html_string)
|
86
|
+
Nokogiri::HTML(html_string).text
|
87
|
+
end
|
88
|
+
|
89
|
+
def html_to_ascii(html_string)
|
90
|
+
node = Nokogiri::HTML(html_string)
|
91
|
+
blocks = %w[p div address] # els to put newlines after
|
92
|
+
swaps = { 'br' => "\n", 'hr' => "\n#{'-'*70}\n" } # content to swap out
|
93
|
+
dup = node.dup # don't munge the original
|
94
|
+
|
95
|
+
# Get rid of superfluous whitespace in the source
|
96
|
+
dup.xpath('.//text()').each { |t| t.content = t.text.gsub(/\s+/, ' ') }
|
97
|
+
|
98
|
+
# Swap out the swaps
|
99
|
+
dup.css(swaps.keys.join(',')).each { |n| n.replace(swaps[n.name]) }
|
100
|
+
|
101
|
+
# Slap a couple newlines after each block level element
|
102
|
+
dup.css(blocks.join(',')).each { |n| n.after("\n\n") }
|
103
|
+
|
104
|
+
# Return the modified text content
|
105
|
+
dup.text.strip
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module IntercomExport
|
2
|
+
module Executor
|
3
|
+
class DryRun
|
4
|
+
def initialize(listener)
|
5
|
+
@listener = listener
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(commands)
|
9
|
+
commands.each do |c|
|
10
|
+
listener.executing c
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
attr_reader :listener
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module IntercomExport
|
2
|
+
module Executor
|
3
|
+
class Zendesk
|
4
|
+
ReferenceResult = Struct.new(:id)
|
5
|
+
|
6
|
+
def initialize(client, listener=nil)
|
7
|
+
@client = client
|
8
|
+
@listener = listener
|
9
|
+
@references = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(commands)
|
13
|
+
commands.each do |command|
|
14
|
+
executing(command)
|
15
|
+
details = resolve_reference(command.fetch(:details))
|
16
|
+
result = case command.fetch(:name)
|
17
|
+
when :reference
|
18
|
+
ReferenceResult.new(details)
|
19
|
+
when :import_user
|
20
|
+
import_user(details)
|
21
|
+
when :import_ticket
|
22
|
+
import_ticket(details)
|
23
|
+
end
|
24
|
+
save_reference(command[:reference].value, result.id) if command.fetch(:reference, nil)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
attr_reader :client, :references, :listener
|
31
|
+
|
32
|
+
def import_user(details)
|
33
|
+
client.users.create!(details)
|
34
|
+
end
|
35
|
+
|
36
|
+
def import_ticket(details)
|
37
|
+
client.tickets.import!(details)
|
38
|
+
end
|
39
|
+
|
40
|
+
def save_reference(local_id, remote_id)
|
41
|
+
references[local_id] = remote_id
|
42
|
+
end
|
43
|
+
|
44
|
+
def deep_resolve_references(details)
|
45
|
+
details.each do |key, value|
|
46
|
+
resolved_value = resolve_reference(value)
|
47
|
+
details[key] = resolved_value unless resolved_value === value
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def executing(command)
|
52
|
+
return unless listener
|
53
|
+
listener.executing(command)
|
54
|
+
end
|
55
|
+
|
56
|
+
def resolve_reference(value)
|
57
|
+
case value
|
58
|
+
when Hash
|
59
|
+
deep_resolve_references(value)
|
60
|
+
when Array
|
61
|
+
value.map { |v| resolve_reference(v) }
|
62
|
+
when Reference
|
63
|
+
references.fetch(value.value)
|
64
|
+
else
|
65
|
+
value
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'intercom_export/model/intercom_admin'
|
2
|
+
require 'intercom_export/model/intercom_user'
|
3
|
+
require 'intercom_export/model/intercom_conversation'
|
4
|
+
require 'intercom_export/model/zendesk_ticket'
|
5
|
+
require 'intercom_export/model/zendesk_user'
|
6
|
+
|
7
|
+
module IntercomExport
|
8
|
+
module Finder
|
9
|
+
class IntercomZendesk
|
10
|
+
def initialize(zendesk_client)
|
11
|
+
@zendesk_client = zendesk_client
|
12
|
+
end
|
13
|
+
|
14
|
+
def find(intercom_source)
|
15
|
+
case intercom_source
|
16
|
+
when IntercomExport::Model::IntercomUser, IntercomExport::Model::IntercomAdmin
|
17
|
+
lookup_zendesk_user(intercom_source)
|
18
|
+
when IntercomExport::Model::IntercomConversation
|
19
|
+
lookup_zendesk_ticket(intercom_source)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :zendesk_client
|
26
|
+
|
27
|
+
def lookup_zendesk_user(intercom_user)
|
28
|
+
value = zendesk_client.users.search(query: "email:#{intercom_user.email}").first
|
29
|
+
return unless value
|
30
|
+
IntercomExport::Model::ZendeskUser.new(value.to_hash)
|
31
|
+
end
|
32
|
+
|
33
|
+
def lookup_zendesk_ticket(intercom_ticket)
|
34
|
+
value = zendesk_client.search(
|
35
|
+
query: "type:ticket external_id:#{intercom_ticket.reference.value}"
|
36
|
+
).first
|
37
|
+
return unless value
|
38
|
+
IntercomExport::Model::ZendeskTicket.new(value.to_hash)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|