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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE +28 -0
  4. data/README.md +184 -0
  5. data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
  6. data/app/controllers/pocket_phone/application_controller.rb +33 -0
  7. data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
  8. data/app/controllers/pocket_phone/media_controller.rb +14 -0
  9. data/app/controllers/pocket_phone/messages_controller.rb +65 -0
  10. data/app/controllers/pocket_phone/people_controller.rb +19 -0
  11. data/app/helpers/pocket_phone/application_helper.rb +145 -0
  12. data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
  13. data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
  14. data/app/views/layouts/pocket_phone/application.html.erb +15 -0
  15. data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
  16. data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
  17. data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
  18. data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
  19. data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
  20. data/app/views/pocket_phone/conversations/show.html.erb +67 -0
  21. data/config/routes.rb +41 -0
  22. data/lib/pocket_phone/carrier.rb +119 -0
  23. data/lib/pocket_phone/configuration.rb +60 -0
  24. data/lib/pocket_phone/database.rb +157 -0
  25. data/lib/pocket_phone/engine.rb +9 -0
  26. data/lib/pocket_phone/link_preview.rb +128 -0
  27. data/lib/pocket_phone/numbers.rb +40 -0
  28. data/lib/pocket_phone/payloads.rb +50 -0
  29. data/lib/pocket_phone/runner.rb +37 -0
  30. data/lib/pocket_phone/store.rb +106 -0
  31. data/lib/pocket_phone/version.rb +5 -0
  32. data/lib/pocket_phone/webhook.rb +68 -0
  33. data/lib/pocket_phone.rb +47 -0
  34. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ VERSION = "0.1.0"
5
+ 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
@@ -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: []