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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32813de625e81367b1f5270e095b09a810afa30eb04d4f96ff326f1bd8d9c96e
4
- data.tar.gz: 2afbaa72756cd6eedd206720b127f5e9ae0add415e29710271294f7af48208f4
3
+ metadata.gz: 74aeeec3868590557dc67265922f7b462df2d96da12a44bcb7672e62938441c0
4
+ data.tar.gz: 16acb2e9badcd39fcdd095cd5a64820b936c4a583679285bbc32a4e8c8366386
5
5
  SHA512:
6
- metadata.gz: 7a819bd93cfc91c3dd90d1b4c3ba97220b16fccf1ca3cd7b022548497ce47b6a26df53ff5d776ebf10782231ce929a9e3021349e697f06709387d1902e960de2
7
- data.tar.gz: e6304e63140854f42c3ffea1a0426e014ebb1cf602a8dd54eb8927494fb1d6005603db294ec523fcceb49251c3270b6c9109c8675de0a2a5d8b5e0460302c387
6
+ metadata.gz: d179161edaed347e9cb88f328e0c8664c06a13d0fb23a1c983884624f17377f10ebfd6ae2d63f3eefd53d019960a761945512f30e5e6e78a804a2a0748708359
7
+ data.tar.gz: 56aeff7d09598b352b85c068f065c2fe4a1b5cb4e48802ed510052caa09684276312726fb1a604d0e16f21d74e4b8b633a1d83b1c24b7e2c6bce04ec164bddf0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
4
+
5
+ - Fix every API call failing with `uninitialized constant` in an app that
6
+ declares `inflect.acronym "API"`. The API controller is now
7
+ `PocketPhone::SendblueController`; no URL changes.
8
+
3
9
  ## 0.1.0
4
10
 
5
11
  First version.
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
+ ![A conversation in pocket_phone: the app's messages in grey, replies typed in the browser in blue, a link preview, a tapback and a read receipt](https://raw.githubusercontent.com/hmk/pocket_phone/main/docs/screenshot.png)
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 module: :api, defaults: { format: :json } do
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"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PocketPhone
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
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