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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ # Moves messages between the app and the phones, after the request that
5
+ # created them has been answered.
6
+ module Carrier
7
+ TAPBACK_VERBS = {
8
+ "love" => "Loved", "like" => "Liked", "dislike" => "Disliked",
9
+ "laugh" => "Laughed at", "emphasize" => "Emphasized", "question" => "Questioned"
10
+ }.freeze
11
+ # Sendblue's code for a message that was accepted and then failed to send.
12
+ SEND_FAILED = 10_001
13
+
14
+ module_function
15
+
16
+ # A message the app sent: walk it through Sendblue's statuses, telling the
17
+ # app about each one through its status callback and outbound webhook.
18
+ def deliver_to_phone(handle)
19
+ Runner.run do
20
+ outcome = PocketPhone.store.read.message(handle)&.dig("outcome")
21
+ steps = outcome == "failed" ? %w[ERROR] : %w[SENT DELIVERED]
22
+ steps.each do |status|
23
+ Runner.pause(PocketPhone.config.delivery_delay)
24
+ break unless advance(handle, status)
25
+ end
26
+ end
27
+ end
28
+
29
+ # A message a phone sent: the receive webhook.
30
+ def deliver_to_app(handle)
31
+ Runner.run do
32
+ database = PocketPhone.store.read
33
+ message = database.message(handle) or next
34
+ conversation = database.conversations[message["conversation_id"]] or next
35
+ result = receive(Payloads.message(message, conversation))
36
+ PocketPhone.store.write { |db| db.log_webhook(handle, result) }
37
+ end
38
+ end
39
+
40
+ # A tapback made on the phone, on something the app sent. Sendblue
41
+ # documents no webhook shape for these, so by default it travels as the
42
+ # text an iPhone falls back to: Loved “hello”.
43
+ def reaction_to_app(handle, by:, reaction:)
44
+ return unless PocketPhone.config.inbound_reactions == :text
45
+
46
+ Runner.run do
47
+ database = PocketPhone.store.read
48
+ target = database.message(handle) or next
49
+ conversation = database.conversations[target["conversation_id"]] or next
50
+ tapback = { "handle" => SecureRandom.uuid.upcase, "direction" => "phone", "status" => "RECEIVED",
51
+ "from_number" => by, "to_number" => conversation["line"],
52
+ "content" => reaction_text(target, reaction), "service" => target["service"],
53
+ "created_at" => PocketPhone.now, "updated_at" => PocketPhone.now }
54
+ result = receive(Payloads.message(tapback, conversation)).merge("type" => "reaction")
55
+ PocketPhone.store.write { |db| db.log_webhook(handle, result) }
56
+ end
57
+ end
58
+
59
+ def typing_to_app(conversation, is_typing:)
60
+ Runner.run do
61
+ url = Runner.with_app { PocketPhone.config.typing_webhook_url } or next
62
+ Webhook.deliver(type: "typing_indicator", url: url,
63
+ payload: Payloads.typing(number: conversation["number"], line: conversation["line"], is_typing: is_typing))
64
+ end
65
+ end
66
+
67
+ # Look up the card for a link, the way the sending phone does. Fetched
68
+ # fresh for every message, since the page you are previewing is likely the
69
+ # one you are editing.
70
+ def fetch_preview(handle)
71
+ return unless PocketPhone.config.link_previews
72
+
73
+ message = PocketPhone.store.read.message(handle) or return
74
+ url = LinkPreview.detect(message["content"])[:url] or return
75
+ Runner.run do
76
+ preview = LinkPreview.fetch(url)
77
+ PocketPhone.store.write { |db| db.previews[url] = preview }
78
+ end
79
+ end
80
+
81
+ def reaction_text(target, reaction)
82
+ quoted = target["content"].to_s.empty? ? "an attachment" : "“#{target["content"]}”"
83
+ verb = TAPBACK_VERBS[reaction]
84
+ verb ? "#{verb} #{quoted}" : "Reacted #{reaction} to #{quoted}"
85
+ end
86
+
87
+ # --- internals ----------------------------------------------------------
88
+
89
+ def advance(handle, status)
90
+ payload, callback = PocketPhone.store.write do |db|
91
+ message = db.message(handle) or next
92
+ conversation = db.conversations[message["conversation_id"]] or next
93
+ message["status"] = status
94
+ message["updated_at"] = PocketPhone.now
95
+ if status == "ERROR"
96
+ message["error_code"] = SEND_FAILED
97
+ message["error_message"] = "Message failed to send (simulated by pocket_phone)"
98
+ conversation["unread"] = [conversation["unread"].to_i - 1, 0].max
99
+ end
100
+ [Payloads.message(message, conversation), message["status_callback"]]
101
+ end
102
+ return false unless payload
103
+
104
+ outbound = Runner.with_app { PocketPhone.config.outbound_webhook_url }
105
+ [callback, outbound].compact.uniq.each do |url|
106
+ result = Webhook.deliver(type: "outbound #{status}", url: url, payload: payload)
107
+ PocketPhone.store.write { |db| db.log_webhook(handle, result) }
108
+ end
109
+ true
110
+ end
111
+
112
+ def receive(payload)
113
+ url = Runner.with_app { PocketPhone.config.webhook_url }
114
+ return Webhook.record("receive", "", "error" => "no webhook_url configured") unless url
115
+
116
+ Webhook.deliver(type: "receive", url: url, payload: payload)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ class Configuration
5
+ # Settings that may be given as a value or as a callable. A callable is
6
+ # resolved each time it is read, so it can lean on app code that is not
7
+ # loaded yet when the initializer runs (a credentials row, say).
8
+ LAZY = %i[
9
+ storage_path webhook_url outbound_webhook_url typing_webhook_url
10
+ signing_secret secret_header from_number account_email api_key_id api_secret
11
+ ].freeze
12
+
13
+ attr_writer(*LAZY)
14
+
15
+ # Deliver webhooks and fetch link previews on a background thread. Turn off
16
+ # in tests to make everything happen inline, with no delays.
17
+ attr_accessor :async
18
+ # Seconds between the QUEUED, SENT and DELIVERED steps of a message the app
19
+ # sends.
20
+ attr_accessor :delivery_delay
21
+ # Fetch og: tags for links, the way iMessage does.
22
+ attr_accessor :link_previews
23
+ # How a tapback made on the phone reaches the app: :text sends it as a
24
+ # received message ("Loved “hello”"), :none keeps it in the UI only.
25
+ attr_accessor :inbound_reactions
26
+ # Seconds to wait for the app to answer a webhook.
27
+ attr_accessor :webhook_timeout
28
+ # People to create on first use: [{ name: "Alice", number: "+15555550101",
29
+ # service: "iMessage" }]
30
+ attr_accessor :people
31
+
32
+ def initialize
33
+ @secret_header = "sb-signing-secret"
34
+ @account_email = "pocket_phone@example.com"
35
+ @async = true
36
+ @delivery_delay = 0.4
37
+ @link_previews = true
38
+ @inbound_reactions = :text
39
+ @webhook_timeout = 45
40
+ @people = []
41
+ end
42
+
43
+ LAZY.each do |name|
44
+ define_method(name) do
45
+ value = instance_variable_get(:"@#{name}")
46
+ value = value.call if value.respond_to?(:call)
47
+ value.respond_to?(:empty?) && value.empty? ? nil : value
48
+ end
49
+ end
50
+
51
+ def storage_path
52
+ value = @storage_path
53
+ value = value.call if value.respond_to?(:call)
54
+ return value.to_s if value
55
+
56
+ root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s : Dir.pwd
57
+ File.join(root, "tmp", "pocket_phone")
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module PocketPhone
7
+ # Everything pocket_phone knows, as one plain hash that round-trips through
8
+ # JSON: the app's lines, the fake people, their conversations and messages.
9
+ class Database
10
+ SERVICES = %w[iMessage SMS RCS].freeze
11
+ # What happens to a message the app sends this person: it arrives, it
12
+ # fails after being accepted, or the send is refused outright.
13
+ OUTCOMES = %w[delivered failed rejected].freeze
14
+
15
+ attr_reader :data
16
+
17
+ def self.blank
18
+ { "version" => 0, "lines" => {}, "people" => {}, "conversations" => {},
19
+ "messages" => {}, "previews" => {} }
20
+ end
21
+
22
+ def initialize(data)
23
+ @data = self.class.blank.merge(data || {})
24
+ end
25
+
26
+ def version = data["version"].to_i
27
+ def lines = data["lines"]
28
+ def people = data["people"]
29
+ def conversations = data["conversations"]
30
+ def messages = data["messages"]
31
+ def previews = data["previews"]
32
+
33
+ # --- lines and people ---------------------------------------------------
34
+
35
+ def ensure_line(number)
36
+ lines[number] ||= { "number" => number }
37
+ end
38
+
39
+ def ensure_person(number, attrs = {})
40
+ person = people[number] ||= { "number" => number, "name" => nil,
41
+ "service" => "iMessage", "outcome" => "delivered" }
42
+ attrs.each do |key, value|
43
+ next if value.nil? || value.to_s.empty?
44
+
45
+ person[key.to_s] = value.to_s
46
+ end
47
+ person["service"] = "iMessage" unless SERVICES.include?(person["service"])
48
+ person["outcome"] = "delivered" unless OUTCOMES.include?(person["outcome"])
49
+ person
50
+ end
51
+
52
+ # --- conversations ------------------------------------------------------
53
+
54
+ def direct(line, number, create: true)
55
+ id = "dm_#{Digest::SHA1.hexdigest("#{line}|#{number}")[0, 16]}"
56
+ return conversations[id] unless create
57
+
58
+ conversations[id] ||= { "id" => id, "kind" => "direct", "line" => line, "number" => number,
59
+ "unread" => 0, "updated_at" => PocketPhone.now }
60
+ end
61
+
62
+ # The one-to-one this number is in, whichever line it is with. For calls
63
+ # that name the person but not the line.
64
+ def latest_direct(number)
65
+ conversations.values
66
+ .select { |c| c["kind"] == "direct" && c["number"] == number }
67
+ .max_by { |c| c["updated_at"].to_s }
68
+ end
69
+
70
+ def group(group_id)
71
+ conversation = conversations[group_id]
72
+ conversation if conversation && conversation["kind"] == "group"
73
+ end
74
+
75
+ def create_group(line:, participants:, name: nil, group_id: nil)
76
+ id = group_id.to_s.empty? ? SecureRandom.uuid : group_id.to_s
77
+ participants.each { |number| ensure_person(number) }
78
+ conversations[id] = { "id" => id, "kind" => "group", "line" => line, "group_id" => id,
79
+ "participants" => participants.uniq, "name" => name.to_s.empty? ? nil : name,
80
+ "unread" => 0, "updated_at" => PocketPhone.now }
81
+ end
82
+
83
+ def sorted_conversations
84
+ conversations.values.sort_by { |c| c["updated_at"].to_s }.reverse
85
+ end
86
+
87
+ def group?(conversation) = conversation["kind"] == "group"
88
+
89
+ # A group is only blue when everyone in it is; one Android phone turns the
90
+ # whole thread into group MMS.
91
+ def service_for(conversation)
92
+ if group?(conversation)
93
+ blue = conversation["participants"].all? { |n| people.dig(n, "service").to_s == "iMessage" }
94
+ blue ? "iMessage" : "SMS"
95
+ else
96
+ people.dig(conversation["number"], "service") || "iMessage"
97
+ end
98
+ end
99
+
100
+ def typing?(conversation)
101
+ conversation && conversation["typing_until"].to_s > PocketPhone.now &&
102
+ service_for(conversation) == "iMessage"
103
+ end
104
+
105
+ def delete_conversation(id)
106
+ conversations.delete(id)
107
+ messages.delete_if { |_, message| message["conversation_id"] == id }
108
+ end
109
+
110
+ # --- messages -----------------------------------------------------------
111
+
112
+ def message(handle) = messages[handle.to_s]
113
+
114
+ def messages_for(conversation)
115
+ messages.values.select { |m| m["conversation_id"] == conversation["id"] }
116
+ end
117
+
118
+ def add_message(conversation, attrs)
119
+ now = PocketPhone.now
120
+ attrs = attrs.transform_keys(&:to_s).compact
121
+ from_app = attrs["direction"] == "app"
122
+ message = {
123
+ # Sendblue's own handles look like this: lower case for what you sent,
124
+ # upper case for what a phone sent you.
125
+ "handle" => from_app ? SecureRandom.uuid : SecureRandom.uuid.upcase,
126
+ "conversation_id" => conversation["id"], "created_at" => now, "updated_at" => now,
127
+ "reactions" => [], "webhooks" => []
128
+ }.merge(attrs)
129
+ messages[message["handle"]] = message
130
+
131
+ conversation["updated_at"] = now
132
+ if from_app
133
+ # The message is the answer the dots were promising.
134
+ conversation.delete("typing_until")
135
+ conversation["unread"] = conversation["unread"].to_i + 1 unless message["status"] == "ERROR"
136
+ end
137
+ message
138
+ end
139
+
140
+ # One reaction per sender per message, as in iMessage. Returns :added or
141
+ # :removed.
142
+ def react(message, by:, reaction:, remove: false)
143
+ list = message["reactions"] ||= []
144
+ existing = list.find { |r| r["by"] == by }
145
+ list.delete(existing) if existing
146
+ return :removed if remove || (existing && existing["reaction"] == reaction)
147
+
148
+ list << { "by" => by, "reaction" => reaction }
149
+ :added
150
+ end
151
+
152
+ def log_webhook(handle, result)
153
+ found = message(handle) or return
154
+ (found["webhooks"] ||= []) << result
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module PocketPhone
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace PocketPhone
8
+ end
9
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "net/http"
5
+ require "nokogiri"
6
+ require "uri"
7
+
8
+ module PocketPhone
9
+ # The rich link card iMessage draws for a URL, and the rules for when it
10
+ # draws one.
11
+ module LinkPreview
12
+ URL = %r{https?://[^\s<>"]+}i
13
+ # Punctuation that ends a sentence rather than a URL. iMessage does not
14
+ # expand a link that is touching any of it.
15
+ TRAILING = /[.,;:!?'")\]}>]+\z/
16
+ # What Messages sends when it fetches a page. Sites that serve og: tags
17
+ # only to crawlers key on the facebookexternalhit / Twitterbot part.
18
+ USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 " \
19
+ "(KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 " \
20
+ "facebookexternalhit/1.1 Facebot Twitterbot/1.0"
21
+ MAX_BYTES = 1_000_000
22
+ MAX_REDIRECTS = 5
23
+
24
+ SINGLE = "iMessage only previews a message with a single link"
25
+ EDGE = "iMessage only previews a link at the very start or end of a message, " \
26
+ "not mid-sentence or touching punctuation"
27
+
28
+ module_function
29
+
30
+ # Which link in this text iMessage would expand, if any, and why not when
31
+ # it would not: { url:, reason: }. A message with no links has neither.
32
+ def detect(text)
33
+ text = text.to_s.strip
34
+ urls = text.scan(URL).map { |url| url.sub(TRAILING, "") }
35
+ return { url: nil, reason: nil } if urls.empty?
36
+ return { url: nil, reason: SINGLE } if urls.size > 1
37
+
38
+ url = urls.first
39
+ at_start = text.start_with?(url) && text[url.length].to_s.match?(/\A(\s|\z)/)
40
+ at_end = text.end_with?(url) && (text.length == url.length || text[-url.length - 1].match?(/\s/))
41
+ at_start || at_end ? { url: url, reason: nil } : { url: nil, reason: EDGE }
42
+ end
43
+
44
+ # Fetch the page and read its og: tags. Never raises: a page that cannot
45
+ # be read comes back with "error" set.
46
+ def fetch(url)
47
+ uri, response = get(url)
48
+ type = response["content-type"].to_s
49
+ preview = base(url, uri)
50
+ if type.start_with?("image/")
51
+ preview.merge("image" => uri.to_s)
52
+ elsif type.include?("html") || type.empty?
53
+ preview.merge(parse(response.body.to_s.byteslice(0, MAX_BYTES), uri))
54
+ else
55
+ preview.merge("title" => File.basename(uri.path))
56
+ end
57
+ rescue StandardError => e
58
+ base(url, nil).merge("error" => "#{e.class.name}: #{e.message}")
59
+ end
60
+
61
+ def parse(html, uri)
62
+ document = Nokogiri::HTML(html)
63
+ meta = lambda do |*names|
64
+ names.each do |name|
65
+ node = document.at_css(%(meta[property="#{name}"], meta[name="#{name}"]))
66
+ value = node && node["content"].to_s.strip
67
+ return value unless value.nil? || value.empty?
68
+ end
69
+ nil
70
+ end
71
+ icon = document.at_css('link[rel="apple-touch-icon"], link[rel~="icon"]')
72
+
73
+ {
74
+ "title" => meta.call("og:title", "twitter:title") || document.at_css("title")&.text&.strip,
75
+ "description" => meta.call("og:description", "twitter:description", "description"),
76
+ "site_name" => meta.call("og:site_name"),
77
+ "image" => resolve(uri, meta.call("og:image:secure_url", "og:image", "twitter:image")),
78
+ "icon" => resolve(uri, icon && icon["href"])
79
+ }.compact
80
+ end
81
+
82
+ # A page the real thing could never show: Apple's fetch happens on a
83
+ # phone, and a phone cannot reach your laptop's localhost.
84
+ def local?(host)
85
+ host = host.to_s.downcase.delete_prefix("[").delete_suffix("]")
86
+ return true if host == "localhost" || host.end_with?(".localhost", ".local", ".test", ".internal")
87
+
88
+ address = IPAddr.new(host)
89
+ address.loopback? || address.private? || address.link_local?
90
+ rescue IPAddr::InvalidAddressError
91
+ false
92
+ end
93
+
94
+ def get(url, redirects = MAX_REDIRECTS)
95
+ uri = URI.parse(url)
96
+ raise ArgumentError, "not an http(s) URL" unless uri.is_a?(URI::HTTP) && uri.host
97
+
98
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
99
+ open_timeout: 3, read_timeout: 5) do |http|
100
+ http.get(uri.request_uri, "User-Agent" => USER_AGENT, "Accept" => "text/html,*/*")
101
+ end
102
+ if response.is_a?(Net::HTTPRedirection) && response["location"]
103
+ raise "too many redirects" if redirects.zero?
104
+
105
+ return get(URI.join(uri, response["location"]).to_s, redirects - 1)
106
+ end
107
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
108
+
109
+ [uri, response]
110
+ end
111
+
112
+ def base(url, uri)
113
+ host = (uri || URI.parse(url)).host.to_s
114
+ { "url" => url, "host" => host.delete_prefix("www."), "local" => local?(host),
115
+ "fetched_at" => PocketPhone.now }
116
+ rescue URI::InvalidURIError
117
+ { "url" => url, "host" => "", "local" => false, "fetched_at" => PocketPhone.now }
118
+ end
119
+
120
+ def resolve(uri, href)
121
+ return nil if href.nil? || href.empty?
122
+
123
+ URI.join(uri, href).to_s
124
+ rescue URI::Error
125
+ nil
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ module Numbers
5
+ E164 = /\A\+[1-9]\d{6,14}\z/
6
+ EMAIL = /\A[^@\s]+@[^@\s]+\z/
7
+
8
+ module_function
9
+
10
+ # What the UI accepts: anything a person might type. "555 123 4567" becomes
11
+ # +15551234567; an iMessage email address is kept as it is.
12
+ def normalize(value)
13
+ text = value.to_s.strip
14
+ return text.downcase if text.include?("@")
15
+
16
+ digits = text.gsub(/\D/, "")
17
+ return "" if digits.empty?
18
+ return "+#{digits}" if text.start_with?("+")
19
+
20
+ digits.length == 10 ? "+1#{digits}" : "+#{digits}"
21
+ end
22
+
23
+ # What the API accepts: Sendblue takes E.164 (or an email) and nothing else.
24
+ def valid?(value)
25
+ text = value.to_s
26
+ text.match?(E164) || text.match?(EMAIL)
27
+ end
28
+
29
+ def pretty(number)
30
+ text = number.to_s
31
+ match = text.match(/\A\+1(\d{3})(\d{3})(\d{4})\z/)
32
+ match ? "+1 (#{match[1]}) #{match[2]}-#{match[3]}" : text
33
+ end
34
+
35
+ # A number nobody owns, for the "new person" form.
36
+ def random
37
+ format("+1555555%04d", rand(100..9999))
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ # The JSON Sendblue uses to describe a message: the same shape answers a
5
+ # send, arrives in a status callback and arrives in the receive webhook.
6
+ module Payloads
7
+ module_function
8
+
9
+ def message(message, conversation)
10
+ from_app = message["direction"] == "app"
11
+ group = conversation["kind"] == "group"
12
+ counterpart = from_app ? message["to_number"] : message["from_number"]
13
+
14
+ {
15
+ "accountEmail" => PocketPhone.config.account_email,
16
+ "content" => message["content"].to_s,
17
+ "is_outbound" => from_app,
18
+ "status" => message["status"],
19
+ "error_code" => message["error_code"],
20
+ "error_message" => message["error_message"],
21
+ "error_detail" => nil,
22
+ "message_handle" => message["handle"],
23
+ "date_sent" => message["created_at"],
24
+ "date_updated" => message["updated_at"],
25
+ "from_number" => message["from_number"].to_s,
26
+ "number" => counterpart.to_s,
27
+ "to_number" => message["to_number"].to_s,
28
+ "sendblue_number" => conversation["line"],
29
+ "was_downgraded" => from_app ? message["service"] != "iMessage" : nil,
30
+ "plan" => "blue",
31
+ "media_url" => message["media_url"].to_s,
32
+ "message_type" => group ? "group" : "message",
33
+ "group_id" => group ? conversation["group_id"] : "",
34
+ "group_display_name" => group ? conversation["name"] : nil,
35
+ "participants" => group ? conversation["participants"] : [],
36
+ "send_style" => message["send_style"].to_s,
37
+ "opted_out" => false,
38
+ "service" => message["service"]
39
+ }.tap do |payload|
40
+ payload["media_urls"] = message["media_urls"] if message["media_urls"]
41
+ payload["app_card"] = message["app_card"] if message["app_card"]
42
+ end
43
+ end
44
+
45
+ def typing(number:, line:, is_typing:)
46
+ { "number" => number, "is_typing" => is_typing, "from_number" => line,
47
+ "timestamp" => PocketPhone.now }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ # Where work that must not hold up a request goes. Sendblue answers a send
5
+ # straight away and reports what happened later; so does this.
6
+ module Runner
7
+ module_function
8
+
9
+ def run(&block)
10
+ return block.call unless PocketPhone.config.async
11
+
12
+ Thread.new do
13
+ block.call
14
+ rescue StandardError => e
15
+ log("#{e.class}: #{e.message}")
16
+ end
17
+ end
18
+
19
+ def pause(seconds)
20
+ sleep(seconds) if PocketPhone.config.async && seconds.to_f.positive?
21
+ end
22
+
23
+ # For the moments a background thread touches the host app's code (a
24
+ # configured lambda reading a model). Kept short on purpose: the thread
25
+ # must not be inside the executor while it calls the app over HTTP, or a
26
+ # code reload waiting on it would wait on itself.
27
+ def with_app(&block)
28
+ app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
29
+ app ? app.executor.wrap(&block) : block.call
30
+ end
31
+
32
+ def log(message)
33
+ logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
34
+ logger&.warn("[pocket_phone] #{message}")
35
+ end
36
+ end
37
+ end