pocket_phone 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +2 -0
- data/app/controllers/pocket_phone/sendblue_controller.rb +327 -0
- data/config/routes.rb +1 -1
- data/lib/pocket_phone/version.rb +1 -1
- metadata +2 -2
- data/app/controllers/pocket_phone/api/sendblue_controller.rb +0 -329
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74aeeec3868590557dc67265922f7b462df2d96da12a44bcb7672e62938441c0
|
|
4
|
+
data.tar.gz: 16acb2e9badcd39fcdd095cd5a64820b936c4a583679285bbc32a4e8c8366386
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d179161edaed347e9cb88f328e0c8664c06a13d0fb23a1c983884624f17377f10ebfd6ae2d63f3eefd53d019960a761945512f30e5e6e78a804a2a0748708359
|
|
7
|
+
data.tar.gz: 56aeff7d09598b352b85c068f065c2fe4a1b5cb4e48802ed510052caa09684276312726fb1a604d0e16f21d74e4b8b633a1d83b1c24b7e2c6bce04ec164bddf0
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -11,6 +11,8 @@ app sends to it instead of to `api.sendblue.com`; what it sent shows up in an
|
|
|
11
11
|
iMessage-style inbox in your browser, and what you type there reaches your app
|
|
12
12
|
as a signed Sendblue webhook. No real phone, no real texts, no tunnel.
|
|
13
13
|
|
|
14
|
+

|
|
15
|
+
|
|
14
16
|
- Blue bubbles for iMessage, green for SMS and RCS
|
|
15
17
|
- Text back as any number of fake people, one-to-one or in a group
|
|
16
18
|
- Typing dots, tapbacks, read receipts, attachments
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PocketPhone
|
|
4
|
+
# The part of Sendblue's API that pocket_phone answers. Requests are
|
|
5
|
+
# checked the way Sendblue checks them, so a client that would be refused
|
|
6
|
+
# for real is refused here too; nothing leaves the machine.
|
|
7
|
+
class SendblueController < ActionController::API
|
|
8
|
+
TAPBACKS = %w[love like dislike laugh emphasize question].freeze
|
|
9
|
+
# Sendblue's code for a number it will not send to.
|
|
10
|
+
BLOCKED = 4002
|
|
11
|
+
|
|
12
|
+
# A request Sendblue would turn down, with the status and body it uses.
|
|
13
|
+
class Refusal < StandardError
|
|
14
|
+
attr_reader :status, :body
|
|
15
|
+
|
|
16
|
+
def initialize(status, body)
|
|
17
|
+
@status = status
|
|
18
|
+
@body = body
|
|
19
|
+
super(body["message"] || body["error_message"])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
wrap_parameters false
|
|
24
|
+
rescue_from(Refusal) { |refusal| render json: refusal.body, status: refusal.status }
|
|
25
|
+
before_action :remember_base_url, :require_credentials
|
|
26
|
+
|
|
27
|
+
# --- sending ----------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def send_message
|
|
30
|
+
number = recipient!(params[:number])
|
|
31
|
+
line = line!
|
|
32
|
+
body = message_body!
|
|
33
|
+
|
|
34
|
+
message, conversation = store.write do |db|
|
|
35
|
+
db.ensure_line(line)
|
|
36
|
+
person = db.ensure_person(number)
|
|
37
|
+
conversation = db.direct(line, number)
|
|
38
|
+
attrs = body.merge("to_number" => number, "service" => person["service"], "outcome" => person["outcome"])
|
|
39
|
+
[db.add_message(conversation, outbound(line, attrs)), conversation]
|
|
40
|
+
end
|
|
41
|
+
carry(message)
|
|
42
|
+
render json: Payloads.message(message, conversation)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A group is addressed by `group_id`, or made on the spot from `numbers`.
|
|
46
|
+
def send_group_message
|
|
47
|
+
line = line!
|
|
48
|
+
body = message_body!
|
|
49
|
+
numbers = Array(params[:numbers]).map(&:to_s)
|
|
50
|
+
numbers.each { |number| recipient!(number) }
|
|
51
|
+
group_id = params[:group_id].to_s
|
|
52
|
+
|
|
53
|
+
message, conversation = store.write do |db|
|
|
54
|
+
db.ensure_line(line)
|
|
55
|
+
conversation = db.group(group_id) if group_id.present?
|
|
56
|
+
if conversation.nil?
|
|
57
|
+
next if numbers.size < 2
|
|
58
|
+
|
|
59
|
+
conversation = db.create_group(line: line, participants: numbers, group_id: group_id)
|
|
60
|
+
end
|
|
61
|
+
attrs = body.merge("service" => db.service_for(conversation), "outcome" => "delivered")
|
|
62
|
+
[db.add_message(conversation, outbound(line, attrs)), conversation]
|
|
63
|
+
end
|
|
64
|
+
unless message
|
|
65
|
+
refuse!(400, group_id.present? ? "Group #{group_id} was not found; send `numbers` to create it." :
|
|
66
|
+
"You must specify a `group_id` or at least two `numbers`.")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
carry(message)
|
|
70
|
+
render json: Payloads.message(message, conversation)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def send_carousel
|
|
74
|
+
number = recipient!(params[:number])
|
|
75
|
+
line = line!
|
|
76
|
+
urls = Array(params[:media_urls]).map(&:to_s)
|
|
77
|
+
refuse!(400, "You must provide between 2 and 20 HTTPS image URLs.") unless urls.size.between?(2, 20)
|
|
78
|
+
|
|
79
|
+
message, conversation = store.write do |db|
|
|
80
|
+
db.ensure_line(line)
|
|
81
|
+
person = db.ensure_person(number)
|
|
82
|
+
refuse!(422, "Carousels are only supported on iMessage") unless person["service"] == "iMessage"
|
|
83
|
+
|
|
84
|
+
conversation = db.direct(line, number)
|
|
85
|
+
attrs = { "to_number" => number, "media_urls" => urls, "service" => "iMessage",
|
|
86
|
+
"outcome" => person["outcome"], "send_style" => params[:send_style].presence }
|
|
87
|
+
[db.add_message(conversation, outbound(line, attrs)), conversation]
|
|
88
|
+
end
|
|
89
|
+
carry(message)
|
|
90
|
+
render json: Payloads.message(message, conversation)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- the small gestures -----------------------------------------------
|
|
94
|
+
|
|
95
|
+
def send_typing_indicator
|
|
96
|
+
refuse!(400, "You must specify a `number` in the request body.") if params[:number].blank?
|
|
97
|
+
number = recipient!(params[:number])
|
|
98
|
+
state = params[:state].presence || "start"
|
|
99
|
+
refuse!(400, '`state` must be either "start" or "stop".') unless %w[start stop].include?(state)
|
|
100
|
+
duration = Integer(params[:max_duration_ms].presence || 60_000, exception: false)
|
|
101
|
+
unless duration&.between?(1, 300_000)
|
|
102
|
+
refuse!(400, "`max_duration_ms` must be an integer between 1 and 300000.")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
store.write do |db|
|
|
106
|
+
line = Numbers.normalize(params[:from_number])
|
|
107
|
+
conversation = line.empty? ? db.latest_direct(number) : db.direct(line, number, create: false)
|
|
108
|
+
next unless conversation
|
|
109
|
+
|
|
110
|
+
if state == "start"
|
|
111
|
+
conversation["typing_until"] = (Time.now.utc + (duration / 1000.0)).iso8601(3)
|
|
112
|
+
else
|
|
113
|
+
conversation.delete("typing_until")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
render json: { status: "QUEUED", status_code: 200, error_message: nil, number: number }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def send_reaction
|
|
120
|
+
line!
|
|
121
|
+
reaction = params[:reaction].to_s
|
|
122
|
+
remove = reaction.start_with?("-")
|
|
123
|
+
name = reaction.delete_prefix("-")
|
|
124
|
+
unless TAPBACKS.include?(name) || single_emoji?(name)
|
|
125
|
+
refuse!(400, "Invalid reaction. Must be one of love, like, dislike, laugh, emphasize, question, " \
|
|
126
|
+
"or exactly one emoji")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
store.write do |db|
|
|
130
|
+
message = db.message(params[:message_handle])
|
|
131
|
+
unless message && message["direction"] == "phone"
|
|
132
|
+
refuse!(404, "The message to react to was not found for this account")
|
|
133
|
+
end
|
|
134
|
+
refuse!(422, "Reactions are only supported on iMessage messages") unless message["service"] == "iMessage"
|
|
135
|
+
|
|
136
|
+
db.react(message, by: "line", reaction: name, remove: remove)
|
|
137
|
+
end
|
|
138
|
+
render json: { status: "OK", message: "Reaction request sent",
|
|
139
|
+
message_handle: params[:message_handle], reaction: reaction }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def mark_read
|
|
143
|
+
number = recipient!(params[:number])
|
|
144
|
+
line = line!
|
|
145
|
+
store.write do |db|
|
|
146
|
+
conversation = db.direct(line, number, create: false)
|
|
147
|
+
conversation["read_at"] = PocketPhone.now if conversation
|
|
148
|
+
end
|
|
149
|
+
render json: { status: "OK", message: "Mark read request sent", number: number }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def evaluate_service
|
|
153
|
+
number = recipient!(params[:number])
|
|
154
|
+
render json: { number: number, service: store.read.people.dig(number, "service") || "iMessage" }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The name and photo iMessage shows for the line.
|
|
158
|
+
def contact_profile
|
|
159
|
+
line = Numbers.normalize(params[:fromNumber] || params[:from_number])
|
|
160
|
+
refuse!(400, "You must specify a `fromNumber` in the request body.") if line.empty?
|
|
161
|
+
|
|
162
|
+
name = [params[:firstName] || params[:first_name], params[:lastName] || params[:last_name]].compact.join(" ")
|
|
163
|
+
store.write do |db|
|
|
164
|
+
profile = db.ensure_line(line)
|
|
165
|
+
profile["name"] = name.presence
|
|
166
|
+
profile["photo_url"] = (params[:photoUrl] || params[:photo_url]).presence
|
|
167
|
+
end
|
|
168
|
+
render json: { status: "OK" }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def modify_group
|
|
172
|
+
number = recipient!(params[:number])
|
|
173
|
+
type = params[:modify_type].to_s
|
|
174
|
+
refuse!(400, '`modify_type` must be "add_recipient" or "remove_recipient".') unless
|
|
175
|
+
%w[add_recipient remove_recipient].include?(type)
|
|
176
|
+
|
|
177
|
+
echo = { "group_id" => params[:group_id].to_s, "modify_type" => type, "number" => number }
|
|
178
|
+
store.write do |db|
|
|
179
|
+
conversation = db.group(params[:group_id]) or
|
|
180
|
+
refuse!(404, "Group #{params[:group_id]} was not found", echo.merge("error" => "group_not_found"))
|
|
181
|
+
if type == "add_recipient"
|
|
182
|
+
db.ensure_person(number)
|
|
183
|
+
conversation["participants"] |= [number]
|
|
184
|
+
elsif conversation["participants"].size <= 2
|
|
185
|
+
refuse!(400, "Cannot remove from a group with 3 total members; Apple requires at least 3.",
|
|
186
|
+
echo.merge("error" => "group_too_small"))
|
|
187
|
+
else
|
|
188
|
+
conversation["participants"] -= [number]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
render json: { "status" => "OK" }.merge(echo)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# --- media ------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def upload_file
|
|
197
|
+
file = params[:file]
|
|
198
|
+
refuse!(400, "You must attach a `file`.") unless file.respond_to?(:original_filename)
|
|
199
|
+
|
|
200
|
+
name = store.save_media(file, file.original_filename)
|
|
201
|
+
render status: :created, json: { status: "OK", message: "File uploaded successfully",
|
|
202
|
+
media_url: media_url_for(name), mediaObjectId: name }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def upload_media_object
|
|
206
|
+
refuse!(400, "You must specify a `media_url` in the request body.") if params[:media_url].blank?
|
|
207
|
+
|
|
208
|
+
render status: :created, json: { status: "OK", message: "File uploaded successfully",
|
|
209
|
+
mediaObjectId: File.basename(URI.parse(params[:media_url].to_s).path) }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# --- reading back -----------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def messages
|
|
215
|
+
db = store.read
|
|
216
|
+
found = db.messages.values.reverse.filter_map do |message|
|
|
217
|
+
conversation = db.conversations[message["conversation_id"]]
|
|
218
|
+
conversation && Payloads.message(message, conversation)
|
|
219
|
+
end
|
|
220
|
+
found.select! { |m| m["is_outbound"].to_s == params[:is_outbound] } if params[:is_outbound].present?
|
|
221
|
+
%w[status service group_id from_number to_number number].each do |field|
|
|
222
|
+
found.select! { |m| m[field].to_s == params[field].to_s } if params[field].present?
|
|
223
|
+
end
|
|
224
|
+
limit = params.fetch(:limit, 100).to_i.clamp(1, 100)
|
|
225
|
+
offset = [params[:offset].to_i, 0].max
|
|
226
|
+
render json: { status: "OK", data: found[offset, limit] || [],
|
|
227
|
+
pagination: { limit: limit, offset: offset, total: found.size } }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def message
|
|
231
|
+
db = store.read
|
|
232
|
+
found = db.message(params[:handle]) or refuse!(404, "Message not found")
|
|
233
|
+
render json: { status: "OK", data: Payloads.message(found, db.conversations[found["conversation_id"]]) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def delete_message
|
|
237
|
+
deleted = store.write { |db| db.messages.delete(params[:handle]) }
|
|
238
|
+
refuse!(404, "Message not found") unless deleted
|
|
239
|
+
|
|
240
|
+
render json: { status: "OK", message: "Message deleted" }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def missing
|
|
244
|
+
refuse!(404, "pocket_phone does not fake #{request.method} #{request.path.delete_prefix(request.script_name)} yet")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def store = PocketPhone.store
|
|
250
|
+
|
|
251
|
+
def remember_base_url
|
|
252
|
+
PocketPhone.base_url = request.base_url
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Sendblue wants both keys on every call. With none configured here any
|
|
256
|
+
# pair will do, so long as the client sent one.
|
|
257
|
+
def require_credentials
|
|
258
|
+
key = request.headers["sb-api-key-id"].to_s
|
|
259
|
+
secret = request.headers["sb-api-secret-key"].to_s
|
|
260
|
+
expected_key = PocketPhone.config.api_key_id
|
|
261
|
+
expected_secret = PocketPhone.config.api_secret
|
|
262
|
+
valid = key.present? && secret.present? &&
|
|
263
|
+
(expected_key.nil? || expected_key.to_s == key) &&
|
|
264
|
+
(expected_secret.nil? || expected_secret.to_s == secret)
|
|
265
|
+
refuse!(401, "Missing or invalid `sb-api-key-id` / `sb-api-secret-key` headers.") unless valid
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def refuse!(status, message, extra = {})
|
|
269
|
+
raise Refusal.new(status, { "status" => "ERROR", "message" => message, "error_message" => message }.merge(extra))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def recipient!(value)
|
|
273
|
+
refuse!(400, "You must specify a `number` in the request body.") if value.blank?
|
|
274
|
+
refuse!(400, "`#{value}` is not a valid E.164 phone number (for example +19998887777).") unless
|
|
275
|
+
Numbers.valid?(value)
|
|
276
|
+
|
|
277
|
+
value.to_s
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def line!
|
|
281
|
+
value = params[:from_number]
|
|
282
|
+
refuse!(400, "You must specify a `from_number` in the request body.") if value.blank?
|
|
283
|
+
refuse!(400, "`from_number` must be an E.164 phone number on your account.") unless
|
|
284
|
+
value.to_s.match?(Numbers::E164)
|
|
285
|
+
|
|
286
|
+
value.to_s
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def message_body!
|
|
290
|
+
card = params[:app_card].presence && params[:app_card].to_unsafe_h
|
|
291
|
+
if params[:content].blank? && params[:media_url].blank? && card.nil?
|
|
292
|
+
refuse!(400, "You must specify `content`, a `media_url` or an `app_card`.")
|
|
293
|
+
end
|
|
294
|
+
refuse!(400, "An `app_card` cannot be combined with `media_url`.") if card && params[:media_url].present?
|
|
295
|
+
|
|
296
|
+
{ "content" => params[:content].to_s, "media_url" => params[:media_url].presence,
|
|
297
|
+
"send_style" => params[:send_style].presence, "app_card" => card }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def outbound(line, attrs)
|
|
301
|
+
attrs = attrs.merge("direction" => "app", "from_number" => line, "status" => "QUEUED",
|
|
302
|
+
"status_callback" => params[:status_callback].presence,
|
|
303
|
+
"request" => request.request_parameters.except("format"))
|
|
304
|
+
return attrs unless attrs["outcome"] == "rejected"
|
|
305
|
+
|
|
306
|
+
# Sendblue answers 200 with status ERROR when it refuses a send, so
|
|
307
|
+
# the HTTP status alone does not say whether a message went out.
|
|
308
|
+
attrs.merge("status" => "ERROR", "error_code" => BLOCKED,
|
|
309
|
+
"error_message" => "This number cannot be messaged (simulated by pocket_phone)")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def carry(message)
|
|
313
|
+
return if message["status"] == "ERROR"
|
|
314
|
+
|
|
315
|
+
Carrier.deliver_to_phone(message["handle"])
|
|
316
|
+
Carrier.fetch_preview(message["handle"])
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def single_emoji?(text)
|
|
320
|
+
text.match?(/\A\X\z/) && text.match?(/\p{Emoji}/) && !text.match?(/\A[0-9#*]\z/)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def media_url_for(name)
|
|
324
|
+
"#{request.base_url}#{request.script_name}/media/#{name}"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -18,7 +18,7 @@ PocketPhone::Engine.routes.draw do
|
|
|
18
18
|
# --- the fake Sendblue API ------------------------------------------------
|
|
19
19
|
# Point your Sendblue client's base URL at wherever this engine is mounted
|
|
20
20
|
# and these answer in place of https://api.sendblue.com.
|
|
21
|
-
scope
|
|
21
|
+
scope defaults: { format: :json } do
|
|
22
22
|
post "api/send-message" => "sendblue#send_message"
|
|
23
23
|
post "api/send-group-message" => "sendblue#send_group_message"
|
|
24
24
|
post "api/send-carousel" => "sendblue#send_carousel"
|
data/lib/pocket_phone/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pocket_phone
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hmk
|
|
@@ -78,12 +78,12 @@ files:
|
|
|
78
78
|
- CHANGELOG.md
|
|
79
79
|
- LICENSE
|
|
80
80
|
- README.md
|
|
81
|
-
- app/controllers/pocket_phone/api/sendblue_controller.rb
|
|
82
81
|
- app/controllers/pocket_phone/application_controller.rb
|
|
83
82
|
- app/controllers/pocket_phone/conversations_controller.rb
|
|
84
83
|
- app/controllers/pocket_phone/media_controller.rb
|
|
85
84
|
- app/controllers/pocket_phone/messages_controller.rb
|
|
86
85
|
- app/controllers/pocket_phone/people_controller.rb
|
|
86
|
+
- app/controllers/pocket_phone/sendblue_controller.rb
|
|
87
87
|
- app/helpers/pocket_phone/application_helper.rb
|
|
88
88
|
- app/views/layouts/pocket_phone/_script.html.erb
|
|
89
89
|
- app/views/layouts/pocket_phone/_styles.html.erb
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module PocketPhone
|
|
4
|
-
module Api
|
|
5
|
-
# The part of Sendblue's API that pocket_phone answers. Requests are
|
|
6
|
-
# checked the way Sendblue checks them, so a client that would be refused
|
|
7
|
-
# for real is refused here too; nothing leaves the machine.
|
|
8
|
-
class SendblueController < ActionController::API
|
|
9
|
-
TAPBACKS = %w[love like dislike laugh emphasize question].freeze
|
|
10
|
-
# Sendblue's code for a number it will not send to.
|
|
11
|
-
BLOCKED = 4002
|
|
12
|
-
|
|
13
|
-
# A request Sendblue would turn down, with the status and body it uses.
|
|
14
|
-
class Refusal < StandardError
|
|
15
|
-
attr_reader :status, :body
|
|
16
|
-
|
|
17
|
-
def initialize(status, body)
|
|
18
|
-
@status = status
|
|
19
|
-
@body = body
|
|
20
|
-
super(body["message"] || body["error_message"])
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
wrap_parameters false
|
|
25
|
-
rescue_from(Refusal) { |refusal| render json: refusal.body, status: refusal.status }
|
|
26
|
-
before_action :remember_base_url, :require_credentials
|
|
27
|
-
|
|
28
|
-
# --- sending ----------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
def send_message
|
|
31
|
-
number = recipient!(params[:number])
|
|
32
|
-
line = line!
|
|
33
|
-
body = message_body!
|
|
34
|
-
|
|
35
|
-
message, conversation = store.write do |db|
|
|
36
|
-
db.ensure_line(line)
|
|
37
|
-
person = db.ensure_person(number)
|
|
38
|
-
conversation = db.direct(line, number)
|
|
39
|
-
attrs = body.merge("to_number" => number, "service" => person["service"], "outcome" => person["outcome"])
|
|
40
|
-
[db.add_message(conversation, outbound(line, attrs)), conversation]
|
|
41
|
-
end
|
|
42
|
-
carry(message)
|
|
43
|
-
render json: Payloads.message(message, conversation)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# A group is addressed by `group_id`, or made on the spot from `numbers`.
|
|
47
|
-
def send_group_message
|
|
48
|
-
line = line!
|
|
49
|
-
body = message_body!
|
|
50
|
-
numbers = Array(params[:numbers]).map(&:to_s)
|
|
51
|
-
numbers.each { |number| recipient!(number) }
|
|
52
|
-
group_id = params[:group_id].to_s
|
|
53
|
-
|
|
54
|
-
message, conversation = store.write do |db|
|
|
55
|
-
db.ensure_line(line)
|
|
56
|
-
conversation = db.group(group_id) if group_id.present?
|
|
57
|
-
if conversation.nil?
|
|
58
|
-
next if numbers.size < 2
|
|
59
|
-
|
|
60
|
-
conversation = db.create_group(line: line, participants: numbers, group_id: group_id)
|
|
61
|
-
end
|
|
62
|
-
attrs = body.merge("service" => db.service_for(conversation), "outcome" => "delivered")
|
|
63
|
-
[db.add_message(conversation, outbound(line, attrs)), conversation]
|
|
64
|
-
end
|
|
65
|
-
unless message
|
|
66
|
-
refuse!(400, group_id.present? ? "Group #{group_id} was not found; send `numbers` to create it." :
|
|
67
|
-
"You must specify a `group_id` or at least two `numbers`.")
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
carry(message)
|
|
71
|
-
render json: Payloads.message(message, conversation)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def send_carousel
|
|
75
|
-
number = recipient!(params[:number])
|
|
76
|
-
line = line!
|
|
77
|
-
urls = Array(params[:media_urls]).map(&:to_s)
|
|
78
|
-
refuse!(400, "You must provide between 2 and 20 HTTPS image URLs.") unless urls.size.between?(2, 20)
|
|
79
|
-
|
|
80
|
-
message, conversation = store.write do |db|
|
|
81
|
-
db.ensure_line(line)
|
|
82
|
-
person = db.ensure_person(number)
|
|
83
|
-
refuse!(422, "Carousels are only supported on iMessage") unless person["service"] == "iMessage"
|
|
84
|
-
|
|
85
|
-
conversation = db.direct(line, number)
|
|
86
|
-
attrs = { "to_number" => number, "media_urls" => urls, "service" => "iMessage",
|
|
87
|
-
"outcome" => person["outcome"], "send_style" => params[:send_style].presence }
|
|
88
|
-
[db.add_message(conversation, outbound(line, attrs)), conversation]
|
|
89
|
-
end
|
|
90
|
-
carry(message)
|
|
91
|
-
render json: Payloads.message(message, conversation)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# --- the small gestures -----------------------------------------------
|
|
95
|
-
|
|
96
|
-
def send_typing_indicator
|
|
97
|
-
refuse!(400, "You must specify a `number` in the request body.") if params[:number].blank?
|
|
98
|
-
number = recipient!(params[:number])
|
|
99
|
-
state = params[:state].presence || "start"
|
|
100
|
-
refuse!(400, '`state` must be either "start" or "stop".') unless %w[start stop].include?(state)
|
|
101
|
-
duration = Integer(params[:max_duration_ms].presence || 60_000, exception: false)
|
|
102
|
-
unless duration&.between?(1, 300_000)
|
|
103
|
-
refuse!(400, "`max_duration_ms` must be an integer between 1 and 300000.")
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
store.write do |db|
|
|
107
|
-
line = Numbers.normalize(params[:from_number])
|
|
108
|
-
conversation = line.empty? ? db.latest_direct(number) : db.direct(line, number, create: false)
|
|
109
|
-
next unless conversation
|
|
110
|
-
|
|
111
|
-
if state == "start"
|
|
112
|
-
conversation["typing_until"] = (Time.now.utc + (duration / 1000.0)).iso8601(3)
|
|
113
|
-
else
|
|
114
|
-
conversation.delete("typing_until")
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
render json: { status: "QUEUED", status_code: 200, error_message: nil, number: number }
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def send_reaction
|
|
121
|
-
line!
|
|
122
|
-
reaction = params[:reaction].to_s
|
|
123
|
-
remove = reaction.start_with?("-")
|
|
124
|
-
name = reaction.delete_prefix("-")
|
|
125
|
-
unless TAPBACKS.include?(name) || single_emoji?(name)
|
|
126
|
-
refuse!(400, "Invalid reaction. Must be one of love, like, dislike, laugh, emphasize, question, " \
|
|
127
|
-
"or exactly one emoji")
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
store.write do |db|
|
|
131
|
-
message = db.message(params[:message_handle])
|
|
132
|
-
unless message && message["direction"] == "phone"
|
|
133
|
-
refuse!(404, "The message to react to was not found for this account")
|
|
134
|
-
end
|
|
135
|
-
refuse!(422, "Reactions are only supported on iMessage messages") unless message["service"] == "iMessage"
|
|
136
|
-
|
|
137
|
-
db.react(message, by: "line", reaction: name, remove: remove)
|
|
138
|
-
end
|
|
139
|
-
render json: { status: "OK", message: "Reaction request sent",
|
|
140
|
-
message_handle: params[:message_handle], reaction: reaction }
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def mark_read
|
|
144
|
-
number = recipient!(params[:number])
|
|
145
|
-
line = line!
|
|
146
|
-
store.write do |db|
|
|
147
|
-
conversation = db.direct(line, number, create: false)
|
|
148
|
-
conversation["read_at"] = PocketPhone.now if conversation
|
|
149
|
-
end
|
|
150
|
-
render json: { status: "OK", message: "Mark read request sent", number: number }
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def evaluate_service
|
|
154
|
-
number = recipient!(params[:number])
|
|
155
|
-
render json: { number: number, service: store.read.people.dig(number, "service") || "iMessage" }
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# The name and photo iMessage shows for the line.
|
|
159
|
-
def contact_profile
|
|
160
|
-
line = Numbers.normalize(params[:fromNumber] || params[:from_number])
|
|
161
|
-
refuse!(400, "You must specify a `fromNumber` in the request body.") if line.empty?
|
|
162
|
-
|
|
163
|
-
name = [params[:firstName] || params[:first_name], params[:lastName] || params[:last_name]].compact.join(" ")
|
|
164
|
-
store.write do |db|
|
|
165
|
-
profile = db.ensure_line(line)
|
|
166
|
-
profile["name"] = name.presence
|
|
167
|
-
profile["photo_url"] = (params[:photoUrl] || params[:photo_url]).presence
|
|
168
|
-
end
|
|
169
|
-
render json: { status: "OK" }
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def modify_group
|
|
173
|
-
number = recipient!(params[:number])
|
|
174
|
-
type = params[:modify_type].to_s
|
|
175
|
-
refuse!(400, '`modify_type` must be "add_recipient" or "remove_recipient".') unless
|
|
176
|
-
%w[add_recipient remove_recipient].include?(type)
|
|
177
|
-
|
|
178
|
-
echo = { "group_id" => params[:group_id].to_s, "modify_type" => type, "number" => number }
|
|
179
|
-
store.write do |db|
|
|
180
|
-
conversation = db.group(params[:group_id]) or
|
|
181
|
-
refuse!(404, "Group #{params[:group_id]} was not found", echo.merge("error" => "group_not_found"))
|
|
182
|
-
if type == "add_recipient"
|
|
183
|
-
db.ensure_person(number)
|
|
184
|
-
conversation["participants"] |= [number]
|
|
185
|
-
elsif conversation["participants"].size <= 2
|
|
186
|
-
refuse!(400, "Cannot remove from a group with 3 total members; Apple requires at least 3.",
|
|
187
|
-
echo.merge("error" => "group_too_small"))
|
|
188
|
-
else
|
|
189
|
-
conversation["participants"] -= [number]
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
render json: { "status" => "OK" }.merge(echo)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# --- media ------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
def upload_file
|
|
198
|
-
file = params[:file]
|
|
199
|
-
refuse!(400, "You must attach a `file`.") unless file.respond_to?(:original_filename)
|
|
200
|
-
|
|
201
|
-
name = store.save_media(file, file.original_filename)
|
|
202
|
-
render status: :created, json: { status: "OK", message: "File uploaded successfully",
|
|
203
|
-
media_url: media_url_for(name), mediaObjectId: name }
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def upload_media_object
|
|
207
|
-
refuse!(400, "You must specify a `media_url` in the request body.") if params[:media_url].blank?
|
|
208
|
-
|
|
209
|
-
render status: :created, json: { status: "OK", message: "File uploaded successfully",
|
|
210
|
-
mediaObjectId: File.basename(URI.parse(params[:media_url].to_s).path) }
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
# --- reading back -----------------------------------------------------
|
|
214
|
-
|
|
215
|
-
def messages
|
|
216
|
-
db = store.read
|
|
217
|
-
found = db.messages.values.reverse.filter_map do |message|
|
|
218
|
-
conversation = db.conversations[message["conversation_id"]]
|
|
219
|
-
conversation && Payloads.message(message, conversation)
|
|
220
|
-
end
|
|
221
|
-
found.select! { |m| m["is_outbound"].to_s == params[:is_outbound] } if params[:is_outbound].present?
|
|
222
|
-
%w[status service group_id from_number to_number number].each do |field|
|
|
223
|
-
found.select! { |m| m[field].to_s == params[field].to_s } if params[field].present?
|
|
224
|
-
end
|
|
225
|
-
limit = params.fetch(:limit, 100).to_i.clamp(1, 100)
|
|
226
|
-
offset = [params[:offset].to_i, 0].max
|
|
227
|
-
render json: { status: "OK", data: found[offset, limit] || [],
|
|
228
|
-
pagination: { limit: limit, offset: offset, total: found.size } }
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def message
|
|
232
|
-
db = store.read
|
|
233
|
-
found = db.message(params[:handle]) or refuse!(404, "Message not found")
|
|
234
|
-
render json: { status: "OK", data: Payloads.message(found, db.conversations[found["conversation_id"]]) }
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def delete_message
|
|
238
|
-
deleted = store.write { |db| db.messages.delete(params[:handle]) }
|
|
239
|
-
refuse!(404, "Message not found") unless deleted
|
|
240
|
-
|
|
241
|
-
render json: { status: "OK", message: "Message deleted" }
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def missing
|
|
245
|
-
refuse!(404, "pocket_phone does not fake #{request.method} #{request.path.delete_prefix(request.script_name)} yet")
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
private
|
|
249
|
-
|
|
250
|
-
def store = PocketPhone.store
|
|
251
|
-
|
|
252
|
-
def remember_base_url
|
|
253
|
-
PocketPhone.base_url = request.base_url
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
# Sendblue wants both keys on every call. With none configured here any
|
|
257
|
-
# pair will do, so long as the client sent one.
|
|
258
|
-
def require_credentials
|
|
259
|
-
key = request.headers["sb-api-key-id"].to_s
|
|
260
|
-
secret = request.headers["sb-api-secret-key"].to_s
|
|
261
|
-
expected_key = PocketPhone.config.api_key_id
|
|
262
|
-
expected_secret = PocketPhone.config.api_secret
|
|
263
|
-
valid = key.present? && secret.present? &&
|
|
264
|
-
(expected_key.nil? || expected_key.to_s == key) &&
|
|
265
|
-
(expected_secret.nil? || expected_secret.to_s == secret)
|
|
266
|
-
refuse!(401, "Missing or invalid `sb-api-key-id` / `sb-api-secret-key` headers.") unless valid
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def refuse!(status, message, extra = {})
|
|
270
|
-
raise Refusal.new(status, { "status" => "ERROR", "message" => message, "error_message" => message }.merge(extra))
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def recipient!(value)
|
|
274
|
-
refuse!(400, "You must specify a `number` in the request body.") if value.blank?
|
|
275
|
-
refuse!(400, "`#{value}` is not a valid E.164 phone number (for example +19998887777).") unless
|
|
276
|
-
Numbers.valid?(value)
|
|
277
|
-
|
|
278
|
-
value.to_s
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
def line!
|
|
282
|
-
value = params[:from_number]
|
|
283
|
-
refuse!(400, "You must specify a `from_number` in the request body.") if value.blank?
|
|
284
|
-
refuse!(400, "`from_number` must be an E.164 phone number on your account.") unless
|
|
285
|
-
value.to_s.match?(Numbers::E164)
|
|
286
|
-
|
|
287
|
-
value.to_s
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def message_body!
|
|
291
|
-
card = params[:app_card].presence && params[:app_card].to_unsafe_h
|
|
292
|
-
if params[:content].blank? && params[:media_url].blank? && card.nil?
|
|
293
|
-
refuse!(400, "You must specify `content`, a `media_url` or an `app_card`.")
|
|
294
|
-
end
|
|
295
|
-
refuse!(400, "An `app_card` cannot be combined with `media_url`.") if card && params[:media_url].present?
|
|
296
|
-
|
|
297
|
-
{ "content" => params[:content].to_s, "media_url" => params[:media_url].presence,
|
|
298
|
-
"send_style" => params[:send_style].presence, "app_card" => card }
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def outbound(line, attrs)
|
|
302
|
-
attrs = attrs.merge("direction" => "app", "from_number" => line, "status" => "QUEUED",
|
|
303
|
-
"status_callback" => params[:status_callback].presence,
|
|
304
|
-
"request" => request.request_parameters.except("format"))
|
|
305
|
-
return attrs unless attrs["outcome"] == "rejected"
|
|
306
|
-
|
|
307
|
-
# Sendblue answers 200 with status ERROR when it refuses a send, so
|
|
308
|
-
# the HTTP status alone does not say whether a message went out.
|
|
309
|
-
attrs.merge("status" => "ERROR", "error_code" => BLOCKED,
|
|
310
|
-
"error_message" => "This number cannot be messaged (simulated by pocket_phone)")
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def carry(message)
|
|
314
|
-
return if message["status"] == "ERROR"
|
|
315
|
-
|
|
316
|
-
Carrier.deliver_to_phone(message["handle"])
|
|
317
|
-
Carrier.fetch_preview(message["handle"])
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def single_emoji?(text)
|
|
321
|
-
text.match?(/\A\X\z/) && text.match?(/\p{Emoji}/) && !text.match?(/\A[0-9#*]\z/)
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def media_url_for(name)
|
|
325
|
-
"#{request.base_url}#{request.script_name}/media/#{name}"
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
end
|