pocket_phone 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +28 -0
- data/README.md +184 -0
- data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
- data/app/controllers/pocket_phone/application_controller.rb +33 -0
- data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
- data/app/controllers/pocket_phone/media_controller.rb +14 -0
- data/app/controllers/pocket_phone/messages_controller.rb +65 -0
- data/app/controllers/pocket_phone/people_controller.rb +19 -0
- data/app/helpers/pocket_phone/application_helper.rb +145 -0
- data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
- data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
- data/app/views/layouts/pocket_phone/application.html.erb +15 -0
- data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
- data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
- data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
- data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
- data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
- data/app/views/pocket_phone/conversations/show.html.erb +67 -0
- data/config/routes.rb +41 -0
- data/lib/pocket_phone/carrier.rb +119 -0
- data/lib/pocket_phone/configuration.rb +60 -0
- data/lib/pocket_phone/database.rb +157 -0
- data/lib/pocket_phone/engine.rb +9 -0
- data/lib/pocket_phone/link_preview.rb +128 -0
- data/lib/pocket_phone/numbers.rb +40 -0
- data/lib/pocket_phone/payloads.rb +50 -0
- data/lib/pocket_phone/runner.rb +37 -0
- data/lib/pocket_phone/store.rb +106 -0
- data/lib/pocket_phone/version.rb +5 -0
- data/lib/pocket_phone/webhook.rb +68 -0
- data/lib/pocket_phone.rb +47 -0
- metadata +135 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module PocketPhone
|
|
8
|
+
# The database on disk: one JSON file under tmp/, guarded by a lock file so
|
|
9
|
+
# the web server, its background threads and anything else that loads the
|
|
10
|
+
# gem can all read and write it safely.
|
|
11
|
+
class Store
|
|
12
|
+
attr_reader :path
|
|
13
|
+
|
|
14
|
+
def initialize(path)
|
|
15
|
+
@path = path.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read
|
|
19
|
+
locked(File::LOCK_SH) { Database.new(load) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Yields the database for changes and saves it. Returns the block's value.
|
|
23
|
+
def write
|
|
24
|
+
locked(File::LOCK_EX) do
|
|
25
|
+
database = Database.new(load)
|
|
26
|
+
seed(database)
|
|
27
|
+
result = yield database
|
|
28
|
+
database.data["version"] = database.version + 1
|
|
29
|
+
dump(database.data)
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Forget every conversation. People and lines are kept, so a number you
|
|
35
|
+
# set up once stays set up.
|
|
36
|
+
def clear!
|
|
37
|
+
write do |database|
|
|
38
|
+
database.conversations.clear
|
|
39
|
+
database.messages.clear
|
|
40
|
+
database.previews.clear
|
|
41
|
+
end
|
|
42
|
+
FileUtils.rm_rf(media_dir)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Everything, people included.
|
|
46
|
+
def reset!
|
|
47
|
+
locked(File::LOCK_EX) { FileUtils.rm_f(file) }
|
|
48
|
+
FileUtils.rm_rf(media_dir)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# --- attachments --------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def media_dir = File.join(path, "media")
|
|
54
|
+
|
|
55
|
+
def save_media(io, filename)
|
|
56
|
+
FileUtils.mkdir_p(media_dir)
|
|
57
|
+
name = "#{SecureRandom.hex(8)}#{File.extname(filename.to_s).downcase[/\A\.[a-z0-9]{1,8}\z/]}"
|
|
58
|
+
File.binwrite(File.join(media_dir, name), io.read)
|
|
59
|
+
name
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def media_file(name)
|
|
63
|
+
candidate = File.join(media_dir, File.basename(name.to_s))
|
|
64
|
+
candidate if File.file?(candidate)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def file = File.join(path, "store.json")
|
|
70
|
+
|
|
71
|
+
def locked(mode)
|
|
72
|
+
FileUtils.mkdir_p(path)
|
|
73
|
+
File.open(File.join(path, "store.lock"), File::RDWR | File::CREAT, 0o644) do |lock|
|
|
74
|
+
lock.flock(mode)
|
|
75
|
+
yield
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load
|
|
80
|
+
File.exist?(file) ? JSON.parse(File.read(file)) : nil
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Written beside the real file and renamed over it, so a reader never sees
|
|
86
|
+
# half a database.
|
|
87
|
+
def dump(data)
|
|
88
|
+
temporary = "#{file}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
89
|
+
File.write(temporary, JSON.generate(data))
|
|
90
|
+
File.rename(temporary, file)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def seed(database)
|
|
94
|
+
return if database.data["seeded"]
|
|
95
|
+
|
|
96
|
+
database.data["seeded"] = true
|
|
97
|
+
Array(PocketPhone.config.people).each do |person|
|
|
98
|
+
person = person.transform_keys(&:to_s)
|
|
99
|
+
number = Numbers.normalize(person["number"])
|
|
100
|
+
next if number.empty?
|
|
101
|
+
|
|
102
|
+
database.ensure_person(number, person.slice("name", "service", "outcome"))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module PocketPhone
|
|
9
|
+
# One webhook delivery to the app, signed the way Sendblue signs them.
|
|
10
|
+
#
|
|
11
|
+
# Sendblue sends two things when a signing secret is set: the secret itself
|
|
12
|
+
# in a header (`sb-signing-secret` unless you renamed it), and
|
|
13
|
+
# `x-sendblue-signature: t=<unix>,v1=<hex>`, an HMAC-SHA256 of
|
|
14
|
+
# "<t>.<raw body>" keyed by the secret. Both are sent here so that either
|
|
15
|
+
# check can be exercised.
|
|
16
|
+
module Webhook
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns a record of what happened, for the message's details:
|
|
20
|
+
# { "type", "url", "status" or "error", "at" }.
|
|
21
|
+
def deliver(type:, url:, payload:)
|
|
22
|
+
settings = Runner.with_app do
|
|
23
|
+
config = PocketPhone.config
|
|
24
|
+
{ secret: config.signing_secret.to_s, header: config.secret_header.to_s,
|
|
25
|
+
timeout: config.webhook_timeout }
|
|
26
|
+
end
|
|
27
|
+
uri = absolute(url)
|
|
28
|
+
body = JSON.generate(payload)
|
|
29
|
+
response = post(uri, body, headers(body, settings), settings[:timeout])
|
|
30
|
+
record(type, uri, "status" => response.code.to_i)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
record(type, url, "error" => "#{e.class.name}: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def signature(secret, body, timestamp = Time.now.to_i)
|
|
36
|
+
"t=#{timestamp},v1=#{OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def headers(body, settings)
|
|
40
|
+
headers = { "Content-Type" => "application/json", "Accept" => "application/json",
|
|
41
|
+
"User-Agent" => "pocket_phone/#{VERSION}" }
|
|
42
|
+
return headers if settings[:secret].empty?
|
|
43
|
+
|
|
44
|
+
headers["x-sendblue-signature"] = signature(settings[:secret], body)
|
|
45
|
+
headers[settings[:header]] = settings[:secret] unless settings[:header].empty?
|
|
46
|
+
headers
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def absolute(url)
|
|
50
|
+
uri = URI.parse(url.to_s)
|
|
51
|
+
return uri if uri.is_a?(URI::HTTP)
|
|
52
|
+
raise ArgumentError, "webhook URL #{url.inspect} is a path and no request has been served yet" if PocketPhone.base_url.to_s.empty?
|
|
53
|
+
|
|
54
|
+
URI.join(PocketPhone.base_url, url.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def post(uri, body, headers, timeout)
|
|
58
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
59
|
+
open_timeout: 5, read_timeout: timeout) do |http|
|
|
60
|
+
http.post(uri.request_uri, body, headers)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def record(type, url, outcome)
|
|
65
|
+
{ "type" => type, "url" => url.to_s, "at" => PocketPhone.now }.merge(outcome)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/pocket_phone.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pocket_phone/version"
|
|
4
|
+
require "pocket_phone/configuration"
|
|
5
|
+
require "pocket_phone/numbers"
|
|
6
|
+
require "pocket_phone/database"
|
|
7
|
+
require "pocket_phone/store"
|
|
8
|
+
require "pocket_phone/payloads"
|
|
9
|
+
require "pocket_phone/runner"
|
|
10
|
+
require "pocket_phone/webhook"
|
|
11
|
+
require "pocket_phone/link_preview"
|
|
12
|
+
require "pocket_phone/carrier"
|
|
13
|
+
require "pocket_phone/engine"
|
|
14
|
+
|
|
15
|
+
# A fake Sendblue for development: your app sends to it instead of to
|
|
16
|
+
# api.sendblue.com, and you answer from a phone in the browser.
|
|
17
|
+
module PocketPhone
|
|
18
|
+
# The line a phone texts when nothing has told us the app's real number yet.
|
|
19
|
+
FALLBACK_LINE = "+15555550100"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# The scheme and host of the last request the engine served. Webhook URLs
|
|
23
|
+
# may be configured as paths ("/webhooks/sendblue/token"); this is what
|
|
24
|
+
# they are resolved against.
|
|
25
|
+
attr_accessor :base_url
|
|
26
|
+
|
|
27
|
+
def config
|
|
28
|
+
@config ||= Configuration.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
yield config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset_config!
|
|
36
|
+
@config = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def store
|
|
40
|
+
Store.new(config.storage_path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def now
|
|
44
|
+
Time.now.utc.iso8601(3)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pocket_phone
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- hmk
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionview
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: nokogiri
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.13'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.13'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: railties
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '7.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '7.1'
|
|
68
|
+
description: Like letter_opener, but for iMessage and SMS, and in both directions.
|
|
69
|
+
pocket_phone is a mountable Rails engine that answers the Sendblue API, shows what
|
|
70
|
+
your app sent in an iMessage-style inbox, and lets you text back through signed
|
|
71
|
+
webhooks.
|
|
72
|
+
email:
|
|
73
|
+
- hmk@users.noreply.github.com
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- CHANGELOG.md
|
|
79
|
+
- LICENSE
|
|
80
|
+
- README.md
|
|
81
|
+
- app/controllers/pocket_phone/api/sendblue_controller.rb
|
|
82
|
+
- app/controllers/pocket_phone/application_controller.rb
|
|
83
|
+
- app/controllers/pocket_phone/conversations_controller.rb
|
|
84
|
+
- app/controllers/pocket_phone/media_controller.rb
|
|
85
|
+
- app/controllers/pocket_phone/messages_controller.rb
|
|
86
|
+
- app/controllers/pocket_phone/people_controller.rb
|
|
87
|
+
- app/helpers/pocket_phone/application_helper.rb
|
|
88
|
+
- app/views/layouts/pocket_phone/_script.html.erb
|
|
89
|
+
- app/views/layouts/pocket_phone/_styles.html.erb
|
|
90
|
+
- app/views/layouts/pocket_phone/application.html.erb
|
|
91
|
+
- app/views/pocket_phone/conversations/_head.html.erb
|
|
92
|
+
- app/views/pocket_phone/conversations/_message.html.erb
|
|
93
|
+
- app/views/pocket_phone/conversations/_messages.html.erb
|
|
94
|
+
- app/views/pocket_phone/conversations/_new_dialog.html.erb
|
|
95
|
+
- app/views/pocket_phone/conversations/_sidebar.html.erb
|
|
96
|
+
- app/views/pocket_phone/conversations/show.html.erb
|
|
97
|
+
- config/routes.rb
|
|
98
|
+
- lib/pocket_phone.rb
|
|
99
|
+
- lib/pocket_phone/carrier.rb
|
|
100
|
+
- lib/pocket_phone/configuration.rb
|
|
101
|
+
- lib/pocket_phone/database.rb
|
|
102
|
+
- lib/pocket_phone/engine.rb
|
|
103
|
+
- lib/pocket_phone/link_preview.rb
|
|
104
|
+
- lib/pocket_phone/numbers.rb
|
|
105
|
+
- lib/pocket_phone/payloads.rb
|
|
106
|
+
- lib/pocket_phone/runner.rb
|
|
107
|
+
- lib/pocket_phone/store.rb
|
|
108
|
+
- lib/pocket_phone/version.rb
|
|
109
|
+
- lib/pocket_phone/webhook.rb
|
|
110
|
+
homepage: https://github.com/hmk/pocket_phone
|
|
111
|
+
licenses:
|
|
112
|
+
- BSD-3-Clause
|
|
113
|
+
metadata:
|
|
114
|
+
source_code_uri: https://github.com/hmk/pocket_phone
|
|
115
|
+
changelog_uri: https://github.com/hmk/pocket_phone/blob/main/CHANGELOG.md
|
|
116
|
+
bug_tracker_uri: https://github.com/hmk/pocket_phone/issues
|
|
117
|
+
rubygems_mfa_required: 'true'
|
|
118
|
+
rdoc_options: []
|
|
119
|
+
require_paths:
|
|
120
|
+
- lib
|
|
121
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
122
|
+
requirements:
|
|
123
|
+
- - ">="
|
|
124
|
+
- !ruby/object:Gem::Version
|
|
125
|
+
version: '3.2'
|
|
126
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
requirements: []
|
|
132
|
+
rubygems_version: 4.0.6
|
|
133
|
+
specification_version: 4
|
|
134
|
+
summary: 'A fake Sendblue for development: text your app from a phone in the browser.'
|
|
135
|
+
test_files: []
|