pocket_phone 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE +28 -0
  4. data/README.md +184 -0
  5. data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
  6. data/app/controllers/pocket_phone/application_controller.rb +33 -0
  7. data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
  8. data/app/controllers/pocket_phone/media_controller.rb +14 -0
  9. data/app/controllers/pocket_phone/messages_controller.rb +65 -0
  10. data/app/controllers/pocket_phone/people_controller.rb +19 -0
  11. data/app/helpers/pocket_phone/application_helper.rb +145 -0
  12. data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
  13. data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
  14. data/app/views/layouts/pocket_phone/application.html.erb +15 -0
  15. data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
  16. data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
  17. data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
  18. data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
  19. data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
  20. data/app/views/pocket_phone/conversations/show.html.erb +67 -0
  21. data/config/routes.rb +41 -0
  22. data/lib/pocket_phone/carrier.rb +119 -0
  23. data/lib/pocket_phone/configuration.rb +60 -0
  24. data/lib/pocket_phone/database.rb +157 -0
  25. data/lib/pocket_phone/engine.rb +9 -0
  26. data/lib/pocket_phone/link_preview.rb +128 -0
  27. data/lib/pocket_phone/numbers.rb +40 -0
  28. data/lib/pocket_phone/payloads.rb +50 -0
  29. data/lib/pocket_phone/runner.rb +37 -0
  30. data/lib/pocket_phone/store.rb +106 -0
  31. data/lib/pocket_phone/version.rb +5 -0
  32. data/lib/pocket_phone/webhook.rb +68 -0
  33. data/lib/pocket_phone.rb +47 -0
  34. metadata +135 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 32813de625e81367b1f5270e095b09a810afa30eb04d4f96ff326f1bd8d9c96e
4
+ data.tar.gz: 2afbaa72756cd6eedd206720b127f5e9ae0add415e29710271294f7af48208f4
5
+ SHA512:
6
+ metadata.gz: 7a819bd93cfc91c3dd90d1b4c3ba97220b16fccf1ca3cd7b022548497ce47b6a26df53ff5d776ebf10782231ce929a9e3021349e697f06709387d1902e960de2
7
+ data.tar.gz: e6304e63140854f42c3ffea1a0426e014ebb1cf602a8dd54eb8927494fb1d6005603db294ec523fcceb49251c3270b6c9109c8675de0a2a5d8b5e0460302c387
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First version.
6
+
7
+ - A fake Sendblue API: messages, group messages, carousels, typing indicators,
8
+ tapbacks, read receipts, service lookup, media upload, contact sharing and
9
+ message history.
10
+ - An iMessage-style inbox for the phones on the other end, with blue and green
11
+ bubbles, attachments, group chats and link previews.
12
+ - Signed `receive` webhooks for what the phones send, plus status callbacks,
13
+ outbound webhooks and typing webhooks.
14
+ - Per-person simulation of Android phones, failed sends and refused sends.
data/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, hmk
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # pocket_phone
2
+
3
+ [![CI](https://github.com/hmk/pocket_phone/actions/workflows/ci.yml/badge.svg)](https://github.com/hmk/pocket_phone/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/pocket_phone.svg)](https://rubygems.org/gems/pocket_phone)
5
+
6
+ Like [letter_opener](https://github.com/ryanb/letter_opener), but for iMessage
7
+ and SMS, and in both directions.
8
+
9
+ pocket_phone is a fake [Sendblue](https://sendblue.com) for development. Your
10
+ app sends to it instead of to `api.sendblue.com`; what it sent shows up in an
11
+ iMessage-style inbox in your browser, and what you type there reaches your app
12
+ as a signed Sendblue webhook. No real phone, no real texts, no tunnel.
13
+
14
+ - Blue bubbles for iMessage, green for SMS and RCS
15
+ - Text back as any number of fake people, one-to-one or in a group
16
+ - Typing dots, tapbacks, read receipts, attachments
17
+ - Link previews drawn by iMessage's own rules, with a note when a link would
18
+ not get one
19
+ - Make a person an Android phone, or make sends to them fail, with one click
20
+ - Every message shows the request your app made and the webhooks it got back
21
+
22
+ ## Install
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ group :development do
27
+ gem "pocket_phone"
28
+ end
29
+ ```
30
+
31
+ ```ruby
32
+ # config/routes.rb
33
+ mount PocketPhone::Engine, at: "/pocket_phone" if Rails.env.development?
34
+ ```
35
+
36
+ Then make two connections.
37
+
38
+ **1. Point your Sendblue client at the engine.** Wherever the base URL lives,
39
+ make it `http://localhost:3000/pocket_phone` in development in place of
40
+ `https://api.sendblue.com`. The paths after it stay the same
41
+ (`/api/send-message`, and so on), and so do the `sb-api-key-id` and
42
+ `sb-api-secret-key` headers, which must be present but can hold anything.
43
+
44
+ **2. Tell pocket_phone where your webhook is.**
45
+
46
+ ```ruby
47
+ # config/initializers/pocket_phone.rb
48
+ if defined?(PocketPhone::Engine)
49
+ PocketPhone.configure do |config|
50
+ config.webhook_url = "/webhooks/sendblue" # where texts from the phone go
51
+ config.signing_secret = ENV["SENDBLUE_WEBHOOK_SECRET"]
52
+ config.from_number = ENV["SENDBLUE_FROM_NUMBER"] # your line, for phones that text first
53
+ end
54
+ end
55
+ ```
56
+
57
+ Open <http://localhost:3000/pocket_phone>.
58
+
59
+ ## Try it without an app
60
+
61
+ ```
62
+ bundle install
63
+ bundle exec rake demo
64
+ ```
65
+
66
+ That runs pocket_phone on <http://localhost:4567/pocket_phone> with a small bot
67
+ behind it. Text it "link", "photo" or "thanks". The bot is 40 lines in
68
+ [`demo/config.ru`](demo/config.ru) and talks to Sendblue the ordinary way.
69
+
70
+ ## Configuration
71
+
72
+ Any setting marked *lazy* also takes a lambda, read each time it is needed, for
73
+ values that live in your database or are not loaded when the initializer runs.
74
+
75
+ | Setting | Default | |
76
+ |---|---|---|
77
+ | `webhook_url` *lazy* | none | Where `receive` webhooks go. A full URL, or a path on the server pocket_phone is mounted in. |
78
+ | `outbound_webhook_url` *lazy* | none | Where status updates for every message your app sends go. A `status_callback` on a send is always honoured as well. |
79
+ | `typing_webhook_url` *lazy* | none | Where `typing_indicator` webhooks go when you type on the phone. |
80
+ | `signing_secret` *lazy* | none | Signs webhooks, as described below. |
81
+ | `secret_header` *lazy* | `sb-signing-secret` | The header the secret itself is sent in. |
82
+ | `from_number` *lazy* | first line seen | Your Sendblue line. |
83
+ | `api_key_id`, `api_secret` *lazy* | none | If set, API calls must carry exactly these. |
84
+ | `account_email` *lazy* | `pocket_phone@example.com` | The `accountEmail` in payloads. |
85
+ | `storage_path` *lazy* | `tmp/pocket_phone` | Where conversations and attachments are kept. |
86
+ | `people` | `[]` | People to create on first use: `[{ name: "Alice", number: "+15555550101", service: "iMessage" }]` |
87
+ | `delivery_delay` | `0.4` | Seconds between `QUEUED`, `SENT` and `DELIVERED`. |
88
+ | `link_previews` | `true` | Fetch pages to draw link cards. |
89
+ | `inbound_reactions` | `:text` | How a tapback made on the phone reaches your app. See below. |
90
+ | `webhook_timeout` | `45` | Seconds to wait for your app to answer a webhook. |
91
+ | `async` | `true` | Deliver on background threads. Set `false` in tests for inline delivery with no delays. |
92
+
93
+ ## What is faked
94
+
95
+ | Endpoint | Notes |
96
+ |---|---|
97
+ | `POST /api/send-message` | `content`, `media_url`, `send_style`, `status_callback`, `app_card` |
98
+ | `POST /api/send-group-message` | By `group_id`, or `numbers` to create the group |
99
+ | `POST /api/send-carousel` | 2 to 20 images, iMessage only |
100
+ | `POST /api/send-typing-indicator` | `state` and `max_duration_ms` are honoured |
101
+ | `POST /api/send-reaction` | Six tapbacks or one emoji; a leading `-` removes it |
102
+ | `POST /api/mark-read` | Shows "Read" under the phone's last message |
103
+ | `POST /api/modify-group` | `add_recipient`, `remove_recipient` |
104
+ | `GET /api/evaluate-service` | Answers with the person's phone type |
105
+ | `POST /api/upload-file`, `POST /api/upload-media-object` | Files are kept locally and served back |
106
+ | `POST /api/v2/contact-sharing/profile` | The line's shared name and photo |
107
+ | `GET /api/v2/messages`, `GET`/`DELETE /api/v2/messages/:handle` | |
108
+
109
+ Requests are checked the way Sendblue checks them: missing keys are a 401, a
110
+ number that is not E.164 is a 400, a tapback on an SMS is a 422. Anything else
111
+ under `/api` answers 404 with a message naming the endpoint, so a call that is
112
+ not faked is obvious rather than silently accepted.
113
+
114
+ App cards are drawn as the static card a recipient sees without the extension
115
+ installed. The extension itself runs on an iPhone and cannot be faked.
116
+
117
+ ## Webhooks
118
+
119
+ When `signing_secret` is set, every webhook carries both things Sendblue sends:
120
+
121
+ - `x-sendblue-signature: t=<unix seconds>,v1=<hex>`, an HMAC-SHA256 of
122
+ `"<t>.<raw body>"` keyed by the secret
123
+ - the secret itself in `sb-signing-secret` (or the header you named)
124
+
125
+ Under a message the phone sent you will see **Delivered** when your app
126
+ answered 2xx, **Not Delivered** with the reason when it did not, and **Read**
127
+ once your app called `mark-read`. Open a message's details (the ⓘ) to see
128
+ every webhook and its response, and to send the `receive` webhook again, which
129
+ is how you check that a redelivery is not answered twice.
130
+
131
+ ## Simulating people
132
+
133
+ Each person has two settings in the thread's header:
134
+
135
+ - **Phone**: iPhone (iMessage), Android (SMS) or Android (RCS). Sends to an
136
+ Android phone come back with that `service` and `was_downgraded: true`, and
137
+ iMessage-only calls are refused.
138
+ - **Sends to it**: delivered; fail after sending (the send is accepted, then an
139
+ `ERROR` status arrives by callback); or refused (the send answers 200 with
140
+ `"status": "ERROR"`, which is how Sendblue reports a blocked number).
141
+
142
+ A group is blue only while everyone in it has an iPhone.
143
+
144
+ ## Link previews
145
+
146
+ iMessage expands a link into a card only when the message contains exactly one
147
+ URL, at the very start or end, not touching punctuation. pocket_phone applies
148
+ the same rule and says so under a message whose link would stay plain text. The
149
+ page is fetched with the user agent Messages uses, so a site that serves `og:`
150
+ tags only to crawlers answers as it would for real.
151
+
152
+ A preview of a page on `localhost` (or any private address) is drawn with a
153
+ warning: a real phone could not have fetched it.
154
+
155
+ ## Where it differs from Sendblue
156
+
157
+ - Payload shapes follow Sendblue's documentation and the fields its webhooks
158
+ are known to carry. Sendblue changes these without notice; if your app
159
+ depends on a field, check it against a real payload.
160
+ - Sendblue documents no webhook for a tapback made by a recipient. With
161
+ `inbound_reactions = :text`, pocket_phone sends it as a received message
162
+ with the text an iPhone falls back to (`Loved “hello”`). Set `:none` to keep
163
+ phone-side tapbacks in the UI only.
164
+ - Webhooks are not retried automatically. Use "Send the webhook again".
165
+ - Rate limits, opt-outs, contacts, line provisioning and calls are not faked.
166
+
167
+ ## Notes
168
+
169
+ - Mount it in development only. It has no authentication of its own.
170
+ - State lives in `tmp/pocket_phone`. "Clear conversations" keeps your people;
171
+ delete the directory to start over completely.
172
+ - A request in your app that sends a message is calling its own server. Run
173
+ more than one thread or worker in development (Puma does by default).
174
+
175
+ ## Development
176
+
177
+ ```
178
+ bundle install
179
+ bundle exec rake test
180
+ ```
181
+
182
+ ## License
183
+
184
+ BSD 3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,329 @@
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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+ layout "pocket_phone/application"
7
+
8
+ # The page carries its own inline styles and script, like letter_opener_web;
9
+ # a host app's policy written for its own pages would block both.
10
+ content_security_policy(false) if respond_to?(:content_security_policy)
11
+
12
+ before_action :remember_base_url
13
+
14
+ private
15
+
16
+ def store = PocketPhone.store
17
+
18
+ def remember_base_url
19
+ PocketPhone.base_url = request.base_url
20
+ end
21
+
22
+ # The line a phone texts when it starts the conversation: the one the app
23
+ # is configured with, else one the app has already sent from.
24
+ def default_line(database)
25
+ Numbers.normalize(PocketPhone.config.from_number).presence ||
26
+ database.lines.keys.first || PocketPhone::FALLBACK_LINE
27
+ end
28
+
29
+ def media_url_for(name)
30
+ "#{request.base_url}#{request.script_name}/media/#{name}"
31
+ end
32
+ end
33
+ end