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,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,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
|