pocket_phone 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +28 -0
- data/README.md +184 -0
- data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
- data/app/controllers/pocket_phone/application_controller.rb +33 -0
- data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
- data/app/controllers/pocket_phone/media_controller.rb +14 -0
- data/app/controllers/pocket_phone/messages_controller.rb +65 -0
- data/app/controllers/pocket_phone/people_controller.rb +19 -0
- data/app/helpers/pocket_phone/application_helper.rb +145 -0
- data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
- data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
- data/app/views/layouts/pocket_phone/application.html.erb +15 -0
- data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
- data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
- data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
- data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
- data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
- data/app/views/pocket_phone/conversations/show.html.erb +67 -0
- data/config/routes.rb +41 -0
- data/lib/pocket_phone/carrier.rb +119 -0
- data/lib/pocket_phone/configuration.rb +60 -0
- data/lib/pocket_phone/database.rb +157 -0
- data/lib/pocket_phone/engine.rb +9 -0
- data/lib/pocket_phone/link_preview.rb +128 -0
- data/lib/pocket_phone/numbers.rb +40 -0
- data/lib/pocket_phone/payloads.rb +50 -0
- data/lib/pocket_phone/runner.rb +37 -0
- data/lib/pocket_phone/store.rb +106 -0
- data/lib/pocket_phone/version.rb +5 -0
- data/lib/pocket_phone/webhook.rb +68 -0
- data/lib/pocket_phone.rb +47 -0
- metadata +135 -0
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
|
+
[](https://github.com/hmk/pocket_phone/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|