flow_chat 0.8.2 → 0.10.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 +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
|
@@ -0,0 +1,4064 @@
|
|
|
1
|
+
# Messenger and Instagram DM Support Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add Facebook Messenger and Instagram DM gateways to FlowChat at parity with the WhatsApp gateway, on one shared Meta webhook implementation, and fix two WhatsApp bugs found while designing the choice ladder.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `FlowChat::Meta::` holds what all three Meta platforms share: `X-Hub-Signature-256` validation, `hub.challenge` verification, and `MessagingGateway`, which implements the `entry[].messaging[]` envelope once. `Messenger::Gateway::SendApi` and `Instagram::Gateway::SendApi` each subclass `MessagingGateway` and override four hooks (platform symbol, configuration class, renderer class, account-id check). Choices render down a ladder (quick replies, carousel, numbered text) whose rung is decided by one shared helper so the renderer and the choice mapper cannot disagree.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Zeitwerk autoloading, Minitest, WebMock, Kramdown, ActiveSupport. Meta Graph API v23.0.
|
|
10
|
+
|
|
11
|
+
**User Verification:** YES. Two facts cannot be settled from this machine and need the user: (1) whether Instagram-via-Facebook-Login delivers webhooks under `object: "page"` or `object: "instagram"`, readable only from their Meta app dashboard, and (2) whether the Instagram carousel is legible for plain option menus on a real device. Task 20 is a dedicated verification checkpoint for both.
|
|
12
|
+
|
|
13
|
+
**Spec:** `docs/superpowers/specs/2026-08-10-messenger-instagram-design.md`
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Conventions for every task
|
|
18
|
+
|
|
19
|
+
- Run one test file with `bundle exec ruby -Itest test/unit/path_test.rb`, one test with `bundle exec ruby -Itest test/unit/path_test.rb -n test_name`, everything with `bundle exec rake test`. The `bundle exec` prefix is required: plain `ruby -Itest` bypasses Bundler and dies on `cannot load such file -- minitest/mock`. Later tasks in this plan write the bare form in places; prefix it anyway.
|
|
20
|
+
- Never stage or commit anything beyond the files a task names.
|
|
21
|
+
- Logging uses block syntax: `FlowChat.logger.debug { "..." }`.
|
|
22
|
+
- No `respond_to?` guards. If a collaborator is required, call it.
|
|
23
|
+
- **Inside a standalone `module`, always fully qualify `FlowChat::Instrumentation::Events::X`.** Bare `Events::X` raises `NameError` there: Ruby resolves an unqualified constant through `Module.nesting` and then the ancestors of the cref, not the ancestors of whichever class later includes the module. The bare form works only in a class body that itself includes `FlowChat::Instrumentation`, which is why the old `CloudApi` code got away with it. Task 2 hit this. Code blocks later in this plan sometimes show the bare form inside modules; qualify it anyway. Classes that `include FlowChat::Instrumentation` directly (such as `Meta::MessagingGateway`) are fine either way.
|
|
24
|
+
- Docs prose: dense and plain, no marketing adjectives, **no em-dashes**.
|
|
25
|
+
- **The suite's flakiness was real and is now fixed. Do not repeat the claim that it is inherently flaky.** Two causes, both since resolved: `metrics_collector_test.rb` slept for the durations it asserted (now stated via a constructed `Event`), and `security_test.rb` could leave `ActiveSupport::SecurityUtils` set to `nil` for the rest of the process, which broke every later `secure_compare` and produced cascades of thirty or more unrelated failures. Benchmarks also moved out of `rake test` into `rake benchmark`.
|
|
26
|
+
- **The suite is order-dependent by nature, so check a failure against a seed before blaming your change.** Minitest randomizes the order. Seeds 1 to 140 pass as of `bd42d12`. If you see a failure, re-run with `bundle exec rake test TESTOPTS="--seed=N"` for the seed printed in the output: a failure that reproduces on a fixed seed is real, and one that moves around is order pollution worth reporting rather than working around. Do not describe the suite as "deterministic" without saying which seeds you actually ran.
|
|
27
|
+
|
|
28
|
+
## File Structure
|
|
29
|
+
|
|
30
|
+
### Created
|
|
31
|
+
|
|
32
|
+
| File | Responsibility |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `lib/flow_chat/meta/signature_validation.rb` | `X-Hub-Signature-256` HMAC check, shared by 3 platforms |
|
|
35
|
+
| `lib/flow_chat/meta/webhook_verification.rb` | `hub.mode`/`hub.verify_token`/`hub.challenge` exchange |
|
|
36
|
+
| `lib/flow_chat/meta/choice_ladder.rb` | Decides which rung renders a given choice count |
|
|
37
|
+
| `lib/flow_chat/meta/messaging_gateway.rb` | The `entry[].messaging[]` envelope, dispatch, echoes, statuses, context |
|
|
38
|
+
| `lib/flow_chat/named_configuration.rb` | Named-configuration registry with per-class storage |
|
|
39
|
+
| `lib/flow_chat/id_generator.rb` | Moved from `whatsapp/id_generator.rb`, max length now configurable |
|
|
40
|
+
| `lib/flow_chat/messenger/configuration.rb` | Messenger credentials |
|
|
41
|
+
| `lib/flow_chat/messenger/client.rb` | Send API calls, text splitting, attachment upload |
|
|
42
|
+
| `lib/flow_chat/messenger/renderer.rb` | Messenger choice ladder and plain-text conversion |
|
|
43
|
+
| `lib/flow_chat/messenger/gateway/send_api.rb` | Messenger subclass of `MessagingGateway` |
|
|
44
|
+
| `lib/flow_chat/messenger/middleware/choice_mapper.rb` | Payload and position mapping back to choice keys |
|
|
45
|
+
| `lib/flow_chat/instagram/configuration.rb` | Instagram credentials |
|
|
46
|
+
| `lib/flow_chat/instagram/client.rb` | Instagram Send API calls, byte-based splitting |
|
|
47
|
+
| `lib/flow_chat/instagram/renderer.rb` | Instagram ladder, always numbers the body |
|
|
48
|
+
| `lib/flow_chat/instagram/gateway/send_api.rb` | Instagram subclass of `MessagingGateway` |
|
|
49
|
+
| `lib/flow_chat/instagram/middleware/choice_mapper.rb` | Instagram choice mapping |
|
|
50
|
+
| `docs/platforms/messenger.md`, `docs/platforms/instagram.md` | Platform guides |
|
|
51
|
+
|
|
52
|
+
### Modified
|
|
53
|
+
|
|
54
|
+
| File | Change |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `lib/flow_chat/whatsapp/gateway/cloud_api.rb` | Uses shared `Meta::` modules; derives `echo_origin` |
|
|
57
|
+
| `lib/flow_chat/whatsapp/configuration.rb` | Uses `NamedConfiguration` |
|
|
58
|
+
| `lib/flow_chat/telegram/configuration.rb` | Uses `NamedConfiguration` |
|
|
59
|
+
| `lib/flow_chat/intercom/configuration.rb` | Uses `NamedConfiguration` |
|
|
60
|
+
| `lib/flow_chat/whatsapp/renderer.rb` | List capped at 10 rows; numbered fallback above |
|
|
61
|
+
| `lib/flow_chat/whatsapp/middleware/choice_mapper.rb` | Uses `FlowChat::IdGenerator`; stores position map |
|
|
62
|
+
| `lib/flow_chat/renderers/markdown_support.rb` | Adds `to_plain_text` |
|
|
63
|
+
| `lib/flow_chat/session/middleware.rb` | `:messenger` and `:instagram` default to `:user_id` |
|
|
64
|
+
| `lib/flow_chat/config.rb` | Adds `Config.messenger` and `Config.instagram` |
|
|
65
|
+
| `README.md`, `docs/gateway-context-variables.md` | Both platforms documented |
|
|
66
|
+
|
|
67
|
+
### Deleted
|
|
68
|
+
|
|
69
|
+
| File | Reason |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `lib/flow_chat/whatsapp/id_generator.rb` | Moved to `lib/flow_chat/id_generator.rb` |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# Phase 1: Shared extractions and WhatsApp fixes
|
|
76
|
+
|
|
77
|
+
Phase 1 touches only existing code and is covered by existing suites. It lands before either new platform so that phases 2 and 3 build on proven shared code.
|
|
78
|
+
|
|
79
|
+
## Task 1: Extract Meta signature validation
|
|
80
|
+
|
|
81
|
+
**Goal:** One implementation of `X-Hub-Signature-256` validation, used by the WhatsApp gateway, with each platform keeping its own error class.
|
|
82
|
+
|
|
83
|
+
**Files:**
|
|
84
|
+
- Create: `lib/flow_chat/meta/signature_validation.rb`
|
|
85
|
+
- Modify: `lib/flow_chat/whatsapp/gateway/cloud_api.rb` (remove `valid_webhook_signature?`, lines 325-377)
|
|
86
|
+
- Test: `test/unit/whatsapp/gateway/cloud_api_test.rb` (existing, must stay green)
|
|
87
|
+
|
|
88
|
+
**Acceptance Criteria:**
|
|
89
|
+
- [ ] `FlowChat::Meta::SignatureValidation` provides `valid_webhook_signature?(request)`
|
|
90
|
+
- [ ] `skip_signature_validation` short-circuits to `true`
|
|
91
|
+
- [ ] A blank `app_secret` raises the *including gateway's* error class, not a shared one
|
|
92
|
+
- [ ] A missing `X-Hub-Signature-256` header returns `false`
|
|
93
|
+
- [ ] WhatsApp's three `assert_raises(FlowChat::Whatsapp::ConfigurationError)` tests still pass
|
|
94
|
+
|
|
95
|
+
**Verify:** `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb` → all pass, 0 failures
|
|
96
|
+
|
|
97
|
+
**Steps:**
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 1: Confirm the existing tests pass before touching anything**
|
|
100
|
+
|
|
101
|
+
Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb`
|
|
102
|
+
Expected: PASS. Record the assertion count so you can compare after.
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 2: Create the shared module**
|
|
105
|
+
|
|
106
|
+
Create `lib/flow_chat/meta/signature_validation.rb`:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
require "openssl"
|
|
110
|
+
|
|
111
|
+
module FlowChat
|
|
112
|
+
module Meta
|
|
113
|
+
# Raised when a Meta gateway cannot validate a signature because it was not
|
|
114
|
+
# configured to. Platforms override configuration_error_class to raise their own.
|
|
115
|
+
class ConfigurationError < StandardError; end
|
|
116
|
+
|
|
117
|
+
# X-Hub-Signature-256 validation, shared by every Meta webhook gateway.
|
|
118
|
+
#
|
|
119
|
+
# The including gateway must have @config responding to #app_secret and
|
|
120
|
+
# #skip_signature_validation. It may override platform_label, log_tag and
|
|
121
|
+
# configuration_error_class.
|
|
122
|
+
module SignatureValidation
|
|
123
|
+
def valid_webhook_signature?(request)
|
|
124
|
+
if @config.skip_signature_validation
|
|
125
|
+
FlowChat.logger.debug { "#{log_tag}: Webhook signature validation is disabled" }
|
|
126
|
+
return true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if @config.app_secret.blank?
|
|
130
|
+
error_msg = "#{platform_label} app_secret is required for webhook signature validation. " \
|
|
131
|
+
"Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
|
|
132
|
+
FlowChat.logger.error { "#{log_tag}: #{error_msg}" }
|
|
133
|
+
raise configuration_error_class, error_msg
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
signature_header = request.headers["X-Hub-Signature-256"]
|
|
137
|
+
unless signature_header
|
|
138
|
+
FlowChat.logger.warn { "#{log_tag}: No X-Hub-Signature-256 header found in request" }
|
|
139
|
+
return false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
expected_signature = signature_header.sub("sha256=", "")
|
|
143
|
+
|
|
144
|
+
request.body.rewind
|
|
145
|
+
body = request.body.read
|
|
146
|
+
request.body.rewind
|
|
147
|
+
|
|
148
|
+
calculated_signature = OpenSSL::HMAC.hexdigest(
|
|
149
|
+
OpenSSL::Digest.new("sha256"),
|
|
150
|
+
@config.app_secret,
|
|
151
|
+
body
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
signature_valid = FlowChat::Security.secure_compare(expected_signature, calculated_signature)
|
|
155
|
+
|
|
156
|
+
if signature_valid
|
|
157
|
+
FlowChat.logger.debug { "#{log_tag}: Webhook signature validation successful" }
|
|
158
|
+
else
|
|
159
|
+
FlowChat.logger.warn { "#{log_tag}: Webhook signature validation failed - signatures do not match" }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
signature_valid
|
|
163
|
+
rescue => e
|
|
164
|
+
# A misconfiguration is the developer's problem and must not be swallowed
|
|
165
|
+
# into a plain "invalid signature".
|
|
166
|
+
raise if e.is_a?(configuration_error_class)
|
|
167
|
+
|
|
168
|
+
FlowChat.logger.error { "#{log_tag}: Error validating webhook signature: #{e.class.name}: #{e.message}" }
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def configuration_error_class
|
|
175
|
+
FlowChat::Meta::ConfigurationError
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def platform_label
|
|
179
|
+
"Meta"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def log_tag
|
|
183
|
+
self.class.name.split("::").last
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 3: Point the WhatsApp gateway at it**
|
|
191
|
+
|
|
192
|
+
In `lib/flow_chat/whatsapp/gateway/cloud_api.rb`, add the include next to the existing ones:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
include FlowChat::Instrumentation
|
|
196
|
+
include FlowChat::GatewayAsyncSupport
|
|
197
|
+
include FlowChat::Meta::SignatureValidation
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Delete the entire private `valid_webhook_signature?` method (lines 325-377) and add the three hook overrides in the private section:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
def configuration_error_class
|
|
204
|
+
FlowChat::Whatsapp::ConfigurationError
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def platform_label
|
|
208
|
+
"WhatsApp"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def log_tag
|
|
212
|
+
"CloudApi"
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- [ ] **Step 4: Run the WhatsApp gateway tests**
|
|
217
|
+
|
|
218
|
+
Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb`
|
|
219
|
+
Expected: PASS, same assertion count as Step 1. The three `ConfigurationError` tests confirm the hook works.
|
|
220
|
+
|
|
221
|
+
- [ ] **Step 5: Run the full suite**
|
|
222
|
+
|
|
223
|
+
Run: `bundle exec rake test`
|
|
224
|
+
Expected: PASS, 0 failures, 0 errors.
|
|
225
|
+
|
|
226
|
+
- [ ] **Step 6: Commit**
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
git add lib/flow_chat/meta/signature_validation.rb lib/flow_chat/whatsapp/gateway/cloud_api.rb
|
|
230
|
+
git commit -m "refactor(meta): share the webhook signature check between platforms"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Task 2: Extract Meta webhook verification
|
|
236
|
+
|
|
237
|
+
**Goal:** One implementation of the `hub.challenge` exchange, including the guard that a gateway with no verify token verifies nothing. Plus the identity seam both `Meta::` behavior modules depend on.
|
|
238
|
+
|
|
239
|
+
**Why the identity module:** the obvious version of `WebhookVerification` calls `log_tag` and `platform` but defines neither, so it would only work because `SignatureValidation` happens to be included in the same class. That is a hidden coupling between sibling modules that breaks the moment a gateway includes one without the other. After Task 1, `platform_label` and `configuration_error_class` already raise `NotImplementedError` when a gateway forgets them. Collect all four identity hooks in one module that both behavior modules name as a prerequisite, so the contract is stated once for the three platforms about to arrive.
|
|
240
|
+
|
|
241
|
+
**Files:**
|
|
242
|
+
- Create: `lib/flow_chat/meta/gateway_identity.rb`
|
|
243
|
+
- Create: `lib/flow_chat/meta/webhook_verification.rb`
|
|
244
|
+
- Create: `test/unit/meta/webhook_verification_test.rb`
|
|
245
|
+
- Modify: `lib/flow_chat/meta/signature_validation.rb` (take its identity hooks from the new module)
|
|
246
|
+
- Modify: `lib/flow_chat/whatsapp/gateway/cloud_api.rb` (remove `handle_verification`, lines 79-110)
|
|
247
|
+
- Test: `test/unit/whatsapp/gateway/cloud_api_test.rb` (existing, must stay green)
|
|
248
|
+
|
|
249
|
+
**Acceptance Criteria:**
|
|
250
|
+
- [ ] `FlowChat::Meta::GatewayIdentity` declares `platform`, `platform_label`, `configuration_error_class` (all `NotImplementedError` by default) and `log_tag` (derived from the class name)
|
|
251
|
+
- [ ] Both `SignatureValidation` and `WebhookVerification` work when included alone, with `GatewayIdentity` present, and neither depends on the other
|
|
252
|
+
- [ ] `FlowChat::Meta::WebhookVerification#handle_verification` renders the challenge on a token match
|
|
253
|
+
- [ ] A blank configured verify token returns `:forbidden` even when the request also sends a blank token
|
|
254
|
+
- [ ] `WEBHOOK_VERIFIED` and `WEBHOOK_FAILED` carry the including gateway's `platform`
|
|
255
|
+
- [ ] `handle_verification` is private, matching the visibility fix applied to `valid_webhook_signature?` in Task 1
|
|
256
|
+
|
|
257
|
+
**Verify:** `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n test_get_request_webhook_verification` → PASS
|
|
258
|
+
|
|
259
|
+
**Steps:**
|
|
260
|
+
|
|
261
|
+
- [ ] **Step 1: Create the identity module**
|
|
262
|
+
|
|
263
|
+
Create `lib/flow_chat/meta/gateway_identity.rb`:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
module FlowChat
|
|
267
|
+
module Meta
|
|
268
|
+
# What a Meta gateway must say about itself.
|
|
269
|
+
#
|
|
270
|
+
# The behavior modules in this namespace need a handful of the same values:
|
|
271
|
+
# which platform this is, how to name it to a developer, which error class to
|
|
272
|
+
# raise, what to tag logs with. Declaring them here rather than in whichever
|
|
273
|
+
# behavior module happens to be included first means a gateway can include
|
|
274
|
+
# one behavior without the other, and a gateway that forgets a value fails
|
|
275
|
+
# loudly rather than borrowing another platform's.
|
|
276
|
+
#
|
|
277
|
+
# NotImplementedError rather than a default: it descends from ScriptError,
|
|
278
|
+
# not StandardError, so it travels through the bare rescue in
|
|
279
|
+
# SignatureValidation instead of being swallowed into a false return that
|
|
280
|
+
# would read as "invalid signature" and drop every webhook.
|
|
281
|
+
module GatewayIdentity
|
|
282
|
+
def platform
|
|
283
|
+
raise NotImplementedError, "#{self.class.name} must define #platform"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The product's name as a developer reading an error message expects it,
|
|
287
|
+
# which is not always the constant: Whatsapp the module, WhatsApp the product.
|
|
288
|
+
def platform_label
|
|
289
|
+
raise NotImplementedError, "#{self.class.name} must define #platform_label"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def configuration_error_class
|
|
293
|
+
raise NotImplementedError, "#{self.class.name} must define #configuration_error_class"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Derived, because every gateway's class name already ends in the tag its
|
|
297
|
+
# logs use. Override only to pin the tag against a class rename.
|
|
298
|
+
def log_tag
|
|
299
|
+
self.class.name.split("::").last
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Then in `lib/flow_chat/meta/signature_validation.rb`, delete the `platform_label`, `configuration_error_class` and `log_tag` hooks and include the identity module instead:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
module SignatureValidation
|
|
310
|
+
include FlowChat::Meta::GatewayIdentity
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Confirm `test/unit/meta/signature_validation_test.rb` still passes: its fake defines the required hooks, so it should be unaffected.
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 2: Create the verification module**
|
|
316
|
+
|
|
317
|
+
Create `lib/flow_chat/meta/webhook_verification.rb`, including the identity module so it does not depend on `SignatureValidation` being present:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
module FlowChat
|
|
321
|
+
module Meta
|
|
322
|
+
# The GET handshake Meta performs when a webhook URL is registered.
|
|
323
|
+
#
|
|
324
|
+
# The including gateway must have @config responding to #verify_token,
|
|
325
|
+
# @controller, and must define #platform.
|
|
326
|
+
module WebhookVerification
|
|
327
|
+
def handle_verification(context)
|
|
328
|
+
params = @controller.request.params
|
|
329
|
+
|
|
330
|
+
verify_token = @config.verify_token
|
|
331
|
+
provided_token = params["hub.verify_token"]
|
|
332
|
+
challenge = params["hub.challenge"]
|
|
333
|
+
|
|
334
|
+
# A configuration with no verify token must not verify anything. Without
|
|
335
|
+
# the presence check a missing token on both sides compares equal, and
|
|
336
|
+
# anyone could claim the endpoint by asking for the challenge.
|
|
337
|
+
verified = verify_token.present? && FlowChat::Security.secure_compare(provided_token.to_s, verify_token)
|
|
338
|
+
|
|
339
|
+
FlowChat.logger.debug { "#{log_tag}: Webhook verification - provided token matches: #{verified}" }
|
|
340
|
+
|
|
341
|
+
if verified
|
|
342
|
+
instrument(Events::WEBHOOK_VERIFIED, {
|
|
343
|
+
challenge: challenge,
|
|
344
|
+
platform: platform
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
@controller.render plain: challenge
|
|
348
|
+
else
|
|
349
|
+
instrument(Events::WEBHOOK_FAILED, {
|
|
350
|
+
reason: "Invalid verify token",
|
|
351
|
+
platform: platform
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
@controller.head :forbidden
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
- [ ] **Step 3: Test the module directly**
|
|
363
|
+
|
|
364
|
+
Create `test/unit/meta/webhook_verification_test.rb`, reusing the shared fake gateway that Task 1 moved into `test/support/`. Cover, at minimum:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
def test_matching_token_renders_the_challenge
|
|
368
|
+
def test_wrong_token_is_forbidden
|
|
369
|
+
def test_blank_configured_token_is_forbidden_even_when_the_request_token_is_blank
|
|
370
|
+
def test_verified_event_carries_the_platform
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The blank-token case is the security-relevant one: without the `verify_token.present?` guard, a missing token on both sides compares equal and anyone can claim the endpoint by asking for the challenge.
|
|
374
|
+
|
|
375
|
+
Also prove the modules are independent, which is the whole point of `GatewayIdentity`:
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
# Each behavior module must stand alone. Before GatewayIdentity existed,
|
|
379
|
+
# WebhookVerification only worked because SignatureValidation happened to be
|
|
380
|
+
# included alongside it and supplied log_tag.
|
|
381
|
+
def test_verification_works_without_signature_validation
|
|
382
|
+
gateway_class = Class.new do
|
|
383
|
+
include FlowChat::Instrumentation
|
|
384
|
+
include FlowChat::Meta::WebhookVerification
|
|
385
|
+
|
|
386
|
+
def platform = :test_platform
|
|
387
|
+
def platform_label = "Test"
|
|
388
|
+
def configuration_error_class = FlowChat::Meta::ConfigurationError
|
|
389
|
+
def log_tag = "TestGateway"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
refute gateway_class.include?(FlowChat::Meta::SignatureValidation)
|
|
393
|
+
# then drive handle_verification and assert the challenge renders
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Note the explicit `log_tag` in that anonymous class: the derived default calls `self.class.name.split("::")`, and an anonymous class has a nil name, so it would raise. Named gateways are unaffected.
|
|
398
|
+
|
|
399
|
+
- [ ] **Step 4: Point the WhatsApp gateway at it**
|
|
400
|
+
|
|
401
|
+
Add the include:
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
include FlowChat::Meta::WebhookVerification
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Delete the private `handle_verification` method (lines 79-110) and add `platform` to the private section:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
def platform
|
|
411
|
+
:whatsapp
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`platform_label` and `configuration_error_class` are already there from Task 1. `log_tag` was deleted in Task 1 as a proven no-op, so do not add it back.
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 5: Run the verification tests**
|
|
418
|
+
|
|
419
|
+
Run: `bundle exec ruby -Itest test/unit/meta/webhook_verification_test.rb`
|
|
420
|
+
Expected: PASS.
|
|
421
|
+
|
|
422
|
+
Run: `bundle exec ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n /verification|verify_token/`
|
|
423
|
+
Expected: PASS.
|
|
424
|
+
|
|
425
|
+
- [ ] **Step 6: Run the full suite**
|
|
426
|
+
|
|
427
|
+
Run: `bundle exec rake test`
|
|
428
|
+
Expected: PASS, 0 failures.
|
|
429
|
+
|
|
430
|
+
- [ ] **Step 7: Commit**
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
git add lib/flow_chat/meta/gateway_identity.rb lib/flow_chat/meta/webhook_verification.rb \
|
|
434
|
+
lib/flow_chat/meta/signature_validation.rb lib/flow_chat/whatsapp/gateway/cloud_api.rb \
|
|
435
|
+
test/unit/meta/webhook_verification_test.rb
|
|
436
|
+
git commit -m "refactor(meta): share the verification handshake and name the identity seam
|
|
437
|
+
|
|
438
|
+
The verification module needs the same few facts about a gateway that the
|
|
439
|
+
signature module does. Taking them from whichever module happened to be
|
|
440
|
+
included first was a coupling waiting to break, so both now include one
|
|
441
|
+
identity module that states the contract."
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Task 3: Extract the named-configuration registry
|
|
447
|
+
|
|
448
|
+
**Goal:** One registry implementation with per-class storage, replacing three verbatim copies.
|
|
449
|
+
|
|
450
|
+
**Files:**
|
|
451
|
+
- Create: `lib/flow_chat/named_configuration.rb`
|
|
452
|
+
- Create: `test/unit/named_configuration_test.rb`
|
|
453
|
+
- Modify: `lib/flow_chat/whatsapp/configuration.rb:46-105`, `lib/flow_chat/telegram/configuration.rb:46-85`, `lib/flow_chat/intercom/configuration.rb:53-95`
|
|
454
|
+
|
|
455
|
+
**Acceptance Criteria:**
|
|
456
|
+
- [ ] `register`, `get`, `exists?`, `configuration_names`, `clear_all!`, `register_as` all provided by the module
|
|
457
|
+
- [ ] Storage is **per including class**: registering `:default` on Messenger does not make `Whatsapp::Configuration.exists?(:default)` true
|
|
458
|
+
- [ ] `get` on a missing name raises `ArgumentError` with each platform's existing message text preserved: `"WhatsApp configuration 'x' not found"`, `"Telegram configuration 'x' not found"`, `"Intercom configuration 'x' not found"`
|
|
459
|
+
|
|
460
|
+
**Verify:** `ruby -Itest test/unit/named_configuration_test.rb && ruby -Itest test/unit/telegram/configuration_test.rb && ruby -Itest test/unit/intercom/configuration_test.rb` → all pass
|
|
461
|
+
|
|
462
|
+
**Steps:**
|
|
463
|
+
|
|
464
|
+
- [ ] **Step 1: Write the failing test, isolation first**
|
|
465
|
+
|
|
466
|
+
Create `test/unit/named_configuration_test.rb`:
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
require "test_helper"
|
|
470
|
+
|
|
471
|
+
class NamedConfigurationTest < Minitest::Test
|
|
472
|
+
def setup
|
|
473
|
+
FlowChat::Whatsapp::Configuration.clear_all!
|
|
474
|
+
FlowChat::Telegram::Configuration.clear_all!
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def teardown
|
|
478
|
+
FlowChat::Whatsapp::Configuration.clear_all!
|
|
479
|
+
FlowChat::Telegram::Configuration.clear_all!
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# The bug a shared @@configurations would have introduced: one merged registry
|
|
483
|
+
# across every platform.
|
|
484
|
+
def test_registries_are_per_class
|
|
485
|
+
FlowChat::Whatsapp::Configuration.new(:shared_name)
|
|
486
|
+
|
|
487
|
+
assert FlowChat::Whatsapp::Configuration.exists?(:shared_name)
|
|
488
|
+
refute FlowChat::Telegram::Configuration.exists?(:shared_name)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def test_get_returns_the_registered_configuration
|
|
492
|
+
config = FlowChat::Whatsapp::Configuration.new(:acme)
|
|
493
|
+
|
|
494
|
+
assert_same config, FlowChat::Whatsapp::Configuration.get(:acme)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def test_get_raises_with_the_platform_label
|
|
498
|
+
error = assert_raises(ArgumentError) { FlowChat::Whatsapp::Configuration.get(:missing) }
|
|
499
|
+
assert_equal "WhatsApp configuration 'missing' not found", error.message
|
|
500
|
+
|
|
501
|
+
error = assert_raises(ArgumentError) { FlowChat::Telegram::Configuration.get(:missing) }
|
|
502
|
+
assert_equal "Telegram configuration 'missing' not found", error.message
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def test_configuration_names_lists_only_that_platform
|
|
506
|
+
FlowChat::Whatsapp::Configuration.new(:wa_one)
|
|
507
|
+
FlowChat::Telegram::Configuration.new(:tg_one)
|
|
508
|
+
|
|
509
|
+
assert_equal [:wa_one], FlowChat::Whatsapp::Configuration.configuration_names
|
|
510
|
+
assert_equal [:tg_one], FlowChat::Telegram::Configuration.configuration_names
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
516
|
+
|
|
517
|
+
Run: `ruby -Itest test/unit/named_configuration_test.rb`
|
|
518
|
+
Expected: FAIL. The per-class test may pass by accident today (each class has its own `@@configurations`), but `test_get_raises_with_the_platform_label` and the others should run green only once the module exists and is wired. Confirm the file at least loads.
|
|
519
|
+
|
|
520
|
+
- [ ] **Step 3: Write the module**
|
|
521
|
+
|
|
522
|
+
Create `lib/flow_chat/named_configuration.rb`:
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
module FlowChat
|
|
526
|
+
# The named-configuration registry every platform's Configuration shares.
|
|
527
|
+
#
|
|
528
|
+
# Storage is a class-level ivar on the including class, not a class variable.
|
|
529
|
+
# A @@configurations in a shared module would give every platform one merged
|
|
530
|
+
# registry, so a name registered for Messenger would resolve for WhatsApp.
|
|
531
|
+
module NamedConfiguration
|
|
532
|
+
def self.included(base)
|
|
533
|
+
base.extend(ClassMethods)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
module ClassMethods
|
|
537
|
+
def configurations
|
|
538
|
+
@configurations ||= {}
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def register(name, config)
|
|
542
|
+
FlowChat.logger.debug { "#{self.name}: Registering configuration '#{name}'" }
|
|
543
|
+
configurations[name.to_sym] = config
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def get(name)
|
|
547
|
+
config = configurations[name.to_sym]
|
|
548
|
+
unless config
|
|
549
|
+
FlowChat.logger.error { "#{self.name}: Configuration '#{name}' not found" }
|
|
550
|
+
raise ArgumentError, "#{configuration_label} configuration '#{name}' not found"
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
FlowChat.logger.debug { "#{self.name}: Retrieved configuration '#{name}'" }
|
|
554
|
+
config
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def exists?(name)
|
|
558
|
+
configurations.key?(name.to_sym)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def configuration_names
|
|
562
|
+
configurations.keys
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def clear_all!
|
|
566
|
+
FlowChat.logger.debug { "#{self.name}: Clearing all registered configurations" }
|
|
567
|
+
configurations.clear
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# The platform's name as it appears in the not-found message. Overridden
|
|
571
|
+
# where the constant name and the product name differ, as with WhatsApp.
|
|
572
|
+
def configuration_label
|
|
573
|
+
name.split("::")[-2]
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def register_as(name)
|
|
578
|
+
FlowChat.logger.debug { "#{self.class.name}: Registering configuration as '#{name}'" }
|
|
579
|
+
@name = name.to_sym
|
|
580
|
+
self.class.register(@name, self)
|
|
581
|
+
self
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
- [ ] **Step 4: Wire the three existing classes**
|
|
588
|
+
|
|
589
|
+
In `lib/flow_chat/whatsapp/configuration.rb`, delete `@@configurations = {}` and the methods `self.register`, `self.get`, `self.exists?`, `self.configuration_names`, `self.clear_all!` and `register_as` (lines 46-105 region). Add near the top of the class body:
|
|
590
|
+
|
|
591
|
+
```ruby
|
|
592
|
+
include FlowChat::NamedConfiguration
|
|
593
|
+
|
|
594
|
+
# "Whatsapp" is the constant, "WhatsApp" is the product.
|
|
595
|
+
def self.configuration_label
|
|
596
|
+
"WhatsApp"
|
|
597
|
+
end
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Do the same in `lib/flow_chat/telegram/configuration.rb` and `lib/flow_chat/intercom/configuration.rb`, but without the `configuration_label` override: `name.split("::")[-2]` already yields `"Telegram"` and `"Intercom"`.
|
|
601
|
+
|
|
602
|
+
- [ ] **Step 5: Run the tests**
|
|
603
|
+
|
|
604
|
+
Run: `ruby -Itest test/unit/named_configuration_test.rb`
|
|
605
|
+
Expected: PASS, 4 tests.
|
|
606
|
+
|
|
607
|
+
Run: `ruby -Itest test/unit/telegram/configuration_test.rb && ruby -Itest test/unit/intercom/configuration_test.rb`
|
|
608
|
+
Expected: PASS. These are the regression net for the two migrations that have direct coverage.
|
|
609
|
+
|
|
610
|
+
- [ ] **Step 6: Run the full suite**
|
|
611
|
+
|
|
612
|
+
Run: `bundle exec rake test`
|
|
613
|
+
Expected: PASS, 0 failures. WhatsApp's configuration has no direct test, so its migration rides on the gateway and client suites.
|
|
614
|
+
|
|
615
|
+
- [ ] **Step 7: Commit**
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
git add lib/flow_chat/named_configuration.rb test/unit/named_configuration_test.rb \
|
|
619
|
+
lib/flow_chat/whatsapp/configuration.rb lib/flow_chat/telegram/configuration.rb \
|
|
620
|
+
lib/flow_chat/intercom/configuration.rb
|
|
621
|
+
git commit -m "refactor(config): keep the named configuration registry in one place"
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
---
|
|
625
|
+
|
|
626
|
+
## Task 4: Move IdGenerator up and make its cap configurable
|
|
627
|
+
|
|
628
|
+
**Goal:** `FlowChat::IdGenerator` usable by any platform, with the maximum length passed in rather than fixed at WhatsApp's 256.
|
|
629
|
+
|
|
630
|
+
**Files:**
|
|
631
|
+
- Create: `lib/flow_chat/id_generator.rb`
|
|
632
|
+
- Delete: `lib/flow_chat/whatsapp/id_generator.rb`
|
|
633
|
+
- Modify: `lib/flow_chat/whatsapp/middleware/choice_mapper.rb:1` (the `require_relative`) and its `IdGenerator.new` call
|
|
634
|
+
- Move: `test/unit/whatsapp/id_generator_test.rb` to `test/unit/id_generator_test.rb`
|
|
635
|
+
|
|
636
|
+
**Acceptance Criteria:**
|
|
637
|
+
- [ ] `FlowChat::IdGenerator.new(max_length: 1000)` truncates at 1000
|
|
638
|
+
- [ ] `FlowChat::IdGenerator.new` still defaults to 256, so WhatsApp behavior is unchanged
|
|
639
|
+
- [ ] Duplicate labels still get a hash suffix, and the suffix still fits inside the cap
|
|
640
|
+
- [ ] `FlowChat::Whatsapp::IdGenerator` no longer exists
|
|
641
|
+
|
|
642
|
+
**Verify:** `ruby -Itest test/unit/id_generator_test.rb` → PASS
|
|
643
|
+
|
|
644
|
+
**Steps:**
|
|
645
|
+
|
|
646
|
+
- [ ] **Step 1: Move the file and the test**
|
|
647
|
+
|
|
648
|
+
```bash
|
|
649
|
+
git mv lib/flow_chat/whatsapp/id_generator.rb lib/flow_chat/id_generator.rb
|
|
650
|
+
git mv test/unit/whatsapp/id_generator_test.rb test/unit/id_generator_test.rb
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
- [ ] **Step 2: Reopen the class one level up and parameterize the cap**
|
|
654
|
+
|
|
655
|
+
In `lib/flow_chat/id_generator.rb`, change the module nesting from `FlowChat::Whatsapp::IdGenerator` to `FlowChat::IdGenerator`, replace the `MAX_ID_LENGTH` constant usage with an instance attribute, and keep `HASH_SUFFIX_LENGTH`:
|
|
656
|
+
|
|
657
|
+
```ruby
|
|
658
|
+
require "digest"
|
|
659
|
+
|
|
660
|
+
module FlowChat
|
|
661
|
+
# Generates platform-safe ids from choice labels.
|
|
662
|
+
#
|
|
663
|
+
# Interactive replies are identified by an id rather than by the label the user
|
|
664
|
+
# saw, and every platform caps that id: WhatsApp list rows at 256 characters,
|
|
665
|
+
# Messenger quick-reply payloads at 1000. The cap is a constructor argument so
|
|
666
|
+
# one generator serves all of them.
|
|
667
|
+
class IdGenerator
|
|
668
|
+
DEFAULT_MAX_ID_LENGTH = 256
|
|
669
|
+
HASH_SUFFIX_LENGTH = 3
|
|
670
|
+
|
|
671
|
+
attr_reader :max_length
|
|
672
|
+
|
|
673
|
+
def initialize(max_length: DEFAULT_MAX_ID_LENGTH)
|
|
674
|
+
@max_length = max_length
|
|
675
|
+
@generated_ids = []
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# ... generate_id, reset, generated_ids unchanged ...
|
|
679
|
+
|
|
680
|
+
private
|
|
681
|
+
|
|
682
|
+
# ... normalize_label unchanged ...
|
|
683
|
+
|
|
684
|
+
def add_hash_suffix(base_id, original_label)
|
|
685
|
+
hash_input = "#{original_label}_#{@generated_ids.count { |id| id.start_with?(base_id) }}"
|
|
686
|
+
hash = Digest::SHA256.hexdigest(hash_input)[0...HASH_SUFFIX_LENGTH]
|
|
687
|
+
|
|
688
|
+
max_base_length = max_length - HASH_SUFFIX_LENGTH - 1
|
|
689
|
+
truncated_base = base_id[0...max_base_length]
|
|
690
|
+
|
|
691
|
+
"#{truncated_base} #{hash}"
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def truncate_to_limit(id)
|
|
695
|
+
return id if id.length <= max_length
|
|
696
|
+
id[0...max_length]
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
- [ ] **Step 3: Update the test file's class references**
|
|
703
|
+
|
|
704
|
+
In `test/unit/id_generator_test.rb`, replace every `FlowChat::Whatsapp::IdGenerator` with `FlowChat::IdGenerator`, and add a test for the new cap:
|
|
705
|
+
|
|
706
|
+
```ruby
|
|
707
|
+
def test_max_length_is_configurable
|
|
708
|
+
generator = FlowChat::IdGenerator.new(max_length: 10)
|
|
709
|
+
|
|
710
|
+
assert_equal 10, generator.generate_id("a" * 50).length
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def test_default_max_length_is_unchanged
|
|
714
|
+
generator = FlowChat::IdGenerator.new
|
|
715
|
+
|
|
716
|
+
assert_equal 256, generator.generate_id("a" * 300).length
|
|
717
|
+
end
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
- [ ] **Step 4: Update the WhatsApp choice mapper**
|
|
721
|
+
|
|
722
|
+
In `lib/flow_chat/whatsapp/middleware/choice_mapper.rb`, delete line 1 (`require_relative "../id_generator"`) since Zeitwerk resolves `FlowChat::IdGenerator`, and change the instantiation inside `create_id_mapping`:
|
|
723
|
+
|
|
724
|
+
```ruby
|
|
725
|
+
id_generator = FlowChat::IdGenerator.new
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
- [ ] **Step 5: Run the tests**
|
|
729
|
+
|
|
730
|
+
Run: `ruby -Itest test/unit/id_generator_test.rb`
|
|
731
|
+
Expected: PASS.
|
|
732
|
+
|
|
733
|
+
Run: `ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb`
|
|
734
|
+
Expected: PASS.
|
|
735
|
+
|
|
736
|
+
- [ ] **Step 6: Run the full suite**
|
|
737
|
+
|
|
738
|
+
Run: `bundle exec rake test`
|
|
739
|
+
Expected: PASS, 0 failures.
|
|
740
|
+
|
|
741
|
+
- [ ] **Step 7: Commit**
|
|
742
|
+
|
|
743
|
+
```bash
|
|
744
|
+
git add -A lib/flow_chat/id_generator.rb lib/flow_chat/whatsapp/ test/unit/id_generator_test.rb
|
|
745
|
+
git commit -m "refactor(choices): lift the id generator out of whatsapp"
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## Task 5: Add plain-text markdown conversion
|
|
751
|
+
|
|
752
|
+
**Goal:** `to_plain_text` on `Renderers::MarkdownSupport`, for the two platforms that support no rich text at all.
|
|
753
|
+
|
|
754
|
+
**Files:**
|
|
755
|
+
- Modify: `lib/flow_chat/renderers/markdown_support.rb`
|
|
756
|
+
- Create: `test/unit/renderers/plain_text_support_test.rb`
|
|
757
|
+
|
|
758
|
+
**Acceptance Criteria:**
|
|
759
|
+
- [ ] Emphasis markers are removed, leaving the words: `**bold**` becomes `bold`
|
|
760
|
+
- [ ] `ul` renders as `• item` lines, `ol` as `1. item` lines
|
|
761
|
+
- [ ] A link renders as `text (url)`, and as bare `url` when the text equals the url
|
|
762
|
+
- [ ] HTML entities are decoded, and runs of 3 or more newlines collapse to 2
|
|
763
|
+
|
|
764
|
+
**Verify:** `ruby -Itest test/unit/renderers/plain_text_support_test.rb` → PASS
|
|
765
|
+
|
|
766
|
+
**Steps:**
|
|
767
|
+
|
|
768
|
+
- [ ] **Step 1: Write the failing test**
|
|
769
|
+
|
|
770
|
+
Create `test/unit/renderers/plain_text_support_test.rb`:
|
|
771
|
+
|
|
772
|
+
```ruby
|
|
773
|
+
require "test_helper"
|
|
774
|
+
|
|
775
|
+
class PlainTextSupportTest < Minitest::Test
|
|
776
|
+
class Subject
|
|
777
|
+
include FlowChat::Renderers::MarkdownSupport
|
|
778
|
+
public :to_plain_text
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def setup
|
|
782
|
+
@subject = Subject.new
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def test_strips_emphasis
|
|
786
|
+
assert_equal "bold and italic", @subject.to_plain_text("**bold** and _italic_")
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def test_unordered_list_becomes_bullets
|
|
790
|
+
assert_equal "• one\n• two", @subject.to_plain_text("- one\n- two")
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def test_ordered_list_is_numbered
|
|
794
|
+
assert_equal "1. one\n2. two", @subject.to_plain_text("1. one\n2. two")
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def test_link_shows_text_and_url
|
|
798
|
+
assert_equal "Docs (https://example.com)", @subject.to_plain_text("[Docs](https://example.com)")
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def test_link_with_url_as_text_shows_url_once
|
|
802
|
+
assert_equal "https://example.com", @subject.to_plain_text("[https://example.com](https://example.com)")
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def test_decodes_entities
|
|
806
|
+
assert_equal "Tom & Jerry", @subject.to_plain_text("Tom & Jerry")
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def test_nil_is_empty_string
|
|
810
|
+
assert_equal "", @subject.to_plain_text(nil)
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
816
|
+
|
|
817
|
+
Run: `ruby -Itest test/unit/renderers/plain_text_support_test.rb`
|
|
818
|
+
Expected: FAIL with `NoMethodError: undefined method 'to_plain_text'`.
|
|
819
|
+
|
|
820
|
+
- [ ] **Step 3: Implement it**
|
|
821
|
+
|
|
822
|
+
Add to `lib/flow_chat/renderers/markdown_support.rb`, inside the module and above `private`:
|
|
823
|
+
|
|
824
|
+
```ruby
|
|
825
|
+
# Markdown rendered as plain text, for platforms with no rich text at all.
|
|
826
|
+
# Messenger and Instagram both fall here: they display exactly the
|
|
827
|
+
# characters sent, so any leftover markup is noise the user reads.
|
|
828
|
+
def to_plain_text(text)
|
|
829
|
+
return "" if text.nil?
|
|
830
|
+
|
|
831
|
+
html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
|
|
832
|
+
html_to_plain_text(html)
|
|
833
|
+
end
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
And in the private section:
|
|
837
|
+
|
|
838
|
+
```ruby
|
|
839
|
+
def html_to_plain_text(html)
|
|
840
|
+
result = html.dup
|
|
841
|
+
|
|
842
|
+
# Code blocks and inline code keep their content, lose their markers.
|
|
843
|
+
result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { $1.strip }
|
|
844
|
+
result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { $1 }
|
|
845
|
+
|
|
846
|
+
# Links first: the anchor text is needed before tags are stripped.
|
|
847
|
+
result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
|
|
848
|
+
url, text = $1, $2
|
|
849
|
+
(text == url) ? url : "#{text} (#{url})"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
result.gsub!(%r{<ul[^>]*>(.*?)</ul>}m) do
|
|
853
|
+
$1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten.map { |item| "• #{item.strip}" }.join("\n")
|
|
854
|
+
end
|
|
855
|
+
result.gsub!(%r{<ol[^>]*>(.*?)</ol>}m) do
|
|
856
|
+
$1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten.map.with_index(1) { |item, i| "#{i}. #{item.strip}" }.join("\n")
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
|
|
860
|
+
$1.lines.map { |line| "> #{line.strip}" }.join("\n")
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
|
|
864
|
+
result.gsub!(/<br\s*\/?>/, "\n")
|
|
865
|
+
|
|
866
|
+
# Every remaining tag, emphasis included, goes without replacement.
|
|
867
|
+
result.gsub!(/<[^>]+>/, "")
|
|
868
|
+
|
|
869
|
+
result.gsub!("&", "&")
|
|
870
|
+
result.gsub!("<", "<")
|
|
871
|
+
result.gsub!(">", ">")
|
|
872
|
+
result.gsub!(""", '"')
|
|
873
|
+
result.gsub!("'", "'")
|
|
874
|
+
result.gsub!(" ", " ")
|
|
875
|
+
|
|
876
|
+
result.gsub!(/\n{3,}/, "\n\n")
|
|
877
|
+
|
|
878
|
+
result.strip
|
|
879
|
+
end
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
- [ ] **Step 4: Run the test**
|
|
883
|
+
|
|
884
|
+
Run: `ruby -Itest test/unit/renderers/plain_text_support_test.rb`
|
|
885
|
+
Expected: PASS, 7 tests.
|
|
886
|
+
|
|
887
|
+
- [ ] **Step 5: Confirm nothing else regressed**
|
|
888
|
+
|
|
889
|
+
Run: `bundle exec rake test`
|
|
890
|
+
Expected: PASS. `to_html` and `to_whatsapp` are untouched.
|
|
891
|
+
|
|
892
|
+
- [ ] **Step 6: Commit**
|
|
893
|
+
|
|
894
|
+
```bash
|
|
895
|
+
git add lib/flow_chat/renderers/markdown_support.rb test/unit/renderers/plain_text_support_test.rb
|
|
896
|
+
git commit -m "feat(renderers): render markdown as plain text"
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
---
|
|
900
|
+
|
|
901
|
+
## Task 6: Fix WhatsApp lists above 10 choices
|
|
902
|
+
|
|
903
|
+
**Goal:** Stop building list payloads Meta rejects. Meta allows 10 rows for all sections combined, so above 10 choices the renderer falls back to a numbered body.
|
|
904
|
+
|
|
905
|
+
**Files:**
|
|
906
|
+
- Modify: `lib/flow_chat/whatsapp/renderer.rb:186-217` (`build_list_message`, `build_interactive_message`)
|
|
907
|
+
- Modify: `test/unit/whatsapp/renderer_test.rb:200-235` (the section-slicing assertions)
|
|
908
|
+
- Modify: `lib/flow_chat/whatsapp/middleware/choice_mapper.rb` (store a position map for the numbered rung)
|
|
909
|
+
|
|
910
|
+
**Acceptance Criteria:**
|
|
911
|
+
- [ ] 3 or fewer choices render `:interactive_buttons`, unchanged
|
|
912
|
+
- [ ] 4 to 10 choices render `:interactive_list` with exactly one section
|
|
913
|
+
- [ ] 11 or more choices render `:text` with each option numbered in the body, and no `sections` key
|
|
914
|
+
- [ ] No rendered list ever contains more than 10 rows in total
|
|
915
|
+
- [ ] Typing `"3"` on a numbered screen resolves to the third choice's original key
|
|
916
|
+
|
|
917
|
+
**Verify:** `ruby -Itest test/unit/whatsapp/renderer_test.rb && ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb` → PASS
|
|
918
|
+
|
|
919
|
+
**Steps:**
|
|
920
|
+
|
|
921
|
+
- [ ] **Step 1: Write the failing tests**
|
|
922
|
+
|
|
923
|
+
Replace the existing `test_list_message_pagination_for_many_choices` (around `test/unit/whatsapp/renderer_test.rb:200-214`) with:
|
|
924
|
+
|
|
925
|
+
```ruby
|
|
926
|
+
# Meta allows "up to 10 sections, with up to 10 rows for all sections combined",
|
|
927
|
+
# so the old three-sections-of-ten payload was rejected on send.
|
|
928
|
+
def test_list_is_capped_at_ten_rows
|
|
929
|
+
choices = (1..10).to_h { |i| ["key#{i}", "Option #{i}"] }
|
|
930
|
+
|
|
931
|
+
result = FlowChat::Whatsapp::Renderer.new("Pick one", choices: choices).render
|
|
932
|
+
|
|
933
|
+
assert_equal :interactive_list, result[0]
|
|
934
|
+
assert_equal 1, result[2][:sections].length
|
|
935
|
+
assert_equal 10, result[2][:sections][0][:rows].length
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
def test_more_than_ten_choices_fall_back_to_a_numbered_body
|
|
939
|
+
choices = (1..25).to_h { |i| ["key#{i}", "Option #{i}"] }
|
|
940
|
+
|
|
941
|
+
result = FlowChat::Whatsapp::Renderer.new("Pick one", choices: choices).render
|
|
942
|
+
|
|
943
|
+
assert_equal :text, result[0]
|
|
944
|
+
assert_nil result[2][:sections]
|
|
945
|
+
assert_includes result[1], "1. Option 1"
|
|
946
|
+
assert_includes result[1], "25. Option 25"
|
|
947
|
+
assert_includes result[1], "Pick one"
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def test_three_or_fewer_choices_still_use_buttons
|
|
951
|
+
choices = {"a" => "Alpha", "b" => "Beta"}
|
|
952
|
+
|
|
953
|
+
result = FlowChat::Whatsapp::Renderer.new("Pick one", choices: choices).render
|
|
954
|
+
|
|
955
|
+
assert_equal :interactive_buttons, result[0]
|
|
956
|
+
end
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
960
|
+
|
|
961
|
+
Run: `ruby -Itest test/unit/whatsapp/renderer_test.rb -n test_more_than_ten_choices_fall_back_to_a_numbered_body`
|
|
962
|
+
Expected: FAIL. It returns `:interactive_list` with 3 sections.
|
|
963
|
+
|
|
964
|
+
- [ ] **Step 3: Fix the renderer**
|
|
965
|
+
|
|
966
|
+
In `lib/flow_chat/whatsapp/renderer.rb`, add the cap constant to the class body:
|
|
967
|
+
|
|
968
|
+
```ruby
|
|
969
|
+
# Meta: "up to 10 sections, with up to 10 rows for all sections combined".
|
|
970
|
+
MAX_LIST_ROWS = 10
|
|
971
|
+
MAX_BUTTONS = 3
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
Change `build_interactive_message` to a three-way ladder:
|
|
975
|
+
|
|
976
|
+
```ruby
|
|
977
|
+
def build_interactive_message(choice_hash)
|
|
978
|
+
if choice_hash.length <= MAX_BUTTONS
|
|
979
|
+
build_buttons_message(choice_hash)
|
|
980
|
+
elsif choice_hash.length <= MAX_LIST_ROWS
|
|
981
|
+
build_list_message(choice_hash)
|
|
982
|
+
else
|
|
983
|
+
build_numbered_message(choice_hash)
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
Replace the section-slicing branch in `build_list_message` with a single section, and add the fallback:
|
|
989
|
+
|
|
990
|
+
```ruby
|
|
991
|
+
def build_list_message(choices)
|
|
992
|
+
items = choices.map do |key, value|
|
|
993
|
+
original_text = value.to_s
|
|
994
|
+
truncated_title = truncate_text(original_text, 24)
|
|
995
|
+
|
|
996
|
+
description = if original_text.length > 24
|
|
997
|
+
truncate_text(original_text, 72)
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
{
|
|
1001
|
+
id: key.to_s,
|
|
1002
|
+
title: truncated_title,
|
|
1003
|
+
description: description
|
|
1004
|
+
}.compact
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
[:interactive_list, formatted_message, {sections: [{title: "Options", rows: items}]}]
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Above the row cap there is no interactive surface left, so the options go
|
|
1011
|
+
# in the body and the user types a number. The choice mapper stores the
|
|
1012
|
+
# positions for this rung so the digit resolves to the original key.
|
|
1013
|
+
def build_numbered_message(choices)
|
|
1014
|
+
numbered = choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
|
|
1015
|
+
|
|
1016
|
+
[:text, "#{formatted_message}\n\n#{numbered}", {}]
|
|
1017
|
+
end
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
- [ ] **Step 4: Run the renderer tests**
|
|
1021
|
+
|
|
1022
|
+
Run: `ruby -Itest test/unit/whatsapp/renderer_test.rb`
|
|
1023
|
+
Expected: PASS. If any other test asserted multi-section output, update it to the new ladder rather than restoring the old behavior.
|
|
1024
|
+
|
|
1025
|
+
- [ ] **Step 5: Teach the choice mapper the numbered rung**
|
|
1026
|
+
|
|
1027
|
+
In `lib/flow_chat/whatsapp/middleware/choice_mapper.rb`, store a position map alongside the id map whenever the numbered rung will be used, and resolve ids before positions. Replace `create_id_mapping` and add the resolution fallback:
|
|
1028
|
+
|
|
1029
|
+
```ruby
|
|
1030
|
+
def create_id_mapping(choices)
|
|
1031
|
+
id_generator = FlowChat::IdGenerator.new
|
|
1032
|
+
id_choices = {}
|
|
1033
|
+
choice_mapping = {}
|
|
1034
|
+
|
|
1035
|
+
choices.each do |key, value|
|
|
1036
|
+
generated_id = id_generator.generate_id(value.to_s)
|
|
1037
|
+
id_choices[generated_id] = value
|
|
1038
|
+
choice_mapping[generated_id] = key.to_s
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
store_choice_mapping(choice_mapping)
|
|
1042
|
+
|
|
1043
|
+
# Above the row cap the renderer numbers the options in the body, so
|
|
1044
|
+
# the reply is a digit rather than a row id.
|
|
1045
|
+
if choices.length > FlowChat::Whatsapp::Renderer::MAX_LIST_ROWS
|
|
1046
|
+
store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
1047
|
+
else
|
|
1048
|
+
clear_position_mapping
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
id_choices
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def store_position_mapping(mapping)
|
|
1055
|
+
@session.set("whatsapp.position_mapping", mapping)
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
def get_position_mapping
|
|
1059
|
+
@session.get("whatsapp.position_mapping") || {}
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def clear_position_mapping
|
|
1063
|
+
@session.delete("whatsapp.position_mapping")
|
|
1064
|
+
end
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
Then widen `intercept?` and `handle_choice_input` to consult both maps, ids first:
|
|
1068
|
+
|
|
1069
|
+
```ruby
|
|
1070
|
+
def resolved_choice
|
|
1071
|
+
input = @context.input.to_s
|
|
1072
|
+
get_choice_mapping[input] || get_position_mapping[input]
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def intercept?
|
|
1076
|
+
@context.input.present? && resolved_choice.present?
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def handle_choice_input
|
|
1080
|
+
original_choice = resolved_choice
|
|
1081
|
+
FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
|
|
1082
|
+
@context.input = original_choice
|
|
1083
|
+
end
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
Ids are resolved before positions because the two key spaces can overlap: `IdGenerator#normalize_label` keeps `\w`, which includes digits, so a choice labelled `"1"` generates the id `"1"`.
|
|
1087
|
+
|
|
1088
|
+
- [ ] **Step 6: Test the numbered resolution**
|
|
1089
|
+
|
|
1090
|
+
Add to `test/unit/whatsapp/middleware/choice_mapper_test.rb`:
|
|
1091
|
+
|
|
1092
|
+
```ruby
|
|
1093
|
+
def test_typed_number_resolves_on_the_numbered_rung
|
|
1094
|
+
choices = (1..25).to_h { |i| ["key#{i}", "Option #{i}"] }
|
|
1095
|
+
app = ->(context) { [:prompt, "Pick one", choices, nil] }
|
|
1096
|
+
mapper = FlowChat::Whatsapp::Middleware::ChoiceMapper.new(app)
|
|
1097
|
+
|
|
1098
|
+
context = build_choice_mapper_context(input: "")
|
|
1099
|
+
mapper.call(context)
|
|
1100
|
+
|
|
1101
|
+
second_turn = build_choice_mapper_context(input: "3", session: context.session)
|
|
1102
|
+
mapper.call(second_turn)
|
|
1103
|
+
|
|
1104
|
+
assert_equal "key3", second_turn.input
|
|
1105
|
+
end
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
Match the existing helper names in that file. If it builds contexts inline rather than through a helper, follow its established style instead of introducing `build_choice_mapper_context`.
|
|
1109
|
+
|
|
1110
|
+
Run: `ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb`
|
|
1111
|
+
Expected: PASS.
|
|
1112
|
+
|
|
1113
|
+
- [ ] **Step 7: Run the full suite**
|
|
1114
|
+
|
|
1115
|
+
Run: `bundle exec rake test`
|
|
1116
|
+
Expected: PASS, 0 failures.
|
|
1117
|
+
|
|
1118
|
+
- [ ] **Step 8: Commit**
|
|
1119
|
+
|
|
1120
|
+
```bash
|
|
1121
|
+
git add lib/flow_chat/whatsapp/renderer.rb lib/flow_chat/whatsapp/middleware/choice_mapper.rb \
|
|
1122
|
+
test/unit/whatsapp/renderer_test.rb test/unit/whatsapp/middleware/choice_mapper_test.rb
|
|
1123
|
+
git commit -m "fix(whatsapp): stop building list payloads Meta rejects
|
|
1124
|
+
|
|
1125
|
+
Meta allows ten rows for all sections combined, not ten per section, so
|
|
1126
|
+
slicing twenty-five choices into three sections produced a payload that
|
|
1127
|
+
failed on send. The section titles read like pagination but nothing was
|
|
1128
|
+
paged: no second message, no stored offset.
|
|
1129
|
+
|
|
1130
|
+
Above ten choices the options now go in the body numbered, and the choice
|
|
1131
|
+
mapper stores their positions so a typed digit resolves. Ids are resolved
|
|
1132
|
+
before positions because a choice labelled \"1\" generates the id \"1\"."
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
---
|
|
1136
|
+
|
|
1137
|
+
## Task 7: Say who sent a WhatsApp echo
|
|
1138
|
+
|
|
1139
|
+
**Goal:** Coexistence echoes report whether our app, another app, or a human in the business inbox sent the message.
|
|
1140
|
+
|
|
1141
|
+
**Files:**
|
|
1142
|
+
- Modify: `lib/flow_chat/whatsapp/gateway/cloud_api.rb:309-323` (`handle_unmodelled_field`)
|
|
1143
|
+
- Modify: `test/unit/whatsapp/gateway/cloud_api_test.rb`
|
|
1144
|
+
|
|
1145
|
+
**Acceptance Criteria:**
|
|
1146
|
+
- [ ] `WEBHOOK_RECEIVED` for an echo field carries `echo_origin`
|
|
1147
|
+
- [ ] `echo_origin` is `:self` when the payload's `app_id` equals the configured `app_id`
|
|
1148
|
+
- [ ] `echo_origin` is `:other_app` when an `app_id` is present but different
|
|
1149
|
+
- [ ] `echo_origin` is `:human_agent` when no `app_id` is present
|
|
1150
|
+
- [ ] Non-echo fields are published exactly as before, with no `echo_origin` key
|
|
1151
|
+
|
|
1152
|
+
**Verify:** `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n /echo/` → PASS
|
|
1153
|
+
|
|
1154
|
+
**Steps:**
|
|
1155
|
+
|
|
1156
|
+
- [ ] **Step 1: Write the failing tests**
|
|
1157
|
+
|
|
1158
|
+
Add to `test/unit/whatsapp/gateway/cloud_api_test.rb`:
|
|
1159
|
+
|
|
1160
|
+
```ruby
|
|
1161
|
+
def test_echo_from_a_human_in_the_business_inbox
|
|
1162
|
+
@mock_config.app_id = "our_app"
|
|
1163
|
+
events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
|
|
1164
|
+
context = create_context_with_request(
|
|
1165
|
+
method: :post,
|
|
1166
|
+
body: echo_payload(app_id: nil)
|
|
1167
|
+
)
|
|
1168
|
+
@gateway.call(context)
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
assert_equal 1, events.size
|
|
1172
|
+
assert_equal :human_agent, events.first[:echo_origin]
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def test_echo_from_our_own_app
|
|
1176
|
+
@mock_config.app_id = "our_app"
|
|
1177
|
+
events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
|
|
1178
|
+
context = create_context_with_request(method: :post, body: echo_payload(app_id: "our_app"))
|
|
1179
|
+
@gateway.call(context)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
assert_equal :self, events.first[:echo_origin]
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def test_echo_from_another_app
|
|
1186
|
+
@mock_config.app_id = "our_app"
|
|
1187
|
+
events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
|
|
1188
|
+
context = create_context_with_request(method: :post, body: echo_payload(app_id: "someone_else"))
|
|
1189
|
+
@gateway.call(context)
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
assert_equal :other_app, events.first[:echo_origin]
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
def test_non_echo_field_has_no_echo_origin
|
|
1196
|
+
events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
|
|
1197
|
+
context = create_context_with_request(
|
|
1198
|
+
method: :post,
|
|
1199
|
+
body: {"entry" => [{"id" => "biz_1", "changes" => [{"field" => "account_update", "value" => {"event" => "PARTNER_ADDED"}}]}]}
|
|
1200
|
+
)
|
|
1201
|
+
@gateway.call(context)
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
refute events.first.key?(:echo_origin)
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
private
|
|
1208
|
+
|
|
1209
|
+
def echo_payload(app_id:)
|
|
1210
|
+
value = {
|
|
1211
|
+
"metadata" => {"display_phone_number" => "+15551234567", "phone_number_id" => "test_phone_id"},
|
|
1212
|
+
"message_echoes" => [{"id" => "wamid.echo1", "from" => "15551234567", "type" => "text", "text" => {"body" => "Hi"}}]
|
|
1213
|
+
}
|
|
1214
|
+
value["message_echoes"][0]["app_id"] = app_id if app_id
|
|
1215
|
+
|
|
1216
|
+
{"entry" => [{"id" => "biz_1", "changes" => [{"field" => "smb_message_echoes", "value" => value}]}]}
|
|
1217
|
+
end
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
Use the file's existing event-capture helper. If none exists, add one that subscribes with `ActiveSupport::Notifications.subscribe`, collects payloads, and unsubscribes in `teardown` (the file already tracks `@subscribers` for this).
|
|
1221
|
+
|
|
1222
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
1223
|
+
|
|
1224
|
+
Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n /echo/`
|
|
1225
|
+
Expected: FAIL. `echo_origin` is not in the payload.
|
|
1226
|
+
|
|
1227
|
+
- [ ] **Step 3: Implement the derivation**
|
|
1228
|
+
|
|
1229
|
+
In `lib/flow_chat/whatsapp/gateway/cloud_api.rb`, extend `handle_unmodelled_field`:
|
|
1230
|
+
|
|
1231
|
+
```ruby
|
|
1232
|
+
def handle_unmodelled_field(field, value, business_account_id)
|
|
1233
|
+
FlowChat.logger.info {
|
|
1234
|
+
"CloudApi: Publishing webhook field '#{field}' (value keys: #{value.keys.join(", ")})"
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
payload = {
|
|
1238
|
+
platform: :whatsapp,
|
|
1239
|
+
gateway: :whatsapp_cloud_api,
|
|
1240
|
+
field: field,
|
|
1241
|
+
business_account_id: business_account_id,
|
|
1242
|
+
business_phone_number: value.dig("metadata", "display_phone_number"),
|
|
1243
|
+
business_phone_number_id: value.dig("metadata", "phone_number_id"),
|
|
1244
|
+
value: value
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
origin = echo_origin(field, value)
|
|
1248
|
+
payload[:echo_origin] = origin if origin
|
|
1249
|
+
|
|
1250
|
+
instrument(Events::WEBHOOK_RECEIVED, payload)
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
# An echo reports a message sent on the thread by someone other than the
|
|
1254
|
+
# person we are talking to. Which someone matters: a human replying from
|
|
1255
|
+
# the business inbox usually means the application should stop the flow,
|
|
1256
|
+
# while our own send coming back means nothing at all. Only the app_id
|
|
1257
|
+
# separates them, and only this gateway knows our own.
|
|
1258
|
+
def echo_origin(field, value)
|
|
1259
|
+
return nil unless field.to_s.include?("echo")
|
|
1260
|
+
|
|
1261
|
+
echoes = value.values.find { |v| v.is_a?(Array) && v.first.is_a?(Hash) }
|
|
1262
|
+
app_id = echoes&.first&.dig("app_id")
|
|
1263
|
+
|
|
1264
|
+
return :human_agent if app_id.blank?
|
|
1265
|
+
return :self if app_id.to_s == @config.app_id.to_s
|
|
1266
|
+
|
|
1267
|
+
:other_app
|
|
1268
|
+
end
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
- [ ] **Step 4: Run the tests**
|
|
1272
|
+
|
|
1273
|
+
Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb`
|
|
1274
|
+
Expected: PASS, 4 new tests green.
|
|
1275
|
+
|
|
1276
|
+
- [ ] **Step 5: Run the full suite**
|
|
1277
|
+
|
|
1278
|
+
Run: `bundle exec rake test`
|
|
1279
|
+
Expected: PASS, 0 failures.
|
|
1280
|
+
|
|
1281
|
+
- [ ] **Step 6: Commit**
|
|
1282
|
+
|
|
1283
|
+
```bash
|
|
1284
|
+
git add lib/flow_chat/whatsapp/gateway/cloud_api.rb test/unit/whatsapp/gateway/cloud_api_test.rb
|
|
1285
|
+
git commit -m "feat(whatsapp): say who sent an echo
|
|
1286
|
+
|
|
1287
|
+
An echo carrying no app_id is a human replying from the business inbox,
|
|
1288
|
+
which usually means the application wants the flow to stand down. One
|
|
1289
|
+
carrying our own app_id is just our send coming back. Only this gateway
|
|
1290
|
+
knows our app_id, so it derives the origin rather than leaving every
|
|
1291
|
+
subscriber to compare ids itself."
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
---
|
|
1295
|
+
|
|
1296
|
+
# Phase 2: Messenger
|
|
1297
|
+
|
|
1298
|
+
## Task 8: Messenger configuration
|
|
1299
|
+
|
|
1300
|
+
**Goal:** Credentials for Messenger, loadable from Rails credentials or environment, with the platform's limits as named constants.
|
|
1301
|
+
|
|
1302
|
+
**Files:**
|
|
1303
|
+
- Create: `lib/flow_chat/messenger/configuration.rb`
|
|
1304
|
+
- Create: `test/unit/messenger/configuration_test.rb`
|
|
1305
|
+
- Modify: `lib/flow_chat/config.rb` (add `Config.messenger`)
|
|
1306
|
+
|
|
1307
|
+
**Acceptance Criteria:**
|
|
1308
|
+
- [ ] `FlowChat::Messenger::Configuration` carries `page_id`, `access_token`, `verify_token`, `app_id`, `app_secret`, `skip_signature_validation`
|
|
1309
|
+
- [ ] `from_credentials` reads `messenger:` from Rails credentials, falling back to `MESSENGER_*` env vars
|
|
1310
|
+
- [ ] `valid?` is true only with `access_token`, `page_id` and `verify_token` all present
|
|
1311
|
+
- [ ] `messages_url` is `"#{api_base_url}/#{page_id}/messages"`
|
|
1312
|
+
- [ ] `FlowChat::Config.messenger` exposes `api_base_url`, `max_text_length`, `max_quick_replies`, `max_carousel_elements`, `max_buttons_per_element`
|
|
1313
|
+
|
|
1314
|
+
**Verify:** `ruby -Itest test/unit/messenger/configuration_test.rb` → PASS
|
|
1315
|
+
|
|
1316
|
+
**Steps:**
|
|
1317
|
+
|
|
1318
|
+
- [ ] **Step 1: Write the failing test**
|
|
1319
|
+
|
|
1320
|
+
Create `test/unit/messenger/configuration_test.rb`:
|
|
1321
|
+
|
|
1322
|
+
```ruby
|
|
1323
|
+
require "test_helper"
|
|
1324
|
+
|
|
1325
|
+
class MessengerConfigurationTest < Minitest::Test
|
|
1326
|
+
def teardown
|
|
1327
|
+
FlowChat::Messenger::Configuration.clear_all!
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
def test_valid_requires_token_page_and_verify_token
|
|
1331
|
+
config = FlowChat::Messenger::Configuration.new(nil)
|
|
1332
|
+
refute config.valid?
|
|
1333
|
+
|
|
1334
|
+
config.access_token = "tok"
|
|
1335
|
+
config.page_id = "page_1"
|
|
1336
|
+
refute config.valid?, "verify_token is still missing"
|
|
1337
|
+
|
|
1338
|
+
config.verify_token = "verify"
|
|
1339
|
+
assert config.valid?
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
def test_messages_url_uses_the_page_id
|
|
1343
|
+
config = FlowChat::Messenger::Configuration.new(nil)
|
|
1344
|
+
config.page_id = "page_1"
|
|
1345
|
+
|
|
1346
|
+
assert_equal "#{FlowChat::Config.messenger.api_base_url}/page_1/messages", config.messages_url
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
def test_from_credentials_reads_environment
|
|
1350
|
+
ENV["MESSENGER_ACCESS_TOKEN"] = "env_token"
|
|
1351
|
+
ENV["MESSENGER_PAGE_ID"] = "env_page"
|
|
1352
|
+
ENV["MESSENGER_VERIFY_TOKEN"] = "env_verify"
|
|
1353
|
+
ENV["MESSENGER_APP_SECRET"] = "env_secret"
|
|
1354
|
+
|
|
1355
|
+
config = FlowChat::Messenger::Configuration.from_credentials
|
|
1356
|
+
|
|
1357
|
+
assert_equal "env_token", config.access_token
|
|
1358
|
+
assert_equal "env_page", config.page_id
|
|
1359
|
+
assert_equal "env_secret", config.app_secret
|
|
1360
|
+
assert config.valid?
|
|
1361
|
+
ensure
|
|
1362
|
+
%w[MESSENGER_ACCESS_TOKEN MESSENGER_PAGE_ID MESSENGER_VERIFY_TOKEN MESSENGER_APP_SECRET].each { |k| ENV.delete(k) }
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
def test_registers_by_name
|
|
1366
|
+
config = FlowChat::Messenger::Configuration.new(:acme)
|
|
1367
|
+
|
|
1368
|
+
assert_same config, FlowChat::Messenger::Configuration.get(:acme)
|
|
1369
|
+
end
|
|
1370
|
+
end
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
1374
|
+
|
|
1375
|
+
Run: `ruby -Itest test/unit/messenger/configuration_test.rb`
|
|
1376
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger`.
|
|
1377
|
+
|
|
1378
|
+
- [ ] **Step 3: Add the global config object**
|
|
1379
|
+
|
|
1380
|
+
In `lib/flow_chat/config.rb`, add the accessor next to `self.whatsapp`:
|
|
1381
|
+
|
|
1382
|
+
```ruby
|
|
1383
|
+
# Messenger-specific configuration object
|
|
1384
|
+
def self.messenger
|
|
1385
|
+
@messenger ||= MessengerConfig.new
|
|
1386
|
+
end
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
And the class next to `WhatsappConfig`:
|
|
1390
|
+
|
|
1391
|
+
```ruby
|
|
1392
|
+
class MessengerConfig
|
|
1393
|
+
attr_reader :api_base_url, :max_text_length, :max_quick_replies,
|
|
1394
|
+
:max_carousel_elements, :max_buttons_per_element,
|
|
1395
|
+
:max_quick_reply_title, :max_button_title, :max_element_title
|
|
1396
|
+
|
|
1397
|
+
def initialize
|
|
1398
|
+
@api_base_url = "https://graph.facebook.com/v23.0"
|
|
1399
|
+
@max_text_length = 2000
|
|
1400
|
+
@max_quick_replies = 13
|
|
1401
|
+
@max_quick_reply_title = 20
|
|
1402
|
+
@max_carousel_elements = 10
|
|
1403
|
+
@max_buttons_per_element = 3
|
|
1404
|
+
@max_button_title = 20
|
|
1405
|
+
@max_element_title = 80
|
|
1406
|
+
end
|
|
1407
|
+
end
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
- [ ] **Step 4: Write the configuration class**
|
|
1411
|
+
|
|
1412
|
+
Create `lib/flow_chat/messenger/configuration.rb`:
|
|
1413
|
+
|
|
1414
|
+
```ruby
|
|
1415
|
+
module FlowChat
|
|
1416
|
+
module Messenger
|
|
1417
|
+
class ConfigurationError < StandardError; end
|
|
1418
|
+
|
|
1419
|
+
class Configuration
|
|
1420
|
+
include FlowChat::NamedConfiguration
|
|
1421
|
+
|
|
1422
|
+
attr_accessor :access_token, :page_id, :verify_token, :app_id, :app_secret,
|
|
1423
|
+
:name, :skip_signature_validation
|
|
1424
|
+
|
|
1425
|
+
def initialize(name)
|
|
1426
|
+
@name = name
|
|
1427
|
+
@skip_signature_validation = false
|
|
1428
|
+
|
|
1429
|
+
FlowChat.logger.debug { "Messenger::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
1430
|
+
|
|
1431
|
+
register_as(name) if name.present?
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
def self.from_credentials
|
|
1435
|
+
FlowChat.logger.info { "Messenger::Configuration: Loading configuration from credentials/environment" }
|
|
1436
|
+
|
|
1437
|
+
config = new(nil)
|
|
1438
|
+
|
|
1439
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.messenger
|
|
1440
|
+
credentials = Rails.application.credentials.messenger
|
|
1441
|
+
config.access_token = credentials[:access_token]
|
|
1442
|
+
config.page_id = credentials[:page_id]
|
|
1443
|
+
config.verify_token = credentials[:verify_token]
|
|
1444
|
+
config.app_id = credentials[:app_id]
|
|
1445
|
+
config.app_secret = credentials[:app_secret]
|
|
1446
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
1447
|
+
else
|
|
1448
|
+
config.access_token = ENV["MESSENGER_ACCESS_TOKEN"]
|
|
1449
|
+
config.page_id = ENV["MESSENGER_PAGE_ID"]
|
|
1450
|
+
config.verify_token = ENV["MESSENGER_VERIFY_TOKEN"]
|
|
1451
|
+
config.app_id = ENV["MESSENGER_APP_ID"]
|
|
1452
|
+
config.app_secret = ENV["MESSENGER_APP_SECRET"]
|
|
1453
|
+
config.skip_signature_validation = ENV["MESSENGER_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
config
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
def valid?
|
|
1460
|
+
access_token.present? && page_id.present? && verify_token.present?
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# The account this configuration speaks for. Named generically so the
|
|
1464
|
+
# shared gateway can check it without knowing which platform it holds.
|
|
1465
|
+
def account_id
|
|
1466
|
+
page_id
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1469
|
+
def messages_url
|
|
1470
|
+
"#{api_base_url}/#{page_id}/messages"
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
def attachment_upload_url
|
|
1474
|
+
"#{api_base_url}/#{page_id}/message_attachments"
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
def api_base_url
|
|
1478
|
+
FlowChat::Config.messenger.api_base_url
|
|
1479
|
+
end
|
|
1480
|
+
|
|
1481
|
+
def api_headers
|
|
1482
|
+
{
|
|
1483
|
+
"Authorization" => "Bearer #{access_token}",
|
|
1484
|
+
"Content-Type" => "application/json"
|
|
1485
|
+
}
|
|
1486
|
+
end
|
|
1487
|
+
end
|
|
1488
|
+
end
|
|
1489
|
+
end
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
- [ ] **Step 5: Run the test**
|
|
1493
|
+
|
|
1494
|
+
Run: `ruby -Itest test/unit/messenger/configuration_test.rb`
|
|
1495
|
+
Expected: PASS, 4 tests.
|
|
1496
|
+
|
|
1497
|
+
- [ ] **Step 6: Commit**
|
|
1498
|
+
|
|
1499
|
+
```bash
|
|
1500
|
+
git add lib/flow_chat/messenger/configuration.rb lib/flow_chat/config.rb test/unit/messenger/configuration_test.rb
|
|
1501
|
+
git commit -m "feat(messenger): configure the page a gateway speaks for"
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
---
|
|
1505
|
+
|
|
1506
|
+
## Task 9: The choice ladder helper
|
|
1507
|
+
|
|
1508
|
+
**Goal:** One place that decides which rung renders a given choice count, so the renderer and the choice mapper cannot disagree.
|
|
1509
|
+
|
|
1510
|
+
**Files:**
|
|
1511
|
+
- Create: `lib/flow_chat/meta/choice_ladder.rb`
|
|
1512
|
+
- Create: `test/unit/meta/choice_ladder_test.rb`
|
|
1513
|
+
|
|
1514
|
+
**Acceptance Criteria:**
|
|
1515
|
+
- [ ] `rung_for(count, limits)` returns `:quick_replies`, `:carousel` or `:numbered`
|
|
1516
|
+
- [ ] `count` of 0 returns `:none`
|
|
1517
|
+
- [ ] With Messenger limits: 13 is `:quick_replies`, 14 is `:carousel`, 30 is `:carousel`, 31 is `:numbered`
|
|
1518
|
+
- [ ] `numbers_in_body?` is true on the `:numbered` rung, and true on every rung when `always_number:` is set
|
|
1519
|
+
|
|
1520
|
+
**Verify:** `ruby -Itest test/unit/meta/choice_ladder_test.rb` → PASS
|
|
1521
|
+
|
|
1522
|
+
**Steps:**
|
|
1523
|
+
|
|
1524
|
+
- [ ] **Step 1: Write the failing test**
|
|
1525
|
+
|
|
1526
|
+
Create `test/unit/meta/choice_ladder_test.rb`:
|
|
1527
|
+
|
|
1528
|
+
```ruby
|
|
1529
|
+
require "test_helper"
|
|
1530
|
+
|
|
1531
|
+
class ChoiceLadderTest < Minitest::Test
|
|
1532
|
+
def setup
|
|
1533
|
+
@limits = FlowChat::Config.messenger
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
def test_no_choices
|
|
1537
|
+
assert_equal :none, FlowChat::Meta::ChoiceLadder.rung_for(0, @limits)
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
def test_quick_replies_up_to_thirteen
|
|
1541
|
+
assert_equal :quick_replies, FlowChat::Meta::ChoiceLadder.rung_for(1, @limits)
|
|
1542
|
+
assert_equal :quick_replies, FlowChat::Meta::ChoiceLadder.rung_for(13, @limits)
|
|
1543
|
+
end
|
|
1544
|
+
|
|
1545
|
+
def test_carousel_between_fourteen_and_thirty
|
|
1546
|
+
assert_equal :carousel, FlowChat::Meta::ChoiceLadder.rung_for(14, @limits)
|
|
1547
|
+
assert_equal :carousel, FlowChat::Meta::ChoiceLadder.rung_for(30, @limits)
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
def test_numbered_above_the_carousel_capacity
|
|
1551
|
+
assert_equal :numbered, FlowChat::Meta::ChoiceLadder.rung_for(31, @limits)
|
|
1552
|
+
assert_equal :numbered, FlowChat::Meta::ChoiceLadder.rung_for(200, @limits)
|
|
1553
|
+
end
|
|
1554
|
+
|
|
1555
|
+
def test_numbers_in_body_on_the_numbered_rung
|
|
1556
|
+
assert FlowChat::Meta::ChoiceLadder.numbers_in_body?(31, @limits)
|
|
1557
|
+
refute FlowChat::Meta::ChoiceLadder.numbers_in_body?(5, @limits)
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
# Instagram renders quick replies and carousels on mobile only, so its
|
|
1561
|
+
# renderer numbers the body at every rung.
|
|
1562
|
+
def test_always_number_covers_every_rung
|
|
1563
|
+
assert FlowChat::Meta::ChoiceLadder.numbers_in_body?(5, @limits, always_number: true)
|
|
1564
|
+
assert FlowChat::Meta::ChoiceLadder.numbers_in_body?(20, @limits, always_number: true)
|
|
1565
|
+
refute FlowChat::Meta::ChoiceLadder.numbers_in_body?(0, @limits, always_number: true)
|
|
1566
|
+
end
|
|
1567
|
+
end
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
1571
|
+
|
|
1572
|
+
Run: `ruby -Itest test/unit/meta/choice_ladder_test.rb`
|
|
1573
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Meta::ChoiceLadder`.
|
|
1574
|
+
|
|
1575
|
+
- [ ] **Step 3: Implement it**
|
|
1576
|
+
|
|
1577
|
+
Create `lib/flow_chat/meta/choice_ladder.rb`:
|
|
1578
|
+
|
|
1579
|
+
```ruby
|
|
1580
|
+
module FlowChat
|
|
1581
|
+
module Meta
|
|
1582
|
+
# Which interactive surface renders a given number of choices.
|
|
1583
|
+
#
|
|
1584
|
+
# The renderer and the choice mapper both need this answer, and they must
|
|
1585
|
+
# agree: the renderer decides what the user sees, the mapper decides what a
|
|
1586
|
+
# reply is allowed to mean. Two copies of the arithmetic would drift into a
|
|
1587
|
+
# screen whose replies cannot be resolved.
|
|
1588
|
+
module ChoiceLadder
|
|
1589
|
+
def self.rung_for(count, limits)
|
|
1590
|
+
return :none if count.zero?
|
|
1591
|
+
return :quick_replies if count <= limits.max_quick_replies
|
|
1592
|
+
return :carousel if count <= carousel_capacity(limits)
|
|
1593
|
+
|
|
1594
|
+
:numbered
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
# The carousel holds elements, each holding buttons, and one option is one
|
|
1598
|
+
# button.
|
|
1599
|
+
def self.carousel_capacity(limits)
|
|
1600
|
+
limits.max_carousel_elements * limits.max_buttons_per_element
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
# Whether the options are also listed, numbered, in the message body.
|
|
1604
|
+
#
|
|
1605
|
+
# always_number is for platforms whose interactive surfaces do not render
|
|
1606
|
+
# everywhere. Without it a user who cannot see the buttons has no way to
|
|
1607
|
+
# answer at all.
|
|
1608
|
+
def self.numbers_in_body?(count, limits, always_number: false)
|
|
1609
|
+
return false if count.zero?
|
|
1610
|
+
return true if always_number
|
|
1611
|
+
|
|
1612
|
+
rung_for(count, limits) == :numbered
|
|
1613
|
+
end
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
end
|
|
1617
|
+
```
|
|
1618
|
+
|
|
1619
|
+
- [ ] **Step 4: Run the test**
|
|
1620
|
+
|
|
1621
|
+
Run: `ruby -Itest test/unit/meta/choice_ladder_test.rb`
|
|
1622
|
+
Expected: PASS, 6 tests.
|
|
1623
|
+
|
|
1624
|
+
- [ ] **Step 5: Commit**
|
|
1625
|
+
|
|
1626
|
+
```bash
|
|
1627
|
+
git add lib/flow_chat/meta/choice_ladder.rb test/unit/meta/choice_ladder_test.rb
|
|
1628
|
+
git commit -m "feat(meta): decide the choice rung in one place"
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
---
|
|
1632
|
+
|
|
1633
|
+
## Task 10: Messenger renderer
|
|
1634
|
+
|
|
1635
|
+
**Goal:** Turn `[prompt, choices, media]` into a Send API payload shape, down the ladder, in plain text.
|
|
1636
|
+
|
|
1637
|
+
**Files:**
|
|
1638
|
+
- Create: `lib/flow_chat/messenger/renderer.rb`
|
|
1639
|
+
- Create: `test/unit/messenger/renderer_test.rb`
|
|
1640
|
+
|
|
1641
|
+
**Acceptance Criteria:**
|
|
1642
|
+
- [ ] No choices renders `[:text, plain_text, {}]`
|
|
1643
|
+
- [ ] 1 to 13 choices render `[:quick_replies, text, {quick_replies: [{content_type:, title:, payload:}]}]` with titles truncated to 20
|
|
1644
|
+
- [ ] 14 to 30 choices render `[:carousel, text, {elements: [...]}]` with at most 10 elements of at most 3 `postback` buttons
|
|
1645
|
+
- [ ] Above 30 choices renders `[:text, text_with_numbered_options, {}]`
|
|
1646
|
+
- [ ] Markdown in the prompt is flattened: `**bold**` arrives as `bold`
|
|
1647
|
+
- [ ] Media with no choices renders `[:attachment, caption, {type:, url:}]`
|
|
1648
|
+
|
|
1649
|
+
**Verify:** `ruby -Itest test/unit/messenger/renderer_test.rb` → PASS
|
|
1650
|
+
|
|
1651
|
+
**Steps:**
|
|
1652
|
+
|
|
1653
|
+
- [ ] **Step 1: Write the failing test**
|
|
1654
|
+
|
|
1655
|
+
Create `test/unit/messenger/renderer_test.rb`:
|
|
1656
|
+
|
|
1657
|
+
```ruby
|
|
1658
|
+
require "test_helper"
|
|
1659
|
+
|
|
1660
|
+
class MessengerRendererTest < Minitest::Test
|
|
1661
|
+
def render(message, choices: nil, media: nil)
|
|
1662
|
+
FlowChat::Messenger::Renderer.new(message, choices: choices, media: media).render
|
|
1663
|
+
end
|
|
1664
|
+
|
|
1665
|
+
def test_plain_text_message
|
|
1666
|
+
result = render("Hello **world**")
|
|
1667
|
+
|
|
1668
|
+
assert_equal :text, result[0]
|
|
1669
|
+
assert_equal "Hello world", result[1]
|
|
1670
|
+
assert_equal({}, result[2])
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
def test_quick_replies_for_thirteen_or_fewer
|
|
1674
|
+
choices = (1..13).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
1675
|
+
|
|
1676
|
+
result = render("Pick one", choices: choices)
|
|
1677
|
+
|
|
1678
|
+
assert_equal :quick_replies, result[0]
|
|
1679
|
+
assert_equal 13, result[2][:quick_replies].length
|
|
1680
|
+
assert_equal "text", result[2][:quick_replies][0][:content_type]
|
|
1681
|
+
assert_equal "Option 1", result[2][:quick_replies][0][:title]
|
|
1682
|
+
assert_equal "k1", result[2][:quick_replies][0][:payload]
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
def test_quick_reply_titles_truncate_at_twenty
|
|
1686
|
+
result = render("Pick", choices: {"k" => "A title that is definitely longer than twenty"})
|
|
1687
|
+
|
|
1688
|
+
assert_equal 20, result[2][:quick_replies][0][:title].length
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
def test_carousel_between_fourteen_and_thirty
|
|
1692
|
+
choices = (1..14).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
1693
|
+
|
|
1694
|
+
result = render("Pick one", choices: choices)
|
|
1695
|
+
|
|
1696
|
+
assert_equal :carousel, result[0]
|
|
1697
|
+
assert_equal 5, result[2][:elements].length
|
|
1698
|
+
assert_equal 3, result[2][:elements][0][:buttons].length
|
|
1699
|
+
assert_equal "postback", result[2][:elements][0][:buttons][0][:type]
|
|
1700
|
+
assert_equal "k1", result[2][:elements][0][:buttons][0][:payload]
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
def test_carousel_never_exceeds_ten_elements
|
|
1704
|
+
choices = (1..30).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
1705
|
+
|
|
1706
|
+
result = render("Pick one", choices: choices)
|
|
1707
|
+
|
|
1708
|
+
assert_equal :carousel, result[0]
|
|
1709
|
+
assert_equal 10, result[2][:elements].length
|
|
1710
|
+
assert_equal 30, result[2][:elements].sum { |e| e[:buttons].length }
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
def test_numbered_body_above_thirty
|
|
1714
|
+
choices = (1..31).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
1715
|
+
|
|
1716
|
+
result = render("Pick one", choices: choices)
|
|
1717
|
+
|
|
1718
|
+
assert_equal :text, result[0]
|
|
1719
|
+
assert_includes result[1], "1. Option 1"
|
|
1720
|
+
assert_includes result[1], "31. Option 31"
|
|
1721
|
+
end
|
|
1722
|
+
|
|
1723
|
+
def test_attachment_without_choices
|
|
1724
|
+
result = render("A caption", media: {type: :image, url: "https://example.com/a.png"})
|
|
1725
|
+
|
|
1726
|
+
assert_equal :attachment, result[0]
|
|
1727
|
+
assert_equal "A caption", result[1]
|
|
1728
|
+
assert_equal :image, result[2][:type]
|
|
1729
|
+
assert_equal "https://example.com/a.png", result[2][:url]
|
|
1730
|
+
end
|
|
1731
|
+
end
|
|
1732
|
+
```
|
|
1733
|
+
|
|
1734
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
1735
|
+
|
|
1736
|
+
Run: `ruby -Itest test/unit/messenger/renderer_test.rb`
|
|
1737
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger::Renderer`.
|
|
1738
|
+
|
|
1739
|
+
- [ ] **Step 3: Implement the renderer**
|
|
1740
|
+
|
|
1741
|
+
Create `lib/flow_chat/messenger/renderer.rb`:
|
|
1742
|
+
|
|
1743
|
+
```ruby
|
|
1744
|
+
require "flow_chat/renderers/markdown_support"
|
|
1745
|
+
|
|
1746
|
+
module FlowChat
|
|
1747
|
+
module Messenger
|
|
1748
|
+
class Renderer
|
|
1749
|
+
include FlowChat::Renderers::MarkdownSupport
|
|
1750
|
+
|
|
1751
|
+
attr_reader :message, :choices, :media
|
|
1752
|
+
|
|
1753
|
+
def initialize(message, choices: nil, media: nil)
|
|
1754
|
+
@message = message
|
|
1755
|
+
@choices = choices
|
|
1756
|
+
@media = media
|
|
1757
|
+
end
|
|
1758
|
+
|
|
1759
|
+
def render
|
|
1760
|
+
return build_attachment if media && choices.blank?
|
|
1761
|
+
|
|
1762
|
+
case FlowChat::Meta::ChoiceLadder.rung_for(choice_count, limits)
|
|
1763
|
+
when :none then build_text
|
|
1764
|
+
when :quick_replies then build_quick_replies
|
|
1765
|
+
when :carousel then build_carousel
|
|
1766
|
+
when :numbered then build_numbered
|
|
1767
|
+
end
|
|
1768
|
+
end
|
|
1769
|
+
|
|
1770
|
+
private
|
|
1771
|
+
|
|
1772
|
+
def limits
|
|
1773
|
+
FlowChat::Config.messenger
|
|
1774
|
+
end
|
|
1775
|
+
|
|
1776
|
+
def always_number?
|
|
1777
|
+
false
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1780
|
+
def choice_count
|
|
1781
|
+
choices.is_a?(Hash) ? choices.length : 0
|
|
1782
|
+
end
|
|
1783
|
+
|
|
1784
|
+
# Neither Messenger nor Instagram renders markup, so the prompt is
|
|
1785
|
+
# flattened rather than translated.
|
|
1786
|
+
def body
|
|
1787
|
+
text = to_plain_text(message)
|
|
1788
|
+
return text unless FlowChat::Meta::ChoiceLadder.numbers_in_body?(choice_count, limits, always_number: always_number?)
|
|
1789
|
+
|
|
1790
|
+
"#{text}\n\n#{numbered_options}"
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1793
|
+
def numbered_options
|
|
1794
|
+
choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
|
|
1795
|
+
end
|
|
1796
|
+
|
|
1797
|
+
def build_text
|
|
1798
|
+
[:text, body, {}]
|
|
1799
|
+
end
|
|
1800
|
+
|
|
1801
|
+
def build_numbered
|
|
1802
|
+
[:text, body, {}]
|
|
1803
|
+
end
|
|
1804
|
+
|
|
1805
|
+
def build_quick_replies
|
|
1806
|
+
replies = choices.map do |key, label|
|
|
1807
|
+
{
|
|
1808
|
+
content_type: "text",
|
|
1809
|
+
title: truncate_text(label.to_s, limits.max_quick_reply_title),
|
|
1810
|
+
payload: key.to_s
|
|
1811
|
+
}
|
|
1812
|
+
end
|
|
1813
|
+
|
|
1814
|
+
[:quick_replies, body, {quick_replies: replies}]
|
|
1815
|
+
end
|
|
1816
|
+
|
|
1817
|
+
# One option is one button, and buttons live on elements, so the options
|
|
1818
|
+
# are packed across elements rather than one element per option.
|
|
1819
|
+
def build_carousel
|
|
1820
|
+
elements = choices.each_slice(limits.max_buttons_per_element).map.with_index(1) do |slice, index|
|
|
1821
|
+
first = (index - 1) * limits.max_buttons_per_element + 1
|
|
1822
|
+
last = first + slice.length - 1
|
|
1823
|
+
|
|
1824
|
+
{
|
|
1825
|
+
title: truncate_text("Options #{first} to #{last}", limits.max_element_title),
|
|
1826
|
+
buttons: slice.map do |key, label|
|
|
1827
|
+
{
|
|
1828
|
+
type: "postback",
|
|
1829
|
+
title: truncate_text(label.to_s, limits.max_button_title),
|
|
1830
|
+
payload: key.to_s
|
|
1831
|
+
}
|
|
1832
|
+
end
|
|
1833
|
+
}
|
|
1834
|
+
end
|
|
1835
|
+
|
|
1836
|
+
[:carousel, body, {elements: elements}]
|
|
1837
|
+
end
|
|
1838
|
+
|
|
1839
|
+
def build_attachment
|
|
1840
|
+
type = (media[:type] || :image).to_sym
|
|
1841
|
+
options = {type: type}
|
|
1842
|
+
options[:url] = media[:url] if media[:url]
|
|
1843
|
+
options[:attachment_id] = media[:id] if media[:id]
|
|
1844
|
+
|
|
1845
|
+
[:attachment, to_plain_text(message), options]
|
|
1846
|
+
end
|
|
1847
|
+
|
|
1848
|
+
def truncate_text(text, length)
|
|
1849
|
+
return text if text.length <= length
|
|
1850
|
+
text[0, length - 3] + "..."
|
|
1851
|
+
end
|
|
1852
|
+
end
|
|
1853
|
+
end
|
|
1854
|
+
end
|
|
1855
|
+
```
|
|
1856
|
+
|
|
1857
|
+
- [ ] **Step 4: Run the test**
|
|
1858
|
+
|
|
1859
|
+
Run: `ruby -Itest test/unit/messenger/renderer_test.rb`
|
|
1860
|
+
Expected: PASS, 8 tests.
|
|
1861
|
+
|
|
1862
|
+
- [ ] **Step 5: Commit**
|
|
1863
|
+
|
|
1864
|
+
```bash
|
|
1865
|
+
git add lib/flow_chat/messenger/renderer.rb test/unit/messenger/renderer_test.rb
|
|
1866
|
+
git commit -m "feat(messenger): render prompts down the choice ladder"
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
---
|
|
1870
|
+
|
|
1871
|
+
## Task 11: Messenger client
|
|
1872
|
+
|
|
1873
|
+
**Goal:** Send to the Send API, splitting text that exceeds the platform cap, and report failures through the existing delivery hooks.
|
|
1874
|
+
|
|
1875
|
+
**Files:**
|
|
1876
|
+
- Create: `lib/flow_chat/messenger/client.rb`
|
|
1877
|
+
- Create: `test/unit/messenger/client_test.rb`
|
|
1878
|
+
|
|
1879
|
+
**Acceptance Criteria:**
|
|
1880
|
+
- [ ] `send_message(recipient_id, prompt, choices:, media:)` posts to `config.messages_url`
|
|
1881
|
+
- [ ] The payload is `{recipient: {id:}, messaging_type: "RESPONSE", message: {...}}`
|
|
1882
|
+
- [ ] Quick replies attach to the text message; a carousel posts an `attachment` with `template_type: "generic"`
|
|
1883
|
+
- [ ] Text longer than `max_text_length` is sent as several messages, split on whitespace, and the last result is returned
|
|
1884
|
+
- [ ] A non-2xx response reports `API_ERROR` and returns `nil`
|
|
1885
|
+
- [ ] `upload_media` posts to `config.attachment_upload_url` and returns the `attachment_id`
|
|
1886
|
+
|
|
1887
|
+
**Verify:** `ruby -Itest test/unit/messenger/client_test.rb` → PASS
|
|
1888
|
+
|
|
1889
|
+
**Steps:**
|
|
1890
|
+
|
|
1891
|
+
- [ ] **Step 1: Write the failing test**
|
|
1892
|
+
|
|
1893
|
+
Create `test/unit/messenger/client_test.rb`:
|
|
1894
|
+
|
|
1895
|
+
```ruby
|
|
1896
|
+
require "test_helper"
|
|
1897
|
+
require "webmock/minitest"
|
|
1898
|
+
|
|
1899
|
+
class MessengerClientTest < Minitest::Test
|
|
1900
|
+
def setup
|
|
1901
|
+
@config = FlowChat::Messenger::Configuration.new(nil)
|
|
1902
|
+
@config.page_id = "page_1"
|
|
1903
|
+
@config.access_token = "tok"
|
|
1904
|
+
@config.verify_token = "verify"
|
|
1905
|
+
@client = FlowChat::Messenger::Client.new(@config)
|
|
1906
|
+
|
|
1907
|
+
WebMock.enable!
|
|
1908
|
+
WebMock.reset!
|
|
1909
|
+
stub_request(:post, @config.messages_url)
|
|
1910
|
+
.to_return(status: 200, body: {"recipient_id" => "psid_1", "message_id" => "mid.1"}.to_json)
|
|
1911
|
+
end
|
|
1912
|
+
|
|
1913
|
+
def teardown
|
|
1914
|
+
WebMock.disable!
|
|
1915
|
+
WebMock.reset!
|
|
1916
|
+
end
|
|
1917
|
+
|
|
1918
|
+
def test_sends_text_with_the_send_api_shape
|
|
1919
|
+
result = @client.send_message("psid_1", "Hello")
|
|
1920
|
+
|
|
1921
|
+
assert_equal "mid.1", result["message_id"]
|
|
1922
|
+
assert_requested(:post, @config.messages_url) do |req|
|
|
1923
|
+
body = JSON.parse(req.body)
|
|
1924
|
+
body["recipient"] == {"id" => "psid_1"} &&
|
|
1925
|
+
body["messaging_type"] == "RESPONSE" &&
|
|
1926
|
+
body["message"] == {"text" => "Hello"}
|
|
1927
|
+
end
|
|
1928
|
+
end
|
|
1929
|
+
|
|
1930
|
+
def test_quick_replies_ride_on_the_text_message
|
|
1931
|
+
@client.send_message("psid_1", "Pick", choices: {"a" => "Alpha", "b" => "Beta"})
|
|
1932
|
+
|
|
1933
|
+
assert_requested(:post, @config.messages_url) do |req|
|
|
1934
|
+
message = JSON.parse(req.body)["message"]
|
|
1935
|
+
message["text"] == "Pick" && message["quick_replies"].length == 2
|
|
1936
|
+
end
|
|
1937
|
+
end
|
|
1938
|
+
|
|
1939
|
+
def test_carousel_posts_a_generic_template
|
|
1940
|
+
choices = (1..14).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
1941
|
+
|
|
1942
|
+
@client.send_message("psid_1", "Pick", choices: choices)
|
|
1943
|
+
|
|
1944
|
+
assert_requested(:post, @config.messages_url) do |req|
|
|
1945
|
+
payload = JSON.parse(req.body).dig("message", "attachment", "payload")
|
|
1946
|
+
payload["template_type"] == "generic" && payload["elements"].length == 5
|
|
1947
|
+
end
|
|
1948
|
+
end
|
|
1949
|
+
|
|
1950
|
+
def test_long_text_is_split_into_several_sends
|
|
1951
|
+
long = "word " * 600 # comfortably over 2000 characters
|
|
1952
|
+
|
|
1953
|
+
@client.send_message("psid_1", long)
|
|
1954
|
+
|
|
1955
|
+
assert_requested(:post, @config.messages_url, times: 2)
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
def test_failed_request_returns_nil
|
|
1959
|
+
WebMock.reset!
|
|
1960
|
+
stub_request(:post, @config.messages_url).to_return(status: 400, body: '{"error":{"message":"bad"}}')
|
|
1961
|
+
|
|
1962
|
+
assert_nil @client.send_message("psid_1", "Hello")
|
|
1963
|
+
end
|
|
1964
|
+
end
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
1968
|
+
|
|
1969
|
+
Run: `ruby -Itest test/unit/messenger/client_test.rb`
|
|
1970
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger::Client`.
|
|
1971
|
+
|
|
1972
|
+
- [ ] **Step 3: Implement the client**
|
|
1973
|
+
|
|
1974
|
+
Create `lib/flow_chat/messenger/client.rb`:
|
|
1975
|
+
|
|
1976
|
+
```ruby
|
|
1977
|
+
require "net/http"
|
|
1978
|
+
require "json"
|
|
1979
|
+
require "uri"
|
|
1980
|
+
|
|
1981
|
+
module FlowChat
|
|
1982
|
+
module Messenger
|
|
1983
|
+
class Client
|
|
1984
|
+
include FlowChat::Instrumentation
|
|
1985
|
+
|
|
1986
|
+
def initialize(config)
|
|
1987
|
+
@config = config
|
|
1988
|
+
FlowChat.logger.info { "Messenger::Client: Initialized for page_id: #{@config.page_id}" }
|
|
1989
|
+
end
|
|
1990
|
+
|
|
1991
|
+
def send_message(recipient_id, prompt, choices: nil, media: nil)
|
|
1992
|
+
response = renderer_class.new(prompt, choices: choices, media: media).render
|
|
1993
|
+
type, content, options = response
|
|
1994
|
+
|
|
1995
|
+
instrument(Events::MESSAGE_SENT, {
|
|
1996
|
+
to: recipient_id,
|
|
1997
|
+
message_type: type.to_s,
|
|
1998
|
+
content_length: content.to_s.length,
|
|
1999
|
+
platform: platform
|
|
2000
|
+
}) do
|
|
2001
|
+
deliver(recipient_id, type, content, options)
|
|
2002
|
+
end
|
|
2003
|
+
end
|
|
2004
|
+
|
|
2005
|
+
def send_text(recipient_id, text)
|
|
2006
|
+
send_message(recipient_id, text)
|
|
2007
|
+
end
|
|
2008
|
+
|
|
2009
|
+
# Uploads a file for reuse and returns the id Meta assigned it.
|
|
2010
|
+
def upload_media(url, type: :image)
|
|
2011
|
+
payload = {
|
|
2012
|
+
message: {
|
|
2013
|
+
attachment: {
|
|
2014
|
+
type: type.to_s,
|
|
2015
|
+
payload: {url: url, is_reusable: true}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
result = post_json(@config.attachment_upload_url, payload)
|
|
2021
|
+
result && result["attachment_id"]
|
|
2022
|
+
end
|
|
2023
|
+
|
|
2024
|
+
private
|
|
2025
|
+
|
|
2026
|
+
def renderer_class
|
|
2027
|
+
FlowChat::Messenger::Renderer
|
|
2028
|
+
end
|
|
2029
|
+
|
|
2030
|
+
def platform
|
|
2031
|
+
:messenger
|
|
2032
|
+
end
|
|
2033
|
+
|
|
2034
|
+
def limits
|
|
2035
|
+
FlowChat::Config.messenger
|
|
2036
|
+
end
|
|
2037
|
+
|
|
2038
|
+
# Anything over the platform's cap is rejected whole rather than trimmed by
|
|
2039
|
+
# Meta, so long text goes as several messages. Only the last result is
|
|
2040
|
+
# returned: it carries the id of the message the user ends up looking at.
|
|
2041
|
+
def deliver(recipient_id, type, content, options)
|
|
2042
|
+
case type
|
|
2043
|
+
when :text
|
|
2044
|
+
split_text(content).map { |chunk| post_message(recipient_id, {text: chunk}) }.last
|
|
2045
|
+
when :quick_replies
|
|
2046
|
+
chunks = split_text(content)
|
|
2047
|
+
# Quick replies belong on the final chunk, next to the question.
|
|
2048
|
+
chunks[0..-2].each { |chunk| post_message(recipient_id, {text: chunk}) }
|
|
2049
|
+
post_message(recipient_id, {text: chunks.last, quick_replies: options[:quick_replies]})
|
|
2050
|
+
when :carousel
|
|
2051
|
+
post_message(recipient_id, {text: content}) if content.present?
|
|
2052
|
+
post_message(recipient_id, {
|
|
2053
|
+
attachment: {
|
|
2054
|
+
type: "template",
|
|
2055
|
+
payload: {template_type: "generic", elements: options[:elements]}
|
|
2056
|
+
}
|
|
2057
|
+
})
|
|
2058
|
+
when :attachment
|
|
2059
|
+
attachment_payload = options[:url] ? {url: options[:url], is_reusable: true} : {attachment_id: options[:attachment_id]}
|
|
2060
|
+
post_message(recipient_id, {text: content}) if content.present?
|
|
2061
|
+
post_message(recipient_id, {
|
|
2062
|
+
attachment: {type: options[:type].to_s, payload: attachment_payload}
|
|
2063
|
+
})
|
|
2064
|
+
end
|
|
2065
|
+
end
|
|
2066
|
+
|
|
2067
|
+
def post_message(recipient_id, message)
|
|
2068
|
+
post_json(@config.messages_url, {
|
|
2069
|
+
recipient: {id: recipient_id},
|
|
2070
|
+
messaging_type: "RESPONSE",
|
|
2071
|
+
message: message
|
|
2072
|
+
})
|
|
2073
|
+
end
|
|
2074
|
+
|
|
2075
|
+
# Splits on whitespace so a word is never cut in half. Measured with the
|
|
2076
|
+
# platform's own unit, which is bytes on Instagram and characters here.
|
|
2077
|
+
def split_text(text)
|
|
2078
|
+
limit = limits.max_text_length
|
|
2079
|
+
return [text.to_s] if measure(text.to_s) <= limit
|
|
2080
|
+
|
|
2081
|
+
chunks = []
|
|
2082
|
+
current = ""
|
|
2083
|
+
|
|
2084
|
+
text.to_s.split(/(\s+)/).each do |piece|
|
|
2085
|
+
if measure(current + piece) > limit && current.present?
|
|
2086
|
+
chunks << current.strip
|
|
2087
|
+
current = piece.lstrip
|
|
2088
|
+
else
|
|
2089
|
+
current += piece
|
|
2090
|
+
end
|
|
2091
|
+
end
|
|
2092
|
+
|
|
2093
|
+
chunks << current.strip if current.strip.present?
|
|
2094
|
+
chunks
|
|
2095
|
+
end
|
|
2096
|
+
|
|
2097
|
+
def measure(string)
|
|
2098
|
+
string.length
|
|
2099
|
+
end
|
|
2100
|
+
|
|
2101
|
+
def post_json(url, payload)
|
|
2102
|
+
uri = URI(url)
|
|
2103
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
2104
|
+
http.use_ssl = true
|
|
2105
|
+
|
|
2106
|
+
request = Net::HTTP::Post.new(uri)
|
|
2107
|
+
@config.api_headers.each { |key, value| request[key] = value }
|
|
2108
|
+
request.body = payload.to_json
|
|
2109
|
+
|
|
2110
|
+
response = http.request(request)
|
|
2111
|
+
|
|
2112
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
2113
|
+
JSON.parse(response.body)
|
|
2114
|
+
else
|
|
2115
|
+
FlowChat.logger.error { "#{self.class.name}: API request failed - #{response.code}: #{response.body}" }
|
|
2116
|
+
report_api_error(
|
|
2117
|
+
"#{platform} API request failed",
|
|
2118
|
+
response_code: response.code,
|
|
2119
|
+
response_body: response.body
|
|
2120
|
+
)
|
|
2121
|
+
nil
|
|
2122
|
+
end
|
|
2123
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
2124
|
+
FlowChat.logger.error { "#{self.class.name}: Network timeout: #{network_error.class.name}" }
|
|
2125
|
+
raise network_error
|
|
2126
|
+
end
|
|
2127
|
+
end
|
|
2128
|
+
end
|
|
2129
|
+
end
|
|
2130
|
+
```
|
|
2131
|
+
|
|
2132
|
+
Check `report_api_error`'s exact signature in `lib/flow_chat/instrumentation.rb` before wiring it, and match the keyword arguments the WhatsApp client passes at `whatsapp/client.rb:540`.
|
|
2133
|
+
|
|
2134
|
+
- [ ] **Step 4: Run the test**
|
|
2135
|
+
|
|
2136
|
+
Run: `ruby -Itest test/unit/messenger/client_test.rb`
|
|
2137
|
+
Expected: PASS, 5 tests.
|
|
2138
|
+
|
|
2139
|
+
- [ ] **Step 5: Commit**
|
|
2140
|
+
|
|
2141
|
+
```bash
|
|
2142
|
+
git add lib/flow_chat/messenger/client.rb test/unit/messenger/client_test.rb
|
|
2143
|
+
git commit -m "feat(messenger): send through the Send API"
|
|
2144
|
+
```
|
|
2145
|
+
|
|
2146
|
+
---
|
|
2147
|
+
|
|
2148
|
+
## Task 12: The shared messaging gateway
|
|
2149
|
+
|
|
2150
|
+
**Goal:** The `entry[].messaging[]` envelope, dispatch, echo classification, delivery receipts, and context population, implemented once for both platforms.
|
|
2151
|
+
|
|
2152
|
+
**Files:**
|
|
2153
|
+
- Create: `lib/flow_chat/meta/messaging_gateway.rb`
|
|
2154
|
+
- Create: `test/unit/meta/messaging_gateway_test.rb`
|
|
2155
|
+
- Modify: `lib/flow_chat/session/middleware.rb:93-102`
|
|
2156
|
+
|
|
2157
|
+
**Acceptance Criteria:**
|
|
2158
|
+
- [ ] A text message sets `request.id`, `request.user_id`, `request.message_id`, `request.platform`, `request.gateway`, `context.input`
|
|
2159
|
+
- [ ] `request.msisdn` is `nil`
|
|
2160
|
+
- [ ] A `quick_reply` payload becomes `context.input`
|
|
2161
|
+
- [ ] A `postback` payload becomes `context.input` and drives the flow
|
|
2162
|
+
- [ ] An echo never drives the flow and is published with `echo_origin`
|
|
2163
|
+
- [ ] `delivery` and `read` publish `MESSAGE_STATUS` and are handled before the flow slot is claimed
|
|
2164
|
+
- [ ] Only one event per delivery drives a flow, and a second logs a warning
|
|
2165
|
+
- [ ] An event for an account other than the configured one is rejected with `:forbidden`
|
|
2166
|
+
- [ ] `platform_default_identifier` returns `:user_id` for `:messenger` and `:instagram`
|
|
2167
|
+
|
|
2168
|
+
**Verify:** `ruby -Itest test/unit/meta/messaging_gateway_test.rb` → PASS
|
|
2169
|
+
|
|
2170
|
+
**Steps:**
|
|
2171
|
+
|
|
2172
|
+
- [ ] **Step 1: Write the failing test**
|
|
2173
|
+
|
|
2174
|
+
Create `test/unit/meta/messaging_gateway_test.rb`. The test defines its own concrete subclass so the base class is exercised without depending on Task 13:
|
|
2175
|
+
|
|
2176
|
+
```ruby
|
|
2177
|
+
require "test_helper"
|
|
2178
|
+
|
|
2179
|
+
class MessagingGatewayTest < Minitest::Test
|
|
2180
|
+
# The base class is abstract. This exercises it directly rather than through
|
|
2181
|
+
# a platform, so a failure here is unambiguously the envelope's fault.
|
|
2182
|
+
class TestGateway < FlowChat::Meta::MessagingGateway
|
|
2183
|
+
def platform = :messenger
|
|
2184
|
+
def gateway_name = :messenger_send_api
|
|
2185
|
+
def configuration_class = FlowChat::Messenger::Configuration
|
|
2186
|
+
def client_class = FlowChat::Messenger::Client
|
|
2187
|
+
def renderer_class = FlowChat::Messenger::Renderer
|
|
2188
|
+
def self.choice_mapper_class = FlowChat::Messenger::Middleware::ChoiceMapper
|
|
2189
|
+
end
|
|
2190
|
+
|
|
2191
|
+
def setup
|
|
2192
|
+
@config = FlowChat::Messenger::Configuration.new(nil)
|
|
2193
|
+
@config.page_id = "page_1"
|
|
2194
|
+
@config.access_token = "tok"
|
|
2195
|
+
@config.verify_token = "verify"
|
|
2196
|
+
@config.app_id = "our_app"
|
|
2197
|
+
@config.skip_signature_validation = true
|
|
2198
|
+
|
|
2199
|
+
@app = proc { |context| [:text, "Response", nil, nil] }
|
|
2200
|
+
@gateway = TestGateway.new(@app, @config)
|
|
2201
|
+
@sent = []
|
|
2202
|
+
@gateway.client.define_singleton_method(:send_message) do |*args, **kwargs|
|
|
2203
|
+
{"message_id" => "mid.sent"}
|
|
2204
|
+
end
|
|
2205
|
+
end
|
|
2206
|
+
|
|
2207
|
+
def test_text_message_populates_context
|
|
2208
|
+
context = post(messaging_payload({"message" => {"mid" => "mid.1", "text" => "Hello"}}))
|
|
2209
|
+
|
|
2210
|
+
assert_equal "Hello", context.input
|
|
2211
|
+
assert_equal "psid_1", context["request.user_id"]
|
|
2212
|
+
assert_equal "psid_1", context["request.id"]
|
|
2213
|
+
assert_equal "mid.1", context["request.message_id"]
|
|
2214
|
+
assert_equal :messenger, context["request.platform"]
|
|
2215
|
+
assert_equal :messenger_send_api, context["request.gateway"]
|
|
2216
|
+
assert_nil context["request.msisdn"]
|
|
2217
|
+
end
|
|
2218
|
+
|
|
2219
|
+
def test_quick_reply_payload_is_the_input
|
|
2220
|
+
context = post(messaging_payload({
|
|
2221
|
+
"message" => {"mid" => "mid.2", "text" => "Alpha", "quick_reply" => {"payload" => "choice_a"}}
|
|
2222
|
+
}))
|
|
2223
|
+
|
|
2224
|
+
assert_equal "choice_a", context.input
|
|
2225
|
+
end
|
|
2226
|
+
|
|
2227
|
+
def test_postback_payload_is_the_input
|
|
2228
|
+
context = post(messaging_payload({"postback" => {"mid" => "mid.3", "payload" => "get_started"}}))
|
|
2229
|
+
|
|
2230
|
+
assert_equal "get_started", context.input
|
|
2231
|
+
end
|
|
2232
|
+
|
|
2233
|
+
def test_echo_never_drives_a_flow_and_reports_its_origin
|
|
2234
|
+
events = capture_webhook_received do
|
|
2235
|
+
post(messaging_payload({
|
|
2236
|
+
"message" => {"mid" => "mid.4", "text" => "From a human", "is_echo" => true}
|
|
2237
|
+
}))
|
|
2238
|
+
end
|
|
2239
|
+
|
|
2240
|
+
assert_equal 1, events.size
|
|
2241
|
+
assert_equal :human_agent, events.first[:echo_origin]
|
|
2242
|
+
end
|
|
2243
|
+
|
|
2244
|
+
def test_echo_from_our_own_app_is_labelled_self
|
|
2245
|
+
events = capture_webhook_received do
|
|
2246
|
+
post(messaging_payload({
|
|
2247
|
+
"message" => {"mid" => "mid.5", "text" => "Ours", "is_echo" => true, "app_id" => "our_app"}
|
|
2248
|
+
}))
|
|
2249
|
+
end
|
|
2250
|
+
|
|
2251
|
+
assert_equal :self, events.first[:echo_origin]
|
|
2252
|
+
end
|
|
2253
|
+
|
|
2254
|
+
def test_delivery_receipt_publishes_status_and_leaves_the_flow_slot
|
|
2255
|
+
statuses = capture_events(FlowChat::Instrumentation::Events::MESSAGE_STATUS) do
|
|
2256
|
+
context = post({
|
|
2257
|
+
"object" => "page",
|
|
2258
|
+
"entry" => [{
|
|
2259
|
+
"id" => "page_1",
|
|
2260
|
+
"messaging" => [
|
|
2261
|
+
{"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "delivery" => {"mids" => ["mid.1"], "watermark" => 1}},
|
|
2262
|
+
{"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "message" => {"mid" => "mid.6", "text" => "Still here"}}
|
|
2263
|
+
]
|
|
2264
|
+
}]
|
|
2265
|
+
})
|
|
2266
|
+
|
|
2267
|
+
assert_equal "Still here", context.input, "a receipt must not spend the flow slot"
|
|
2268
|
+
end
|
|
2269
|
+
|
|
2270
|
+
assert_equal 1, statuses.size
|
|
2271
|
+
end
|
|
2272
|
+
|
|
2273
|
+
def test_second_message_in_one_delivery_does_not_run
|
|
2274
|
+
context = post({
|
|
2275
|
+
"object" => "page",
|
|
2276
|
+
"entry" => [{
|
|
2277
|
+
"id" => "page_1",
|
|
2278
|
+
"messaging" => [
|
|
2279
|
+
{"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "message" => {"mid" => "mid.7", "text" => "First"}},
|
|
2280
|
+
{"sender" => {"id" => "psid_2"}, "recipient" => {"id" => "page_1"}, "message" => {"mid" => "mid.8", "text" => "Second"}}
|
|
2281
|
+
]
|
|
2282
|
+
}]
|
|
2283
|
+
})
|
|
2284
|
+
|
|
2285
|
+
assert_equal "First", context.input
|
|
2286
|
+
end
|
|
2287
|
+
|
|
2288
|
+
def test_event_for_another_account_is_rejected
|
|
2289
|
+
context = post({
|
|
2290
|
+
"object" => "page",
|
|
2291
|
+
"entry" => [{
|
|
2292
|
+
"id" => "someone_elses_page",
|
|
2293
|
+
"messaging" => [{"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "someone_elses_page"}, "message" => {"mid" => "m", "text" => "Hi"}}]
|
|
2294
|
+
}]
|
|
2295
|
+
})
|
|
2296
|
+
|
|
2297
|
+
assert_equal :forbidden, context.controller.last_head_status
|
|
2298
|
+
end
|
|
2299
|
+
|
|
2300
|
+
def test_session_identifier_defaults_to_user_id
|
|
2301
|
+
middleware = FlowChat::Session::Middleware.allocate
|
|
2302
|
+
context = FlowChat::Context.new
|
|
2303
|
+
context["request.platform"] = :messenger
|
|
2304
|
+
|
|
2305
|
+
assert_equal :user_id, middleware.send(:platform_default_identifier, context)
|
|
2306
|
+
end
|
|
2307
|
+
|
|
2308
|
+
private
|
|
2309
|
+
|
|
2310
|
+
def messaging_payload(event)
|
|
2311
|
+
{
|
|
2312
|
+
"object" => "page",
|
|
2313
|
+
"entry" => [{
|
|
2314
|
+
"id" => "page_1",
|
|
2315
|
+
"messaging" => [
|
|
2316
|
+
{"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "timestamp" => 1_700_000_000}.merge(event)
|
|
2317
|
+
]
|
|
2318
|
+
}]
|
|
2319
|
+
}
|
|
2320
|
+
end
|
|
2321
|
+
|
|
2322
|
+
def post(body)
|
|
2323
|
+
context = build_messaging_context(body)
|
|
2324
|
+
@gateway.call(context)
|
|
2325
|
+
context
|
|
2326
|
+
end
|
|
2327
|
+
end
|
|
2328
|
+
```
|
|
2329
|
+
|
|
2330
|
+
Add `build_messaging_context(body)`, `capture_events(event_name)` and `capture_webhook_received` to `test/support/test_helpers.rb` so phases 2 and 3 share them. `build_messaging_context` mirrors `create_context_with_request` from `test/unit/whatsapp/gateway/cloud_api_test.rb:851`: an `OpenStruct` request with `post?`, `get?`, `body` as a rewindable `StringIO`, `headers`, `cookies`, and a controller recording `render`, `head` and `last_head_status`.
|
|
2331
|
+
|
|
2332
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
2333
|
+
|
|
2334
|
+
Run: `ruby -Itest test/unit/meta/messaging_gateway_test.rb`
|
|
2335
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Meta::MessagingGateway`.
|
|
2336
|
+
|
|
2337
|
+
- [ ] **Step 3: Implement the base gateway**
|
|
2338
|
+
|
|
2339
|
+
Create `lib/flow_chat/meta/messaging_gateway.rb`:
|
|
2340
|
+
|
|
2341
|
+
```ruby
|
|
2342
|
+
require "json"
|
|
2343
|
+
|
|
2344
|
+
module FlowChat
|
|
2345
|
+
module Meta
|
|
2346
|
+
# The Messenger Platform envelope, shared by Facebook Messenger and
|
|
2347
|
+
# Instagram DMs. Both deliver entry[].messaging[] and both send through the
|
|
2348
|
+
# same Send API, so the envelope is implemented once and each platform
|
|
2349
|
+
# supplies only what actually differs.
|
|
2350
|
+
class MessagingGateway
|
|
2351
|
+
include FlowChat::Instrumentation
|
|
2352
|
+
include FlowChat::GatewayAsyncSupport
|
|
2353
|
+
include FlowChat::Meta::SignatureValidation
|
|
2354
|
+
include FlowChat::Meta::WebhookVerification
|
|
2355
|
+
|
|
2356
|
+
attr_reader :context, :client
|
|
2357
|
+
|
|
2358
|
+
def initialize(app, config = nil)
|
|
2359
|
+
@app = app
|
|
2360
|
+
@config = config || configuration_class.from_credentials
|
|
2361
|
+
@client = client_class.new(@config)
|
|
2362
|
+
|
|
2363
|
+
FlowChat.logger.info { "#{log_tag}: Initialized #{platform} gateway for account #{@config.account_id}" }
|
|
2364
|
+
end
|
|
2365
|
+
|
|
2366
|
+
def call(context)
|
|
2367
|
+
@context = context
|
|
2368
|
+
@controller = context.controller
|
|
2369
|
+
request = @controller.request
|
|
2370
|
+
|
|
2371
|
+
unless in_background?
|
|
2372
|
+
if request.get? && request.params["hub.mode"] == "subscribe"
|
|
2373
|
+
return handle_verification(context)
|
|
2374
|
+
end
|
|
2375
|
+
end
|
|
2376
|
+
|
|
2377
|
+
return handle_webhook(context) if request.post?
|
|
2378
|
+
|
|
2379
|
+
FlowChat.logger.warn { "#{log_tag}: Invalid request method or parameters - returning bad request" }
|
|
2380
|
+
@controller.head :bad_request
|
|
2381
|
+
end
|
|
2382
|
+
|
|
2383
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
2384
|
+
builder.use custom_middleware
|
|
2385
|
+
builder.use choice_mapper_class
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2388
|
+
# --- Hooks each platform overrides ---
|
|
2389
|
+
|
|
2390
|
+
def platform
|
|
2391
|
+
raise NotImplementedError
|
|
2392
|
+
end
|
|
2393
|
+
|
|
2394
|
+
def gateway_name
|
|
2395
|
+
raise NotImplementedError
|
|
2396
|
+
end
|
|
2397
|
+
|
|
2398
|
+
def configuration_class
|
|
2399
|
+
raise NotImplementedError
|
|
2400
|
+
end
|
|
2401
|
+
|
|
2402
|
+
def client_class
|
|
2403
|
+
raise NotImplementedError
|
|
2404
|
+
end
|
|
2405
|
+
|
|
2406
|
+
def renderer_class
|
|
2407
|
+
raise NotImplementedError
|
|
2408
|
+
end
|
|
2409
|
+
|
|
2410
|
+
# Meta names the subscription this delivery came from. Messenger uses
|
|
2411
|
+
# "page". Instagram's value depends on how the app is set up, so each
|
|
2412
|
+
# platform states its own rather than sharing a guess.
|
|
2413
|
+
def expected_webhook_object
|
|
2414
|
+
"page"
|
|
2415
|
+
end
|
|
2416
|
+
|
|
2417
|
+
private
|
|
2418
|
+
|
|
2419
|
+
def handle_webhook(context)
|
|
2420
|
+
begin
|
|
2421
|
+
parse_request_body(@controller.request)
|
|
2422
|
+
rescue JSON::ParserError => e
|
|
2423
|
+
FlowChat.logger.error { "#{log_tag}: Failed to parse webhook body: #{e.message}" }
|
|
2424
|
+
return @controller.head :bad_request
|
|
2425
|
+
end
|
|
2426
|
+
|
|
2427
|
+
is_simulator_mode = simulate?(context)
|
|
2428
|
+
context["simulator_mode"] = true if is_simulator_mode
|
|
2429
|
+
|
|
2430
|
+
unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
|
|
2431
|
+
FlowChat.logger.warn { "#{log_tag}: Invalid webhook signature - dropping request" }
|
|
2432
|
+
return @controller.head :ok
|
|
2433
|
+
end
|
|
2434
|
+
|
|
2435
|
+
if @body["object"].present? && @body["object"] != expected_webhook_object
|
|
2436
|
+
FlowChat.logger.debug { "#{log_tag}: Ignoring webhook for object '#{@body["object"]}'" }
|
|
2437
|
+
return @controller.head :ok
|
|
2438
|
+
end
|
|
2439
|
+
|
|
2440
|
+
entries = @body["entry"]
|
|
2441
|
+
unless entries.is_a?(Array) && entries.any?
|
|
2442
|
+
return @controller.head :ok
|
|
2443
|
+
end
|
|
2444
|
+
|
|
2445
|
+
# Only one event per delivery can drive a flow, because only one can own
|
|
2446
|
+
# the response to this request.
|
|
2447
|
+
flow_ran = false
|
|
2448
|
+
|
|
2449
|
+
entries.each do |entry|
|
|
2450
|
+
events = entry["messaging"] || entry["standby"]
|
|
2451
|
+
next unless events.is_a?(Array)
|
|
2452
|
+
|
|
2453
|
+
# Receipts are handled before any flow claims the slot. A receipt
|
|
2454
|
+
# arriving ahead of a message in the same batch would otherwise spend
|
|
2455
|
+
# the slot and the message would be lost.
|
|
2456
|
+
events.each { |event| handle_status(entry, event) if status_event?(event) }
|
|
2457
|
+
|
|
2458
|
+
events.each do |event|
|
|
2459
|
+
next if status_event?(event)
|
|
2460
|
+
|
|
2461
|
+
if echo?(event)
|
|
2462
|
+
publish_echo(entry, event)
|
|
2463
|
+
next
|
|
2464
|
+
end
|
|
2465
|
+
|
|
2466
|
+
unless drives_flow?(event)
|
|
2467
|
+
publish_unmodelled(entry, event)
|
|
2468
|
+
next
|
|
2469
|
+
end
|
|
2470
|
+
|
|
2471
|
+
if flow_ran
|
|
2472
|
+
FlowChat.logger.warn { "#{log_tag}: A second message arrived in the same delivery and was not processed" }
|
|
2473
|
+
next
|
|
2474
|
+
end
|
|
2475
|
+
flow_ran = true
|
|
2476
|
+
|
|
2477
|
+
case handle_message(context, entry, event)
|
|
2478
|
+
when :rejected then return @controller.head :forbidden
|
|
2479
|
+
when :enqueued then return @controller.head :ok
|
|
2480
|
+
when :rendered then return nil
|
|
2481
|
+
end
|
|
2482
|
+
end
|
|
2483
|
+
end
|
|
2484
|
+
|
|
2485
|
+
@controller.head :ok
|
|
2486
|
+
end
|
|
2487
|
+
|
|
2488
|
+
def status_event?(event)
|
|
2489
|
+
event.key?("delivery") || event.key?("read")
|
|
2490
|
+
end
|
|
2491
|
+
|
|
2492
|
+
def echo?(event)
|
|
2493
|
+
event.dig("message", "is_echo") == true
|
|
2494
|
+
end
|
|
2495
|
+
|
|
2496
|
+
def drives_flow?(event)
|
|
2497
|
+
event.key?("message") || event.key?("postback")
|
|
2498
|
+
end
|
|
2499
|
+
|
|
2500
|
+
def handle_message(context, entry, event)
|
|
2501
|
+
account_id = entry["id"]
|
|
2502
|
+
if account_id.to_s != @config.account_id.to_s
|
|
2503
|
+
FlowChat.logger.warn { "#{log_tag}: Webhook for account '#{account_id}' but configured for '#{@config.account_id}' - rejecting" }
|
|
2504
|
+
return :rejected
|
|
2505
|
+
end
|
|
2506
|
+
|
|
2507
|
+
sender_id = event.dig("sender", "id")
|
|
2508
|
+
message = event["message"] || event["postback"]
|
|
2509
|
+
|
|
2510
|
+
context["request.id"] = sender_id
|
|
2511
|
+
context["request.user_id"] = sender_id
|
|
2512
|
+
context["request.msisdn"] = nil
|
|
2513
|
+
context["request.message_id"] = message["mid"]
|
|
2514
|
+
context["request.gateway"] = gateway_name
|
|
2515
|
+
context["request.platform"] = platform
|
|
2516
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
2517
|
+
context["request.body"] = @body
|
|
2518
|
+
|
|
2519
|
+
context["#{platform}.account.id"] = account_id
|
|
2520
|
+
context["#{platform}.client"] = @client
|
|
2521
|
+
|
|
2522
|
+
extract_message_content!(event, context)
|
|
2523
|
+
|
|
2524
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
2525
|
+
from: sender_id,
|
|
2526
|
+
message: context.input,
|
|
2527
|
+
message_type: event.key?("postback") ? "postback" : "message",
|
|
2528
|
+
message_id: message["mid"]
|
|
2529
|
+
})
|
|
2530
|
+
|
|
2531
|
+
return (enqueue_async_job || :enqueued) && :enqueued if should_enqueue_async?
|
|
2532
|
+
|
|
2533
|
+
if context["simulator_mode"]
|
|
2534
|
+
handle_message_simulator(context)
|
|
2535
|
+
:rendered
|
|
2536
|
+
else
|
|
2537
|
+
handle_message_inline(context)
|
|
2538
|
+
:processed
|
|
2539
|
+
end
|
|
2540
|
+
end
|
|
2541
|
+
|
|
2542
|
+
# A postback's payload, a quick reply's payload, and otherwise the text.
|
|
2543
|
+
# An attachment-only turn has blank input, matching the media contract the
|
|
2544
|
+
# other gateways follow.
|
|
2545
|
+
def extract_message_content!(event, context)
|
|
2546
|
+
if event.key?("postback")
|
|
2547
|
+
context.input = event.dig("postback", "payload").to_s
|
|
2548
|
+
return
|
|
2549
|
+
end
|
|
2550
|
+
|
|
2551
|
+
message = event["message"]
|
|
2552
|
+
|
|
2553
|
+
if message["quick_reply"]
|
|
2554
|
+
context.input = message.dig("quick_reply", "payload").to_s
|
|
2555
|
+
return
|
|
2556
|
+
end
|
|
2557
|
+
|
|
2558
|
+
attachments = message["attachments"]
|
|
2559
|
+
if attachments.is_a?(Array) && attachments.any?
|
|
2560
|
+
attachment = attachments.first
|
|
2561
|
+
context["request.media"] = {
|
|
2562
|
+
type: normalize_attachment_type(attachment["type"]),
|
|
2563
|
+
url: attachment.dig("payload", "url")
|
|
2564
|
+
}
|
|
2565
|
+
end
|
|
2566
|
+
|
|
2567
|
+
context.input = message["text"].presence || ""
|
|
2568
|
+
end
|
|
2569
|
+
|
|
2570
|
+
# "file" is Meta's name for what every other gateway here calls a document.
|
|
2571
|
+
def normalize_attachment_type(type)
|
|
2572
|
+
case type.to_s
|
|
2573
|
+
when "file" then :document
|
|
2574
|
+
when "" then nil
|
|
2575
|
+
else type.to_s.to_sym
|
|
2576
|
+
end
|
|
2577
|
+
end
|
|
2578
|
+
|
|
2579
|
+
def handle_status(entry, event)
|
|
2580
|
+
%w[delivery read].each do |kind|
|
|
2581
|
+
payload = event[kind]
|
|
2582
|
+
next unless payload
|
|
2583
|
+
|
|
2584
|
+
instrument(Events::MESSAGE_STATUS, {
|
|
2585
|
+
platform: platform,
|
|
2586
|
+
gateway: gateway_name,
|
|
2587
|
+
account_id: entry["id"],
|
|
2588
|
+
recipient: event.dig("sender", "id"),
|
|
2589
|
+
status: kind,
|
|
2590
|
+
timestamp: event["timestamp"],
|
|
2591
|
+
value: payload
|
|
2592
|
+
})
|
|
2593
|
+
end
|
|
2594
|
+
end
|
|
2595
|
+
|
|
2596
|
+
# An echo reports a message sent on this thread by someone other than the
|
|
2597
|
+
# user. Which someone decides what the application does about it: a human
|
|
2598
|
+
# replying from the page inbox usually means the flow should stand down.
|
|
2599
|
+
def publish_echo(entry, event)
|
|
2600
|
+
instrument(Events::WEBHOOK_RECEIVED, {
|
|
2601
|
+
platform: platform,
|
|
2602
|
+
gateway: gateway_name,
|
|
2603
|
+
field: "message_echoes",
|
|
2604
|
+
account_id: entry["id"],
|
|
2605
|
+
echo_origin: echo_origin(event),
|
|
2606
|
+
value: event
|
|
2607
|
+
})
|
|
2608
|
+
end
|
|
2609
|
+
|
|
2610
|
+
def echo_origin(event)
|
|
2611
|
+
app_id = event.dig("message", "app_id")
|
|
2612
|
+
|
|
2613
|
+
return :human_agent if app_id.blank?
|
|
2614
|
+
return :self if app_id.to_s == @config.app_id.to_s
|
|
2615
|
+
|
|
2616
|
+
:other_app
|
|
2617
|
+
end
|
|
2618
|
+
|
|
2619
|
+
# Everything that is not a message, its receipt, or an echo. Reactions,
|
|
2620
|
+
# referrals, opt-ins, handovers, policy enforcement: all of it is the
|
|
2621
|
+
# application's domain, so it is published whole rather than interpreted.
|
|
2622
|
+
def publish_unmodelled(entry, event)
|
|
2623
|
+
field = (event.keys - %w[sender recipient timestamp]).first
|
|
2624
|
+
|
|
2625
|
+
FlowChat.logger.info { "#{log_tag}: Publishing webhook event '#{field}'" }
|
|
2626
|
+
|
|
2627
|
+
instrument(Events::WEBHOOK_RECEIVED, {
|
|
2628
|
+
platform: platform,
|
|
2629
|
+
gateway: gateway_name,
|
|
2630
|
+
field: field,
|
|
2631
|
+
account_id: entry["id"],
|
|
2632
|
+
value: event
|
|
2633
|
+
})
|
|
2634
|
+
end
|
|
2635
|
+
|
|
2636
|
+
def handle_message_inline(context)
|
|
2637
|
+
response = @app.call(context)
|
|
2638
|
+
return unless response
|
|
2639
|
+
|
|
2640
|
+
type, prompt, choices, media = response
|
|
2641
|
+
|
|
2642
|
+
result = report_delivery_failure(
|
|
2643
|
+
context,
|
|
2644
|
+
to: context["request.user_id"],
|
|
2645
|
+
session_id: context["request.id"],
|
|
2646
|
+
message: prompt,
|
|
2647
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
2648
|
+
gateway: gateway_name,
|
|
2649
|
+
platform: platform
|
|
2650
|
+
) do
|
|
2651
|
+
@client.send_message(context["request.user_id"], prompt, choices: choices, media: media)
|
|
2652
|
+
end
|
|
2653
|
+
|
|
2654
|
+
context["#{platform}.message_result"] = result
|
|
2655
|
+
|
|
2656
|
+
instrument(Events::MESSAGE_SENT, {
|
|
2657
|
+
to: context["request.user_id"],
|
|
2658
|
+
session_id: context["request.id"],
|
|
2659
|
+
message: prompt,
|
|
2660
|
+
message_type: (type == :prompt) ? "prompt" : "terminal",
|
|
2661
|
+
gateway: gateway_name,
|
|
2662
|
+
platform: platform,
|
|
2663
|
+
content_length: prompt.to_s.length,
|
|
2664
|
+
platform_message_id: platform_message_id_from(result),
|
|
2665
|
+
timestamp: context["request.timestamp"]
|
|
2666
|
+
})
|
|
2667
|
+
end
|
|
2668
|
+
|
|
2669
|
+
# The Send API answers with the id it assigned, flatter than WhatsApp's
|
|
2670
|
+
# messages[0].id.
|
|
2671
|
+
def platform_message_id_from(result)
|
|
2672
|
+
return nil unless result.is_a?(Hash)
|
|
2673
|
+
|
|
2674
|
+
result["message_id"]
|
|
2675
|
+
end
|
|
2676
|
+
|
|
2677
|
+
def handle_message_simulator(context)
|
|
2678
|
+
response = @app.call(context)
|
|
2679
|
+
return unless response
|
|
2680
|
+
|
|
2681
|
+
_, prompt, choices, media = response
|
|
2682
|
+
rendered = renderer_class.new(prompt, choices: choices, media: media).render
|
|
2683
|
+
|
|
2684
|
+
@controller.render json: {
|
|
2685
|
+
mode: "simulator",
|
|
2686
|
+
webhook_processed: true,
|
|
2687
|
+
would_send: rendered,
|
|
2688
|
+
message_info: {
|
|
2689
|
+
to: context["request.user_id"],
|
|
2690
|
+
timestamp: Time.now.iso8601
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
nil
|
|
2695
|
+
end
|
|
2696
|
+
|
|
2697
|
+
def simulate?(context)
|
|
2698
|
+
return false unless context["enable_simulator"]
|
|
2699
|
+
|
|
2700
|
+
@body.dig("simulator_mode") &&
|
|
2701
|
+
FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
|
|
2702
|
+
end
|
|
2703
|
+
|
|
2704
|
+
def parse_request_body(request)
|
|
2705
|
+
return @body if @body
|
|
2706
|
+
|
|
2707
|
+
@body = if request.body.nil?
|
|
2708
|
+
{}
|
|
2709
|
+
else
|
|
2710
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
2711
|
+
JSON.parse(request.body.read)
|
|
2712
|
+
end
|
|
2713
|
+
end
|
|
2714
|
+
|
|
2715
|
+
end
|
|
2716
|
+
end
|
|
2717
|
+
end
|
|
2718
|
+
```
|
|
2719
|
+
|
|
2720
|
+
`platform`, `platform_label`, `configuration_error_class` and `log_tag` are not defined here: `GatewayIdentity` (Task 2) declares them, and the three raising ones are each subclass's job. Do not add local defaults, or a platform that forgets one will silently borrow another's identity.
|
|
2721
|
+
|
|
2722
|
+
Also delete the `def platform; raise NotImplementedError; end` shown in the hooks section above for the same reason. It duplicates `GatewayIdentity`. Keep the other three hooks (`configuration_class`, `client_class`, `renderer_class`) raising here, since those are this class's contract rather than the identity seam's.
|
|
2723
|
+
|
|
2724
|
+
The `should_enqueue_async?` line above is awkward. Write it plainly instead:
|
|
2725
|
+
|
|
2726
|
+
```ruby
|
|
2727
|
+
if should_enqueue_async?
|
|
2728
|
+
enqueue_async_job
|
|
2729
|
+
return :enqueued
|
|
2730
|
+
end
|
|
2731
|
+
```
|
|
2732
|
+
|
|
2733
|
+
- [ ] **Step 4: Teach the session middleware the two platforms**
|
|
2734
|
+
|
|
2735
|
+
In `lib/flow_chat/session/middleware.rb`, change `platform_default_identifier`:
|
|
2736
|
+
|
|
2737
|
+
```ruby
|
|
2738
|
+
def platform_default_identifier(context)
|
|
2739
|
+
platform = context["request.platform"]
|
|
2740
|
+
|
|
2741
|
+
case platform
|
|
2742
|
+
when :whatsapp
|
|
2743
|
+
:msisdn
|
|
2744
|
+
when :messenger, :instagram
|
|
2745
|
+
# Neither platform exposes a phone number. The sender id is scoped to
|
|
2746
|
+
# the app and the account, and is stable per user.
|
|
2747
|
+
:user_id
|
|
2748
|
+
else
|
|
2749
|
+
:request_id
|
|
2750
|
+
end
|
|
2751
|
+
end
|
|
2752
|
+
```
|
|
2753
|
+
|
|
2754
|
+
- [ ] **Step 5: Run the tests**
|
|
2755
|
+
|
|
2756
|
+
Run: `ruby -Itest test/unit/meta/messaging_gateway_test.rb`
|
|
2757
|
+
Expected: PASS, 10 tests. `TestGateway` makes this task self-contained, so it must go green here without Task 13.
|
|
2758
|
+
|
|
2759
|
+
Run: `bundle exec rake test`
|
|
2760
|
+
Expected: PASS, 0 failures. The session-middleware change affects every platform, so watch for identifier regressions in the WhatsApp and Telegram suites.
|
|
2761
|
+
|
|
2762
|
+
- [ ] **Step 6: Commit**
|
|
2763
|
+
|
|
2764
|
+
```bash
|
|
2765
|
+
git add lib/flow_chat/meta/messaging_gateway.rb lib/flow_chat/session/middleware.rb \
|
|
2766
|
+
test/unit/meta/messaging_gateway_test.rb test/support/test_helpers.rb
|
|
2767
|
+
git commit -m "feat(meta): implement the Messenger Platform envelope once"
|
|
2768
|
+
```
|
|
2769
|
+
|
|
2770
|
+
---
|
|
2771
|
+
|
|
2772
|
+
## Task 13: Messenger gateway and choice mapper
|
|
2773
|
+
|
|
2774
|
+
**Goal:** The Messenger subclass, and the middleware that maps a reply back to the choice key the flow used.
|
|
2775
|
+
|
|
2776
|
+
**Files:**
|
|
2777
|
+
- Create: `lib/flow_chat/messenger/gateway/send_api.rb`
|
|
2778
|
+
- Create: `lib/flow_chat/messenger/middleware/choice_mapper.rb`
|
|
2779
|
+
- Create: `test/unit/messenger/middleware/choice_mapper_test.rb`
|
|
2780
|
+
|
|
2781
|
+
**Acceptance Criteria:**
|
|
2782
|
+
- [ ] `FlowChat::Messenger::Gateway::SendApi` subclasses `FlowChat::Meta::MessagingGateway` and overrides `platform`, `gateway_name`, `configuration_class`, `client_class`, `renderer_class`, `choice_mapper_class`
|
|
2783
|
+
- [ ] A tapped quick reply resolves to the flow's original choice key
|
|
2784
|
+
- [ ] On the numbered rung, typing `"3"` resolves to the third choice's key
|
|
2785
|
+
- [ ] Ids resolve before positions, so a choice labelled `"1"` is not shadowed by position `"1"`
|
|
2786
|
+
- [ ] The position map is absent on the quick-reply and carousel rungs
|
|
2787
|
+
- [ ] **Both maps are cleared together.** A test proves that a typed digit on a screen with no choices, reached after a numbered menu, stays a digit rather than resolving to that menu's third choice key. This was a real bug in the WhatsApp mapper, found and fixed in Task 6, and the mechanism here is identical: `create_mappings` runs only when a screen has choices, so a screen without them clears nothing unless clearing is explicit.
|
|
2788
|
+
|
|
2789
|
+
**Verify:** `ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb && ruby -Itest test/unit/meta/messaging_gateway_test.rb` → PASS
|
|
2790
|
+
|
|
2791
|
+
**Steps:**
|
|
2792
|
+
|
|
2793
|
+
- [ ] **Step 1: Write the failing choice-mapper test**
|
|
2794
|
+
|
|
2795
|
+
Create `test/unit/messenger/middleware/choice_mapper_test.rb`:
|
|
2796
|
+
|
|
2797
|
+
```ruby
|
|
2798
|
+
require "test_helper"
|
|
2799
|
+
|
|
2800
|
+
class MessengerChoiceMapperTest < Minitest::Test
|
|
2801
|
+
def build(app)
|
|
2802
|
+
FlowChat::Messenger::Middleware::ChoiceMapper.new(app)
|
|
2803
|
+
end
|
|
2804
|
+
|
|
2805
|
+
def context_with(input, session: nil)
|
|
2806
|
+
context = FlowChat::Context.new
|
|
2807
|
+
context.input = input
|
|
2808
|
+
context["session.id"] = "session_1"
|
|
2809
|
+
context.session = session || FlowChat::TestSupport::MockSessionStore.new
|
|
2810
|
+
context
|
|
2811
|
+
end
|
|
2812
|
+
|
|
2813
|
+
def test_tapped_quick_reply_resolves_to_the_original_key
|
|
2814
|
+
choices = {"create" => "Create Account", "login" => "Log In"}
|
|
2815
|
+
mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
|
|
2816
|
+
|
|
2817
|
+
first = context_with("")
|
|
2818
|
+
_, _, transformed, _ = mapper.call(first)
|
|
2819
|
+
|
|
2820
|
+
tapped = transformed.keys.first
|
|
2821
|
+
second = context_with(tapped, session: first.session)
|
|
2822
|
+
mapper.call(second)
|
|
2823
|
+
|
|
2824
|
+
assert_equal "create", second.input
|
|
2825
|
+
end
|
|
2826
|
+
|
|
2827
|
+
def test_typed_number_resolves_on_the_numbered_rung
|
|
2828
|
+
choices = (1..31).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
2829
|
+
mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
|
|
2830
|
+
|
|
2831
|
+
first = context_with("")
|
|
2832
|
+
mapper.call(first)
|
|
2833
|
+
|
|
2834
|
+
second = context_with("3", session: first.session)
|
|
2835
|
+
mapper.call(second)
|
|
2836
|
+
|
|
2837
|
+
assert_equal "k3", second.input
|
|
2838
|
+
end
|
|
2839
|
+
|
|
2840
|
+
# A generated id and a position occupy the same key space: normalize_label
|
|
2841
|
+
# keeps digits, so a choice labelled "1" generates the id "1".
|
|
2842
|
+
def test_generated_ids_win_over_positions
|
|
2843
|
+
choices = {"a" => "2", "b" => "1"}
|
|
2844
|
+
mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
|
|
2845
|
+
|
|
2846
|
+
first = context_with("")
|
|
2847
|
+
mapper.call(first)
|
|
2848
|
+
|
|
2849
|
+
second = context_with("1", session: first.session)
|
|
2850
|
+
mapper.call(second)
|
|
2851
|
+
|
|
2852
|
+
assert_equal "b", second.input, "the id for the label \"1\" must win over position 1"
|
|
2853
|
+
end
|
|
2854
|
+
|
|
2855
|
+
def test_no_position_map_on_the_quick_reply_rung
|
|
2856
|
+
choices = {"a" => "Alpha", "b" => "Beta"}
|
|
2857
|
+
mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
|
|
2858
|
+
|
|
2859
|
+
context = context_with("")
|
|
2860
|
+
mapper.call(context)
|
|
2861
|
+
|
|
2862
|
+
assert_nil context.session.get("messenger.position_mapping")
|
|
2863
|
+
end
|
|
2864
|
+
end
|
|
2865
|
+
```
|
|
2866
|
+
|
|
2867
|
+
Use whatever session double the existing `test/unit/whatsapp/middleware/choice_mapper_test.rb` uses rather than assuming `MockSessionStore`.
|
|
2868
|
+
|
|
2869
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
2870
|
+
|
|
2871
|
+
Run: `ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb`
|
|
2872
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger::Middleware`.
|
|
2873
|
+
|
|
2874
|
+
- [ ] **Step 3: Write the gateway subclass**
|
|
2875
|
+
|
|
2876
|
+
Create `lib/flow_chat/messenger/gateway/send_api.rb`:
|
|
2877
|
+
|
|
2878
|
+
```ruby
|
|
2879
|
+
module FlowChat
|
|
2880
|
+
module Messenger
|
|
2881
|
+
module Gateway
|
|
2882
|
+
# Facebook Messenger, on the shared Messenger Platform envelope.
|
|
2883
|
+
class SendApi < FlowChat::Meta::MessagingGateway
|
|
2884
|
+
def platform
|
|
2885
|
+
:messenger
|
|
2886
|
+
end
|
|
2887
|
+
|
|
2888
|
+
def gateway_name
|
|
2889
|
+
:messenger_send_api
|
|
2890
|
+
end
|
|
2891
|
+
|
|
2892
|
+
def configuration_class
|
|
2893
|
+
FlowChat::Messenger::Configuration
|
|
2894
|
+
end
|
|
2895
|
+
|
|
2896
|
+
def client_class
|
|
2897
|
+
FlowChat::Messenger::Client
|
|
2898
|
+
end
|
|
2899
|
+
|
|
2900
|
+
def renderer_class
|
|
2901
|
+
FlowChat::Messenger::Renderer
|
|
2902
|
+
end
|
|
2903
|
+
|
|
2904
|
+
def self.choice_mapper_class
|
|
2905
|
+
FlowChat::Messenger::Middleware::ChoiceMapper
|
|
2906
|
+
end
|
|
2907
|
+
|
|
2908
|
+
private
|
|
2909
|
+
|
|
2910
|
+
def configuration_error_class
|
|
2911
|
+
FlowChat::Messenger::ConfigurationError
|
|
2912
|
+
end
|
|
2913
|
+
|
|
2914
|
+
def platform_label
|
|
2915
|
+
"Messenger"
|
|
2916
|
+
end
|
|
2917
|
+
end
|
|
2918
|
+
end
|
|
2919
|
+
end
|
|
2920
|
+
end
|
|
2921
|
+
```
|
|
2922
|
+
|
|
2923
|
+
- [ ] **Step 4: Write the choice mapper**
|
|
2924
|
+
|
|
2925
|
+
Create `lib/flow_chat/messenger/middleware/choice_mapper.rb`:
|
|
2926
|
+
|
|
2927
|
+
```ruby
|
|
2928
|
+
module FlowChat
|
|
2929
|
+
module Messenger
|
|
2930
|
+
module Middleware
|
|
2931
|
+
# Maps a reply back to the choice key the flow used.
|
|
2932
|
+
#
|
|
2933
|
+
# Two key spaces can be live at once. A tap sends the payload id the
|
|
2934
|
+
# renderer put on the button, and on the numbered rung a typed digit sends
|
|
2935
|
+
# a position. They are stored separately and resolved ids first, because
|
|
2936
|
+
# the spaces overlap: IdGenerator keeps digits, so a choice labelled "1"
|
|
2937
|
+
# generates the id "1", which is not necessarily the first choice.
|
|
2938
|
+
class ChoiceMapper
|
|
2939
|
+
ID_KEY = "messenger.choice_mapping"
|
|
2940
|
+
POSITION_KEY = "messenger.position_mapping"
|
|
2941
|
+
|
|
2942
|
+
def initialize(app)
|
|
2943
|
+
@app = app
|
|
2944
|
+
end
|
|
2945
|
+
|
|
2946
|
+
def call(context)
|
|
2947
|
+
@context = context
|
|
2948
|
+
@session = context.session
|
|
2949
|
+
|
|
2950
|
+
handle_choice_input if intercept?
|
|
2951
|
+
|
|
2952
|
+
type, prompt, choices, media = @app.call(context)
|
|
2953
|
+
|
|
2954
|
+
choices = create_mappings(choices) if choices.present?
|
|
2955
|
+
|
|
2956
|
+
[type, prompt, choices, media]
|
|
2957
|
+
end
|
|
2958
|
+
|
|
2959
|
+
private
|
|
2960
|
+
|
|
2961
|
+
def platform_limits
|
|
2962
|
+
FlowChat::Config.messenger
|
|
2963
|
+
end
|
|
2964
|
+
|
|
2965
|
+
def always_number?
|
|
2966
|
+
false
|
|
2967
|
+
end
|
|
2968
|
+
|
|
2969
|
+
def id_key
|
|
2970
|
+
self.class::ID_KEY
|
|
2971
|
+
end
|
|
2972
|
+
|
|
2973
|
+
def position_key
|
|
2974
|
+
self.class::POSITION_KEY
|
|
2975
|
+
end
|
|
2976
|
+
|
|
2977
|
+
def resolved_choice
|
|
2978
|
+
input = @context.input.to_s
|
|
2979
|
+
return nil if input.empty?
|
|
2980
|
+
|
|
2981
|
+
(@session.get(id_key) || {})[input] || (@session.get(position_key) || {})[input]
|
|
2982
|
+
end
|
|
2983
|
+
|
|
2984
|
+
def intercept?
|
|
2985
|
+
@context.input.present? && resolved_choice.present?
|
|
2986
|
+
end
|
|
2987
|
+
|
|
2988
|
+
def handle_choice_input
|
|
2989
|
+
original = resolved_choice
|
|
2990
|
+
FlowChat.logger.info { "#{self.class.name}: Resolving input #{@context.input} to #{original}" }
|
|
2991
|
+
@context.input = original
|
|
2992
|
+
end
|
|
2993
|
+
|
|
2994
|
+
# Both maps are cleared together. Clearing only one leaves the other to
|
|
2995
|
+
# hijack a later turn: a position map surviving from a numbered menu
|
|
2996
|
+
# rewrites a typed "3" on the next free-text screen into that menu's third
|
|
2997
|
+
# choice key. This was a real bug in the WhatsApp mapper, found in Task 6
|
|
2998
|
+
# and fixed there. `create_mappings` only runs when a screen HAS choices,
|
|
2999
|
+
# so a screen without them clears nothing unless clearing is explicit.
|
|
3000
|
+
#
|
|
3001
|
+
# It bites hardest on Instagram, which stores positions at every rung.
|
|
3002
|
+
def clear_mappings
|
|
3003
|
+
@session.delete(id_key)
|
|
3004
|
+
@session.delete(position_key)
|
|
3005
|
+
end
|
|
3006
|
+
|
|
3007
|
+
def create_mappings(choices)
|
|
3008
|
+
generator = FlowChat::IdGenerator.new(max_length: 1000)
|
|
3009
|
+
id_choices = {}
|
|
3010
|
+
id_mapping = {}
|
|
3011
|
+
|
|
3012
|
+
choices.each do |key, label|
|
|
3013
|
+
generated_id = generator.generate_id(label.to_s)
|
|
3014
|
+
id_choices[generated_id] = label
|
|
3015
|
+
id_mapping[generated_id] = key.to_s
|
|
3016
|
+
end
|
|
3017
|
+
|
|
3018
|
+
@session.set(id_key, id_mapping)
|
|
3019
|
+
|
|
3020
|
+
if FlowChat::Meta::ChoiceLadder.numbers_in_body?(choices.length, platform_limits, always_number: always_number?)
|
|
3021
|
+
@session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
3022
|
+
else
|
|
3023
|
+
@session.delete(position_key)
|
|
3024
|
+
end
|
|
3025
|
+
|
|
3026
|
+
id_choices
|
|
3027
|
+
end
|
|
3028
|
+
end
|
|
3029
|
+
end
|
|
3030
|
+
end
|
|
3031
|
+
end
|
|
3032
|
+
```
|
|
3033
|
+
|
|
3034
|
+
- [ ] **Step 5: Run the tests**
|
|
3035
|
+
|
|
3036
|
+
Run: `ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb`
|
|
3037
|
+
Expected: PASS, 4 tests.
|
|
3038
|
+
|
|
3039
|
+
Run: `ruby -Itest test/unit/meta/messaging_gateway_test.rb`
|
|
3040
|
+
Expected: PASS, 10 tests, still green. Leave `TestGateway` in place: it keeps the base class covered independently of either real platform.
|
|
3041
|
+
|
|
3042
|
+
Add one test to `test/unit/messenger/middleware/choice_mapper_test.rb` proving the subclass wires the hooks, since `TestGateway` cannot prove that:
|
|
3043
|
+
|
|
3044
|
+
```ruby
|
|
3045
|
+
def test_gateway_exposes_the_messenger_hooks
|
|
3046
|
+
config = FlowChat::Messenger::Configuration.new(nil)
|
|
3047
|
+
config.page_id = "page_1"
|
|
3048
|
+
config.access_token = "tok"
|
|
3049
|
+
config.verify_token = "verify"
|
|
3050
|
+
|
|
3051
|
+
gateway = FlowChat::Messenger::Gateway::SendApi.new(proc {}, config)
|
|
3052
|
+
|
|
3053
|
+
assert_equal :messenger, gateway.platform
|
|
3054
|
+
assert_equal :messenger_send_api, gateway.gateway_name
|
|
3055
|
+
assert_equal FlowChat::Messenger::Renderer, gateway.renderer_class
|
|
3056
|
+
assert_equal FlowChat::Messenger::Middleware::ChoiceMapper,
|
|
3057
|
+
FlowChat::Messenger::Gateway::SendApi.choice_mapper_class
|
|
3058
|
+
end
|
|
3059
|
+
```
|
|
3060
|
+
|
|
3061
|
+
- [ ] **Step 6: Run the full suite**
|
|
3062
|
+
|
|
3063
|
+
Run: `bundle exec rake test`
|
|
3064
|
+
Expected: PASS, 0 failures.
|
|
3065
|
+
|
|
3066
|
+
- [ ] **Step 7: Commit**
|
|
3067
|
+
|
|
3068
|
+
```bash
|
|
3069
|
+
git add lib/flow_chat/messenger/gateway/send_api.rb lib/flow_chat/messenger/middleware/choice_mapper.rb \
|
|
3070
|
+
test/unit/messenger/middleware/choice_mapper_test.rb test/unit/meta/messaging_gateway_test.rb
|
|
3071
|
+
git commit -m "feat(messenger): wire the gateway and resolve tapped and typed replies"
|
|
3072
|
+
```
|
|
3073
|
+
|
|
3074
|
+
---
|
|
3075
|
+
|
|
3076
|
+
## Task 14: Messenger integration test
|
|
3077
|
+
|
|
3078
|
+
**Goal:** A full webhook-to-send cycle through a real flow, session store and middleware stack.
|
|
3079
|
+
|
|
3080
|
+
**Files:**
|
|
3081
|
+
- Create: `test/integration/messenger_integration_test.rb`
|
|
3082
|
+
|
|
3083
|
+
**Acceptance Criteria:**
|
|
3084
|
+
- [ ] A first webhook starts a flow and sends the first prompt
|
|
3085
|
+
- [ ] A tapped quick reply on the second webhook advances the flow, with session state carried
|
|
3086
|
+
- [ ] A terminal screen sends the final message
|
|
3087
|
+
- [ ] Async mode enqueues instead of sending, and returns 200
|
|
3088
|
+
|
|
3089
|
+
**Verify:** `ruby -Itest test/integration/messenger_integration_test.rb` → PASS
|
|
3090
|
+
|
|
3091
|
+
**Steps:**
|
|
3092
|
+
|
|
3093
|
+
- [ ] **Step 1: Read the model first**
|
|
3094
|
+
|
|
3095
|
+
Read `test/integration/whatsapp_integration_test.rb` in full. Copy its structure: processor construction, session store, flow definition, and how it asserts on sends. Do not invent a new harness.
|
|
3096
|
+
|
|
3097
|
+
- [ ] **Step 2: Write the test**
|
|
3098
|
+
|
|
3099
|
+
Create `test/integration/messenger_integration_test.rb` with a two-screen flow:
|
|
3100
|
+
|
|
3101
|
+
```ruby
|
|
3102
|
+
require "test_helper"
|
|
3103
|
+
|
|
3104
|
+
class MessengerIntegrationTest < Minitest::Test
|
|
3105
|
+
class RegistrationFlow < FlowChat::Flow
|
|
3106
|
+
def main_page
|
|
3107
|
+
name = app.screen(:name) { |prompt| prompt.ask "What is your name?" }
|
|
3108
|
+
plan = app.screen(:plan) do |prompt|
|
|
3109
|
+
prompt.select "Choose a plan", {"basic" => "Basic", "pro" => "Pro"}
|
|
3110
|
+
end
|
|
3111
|
+
app.say "Thanks #{name}, you chose #{plan}."
|
|
3112
|
+
end
|
|
3113
|
+
end
|
|
3114
|
+
|
|
3115
|
+
def setup
|
|
3116
|
+
@config = FlowChat::Messenger::Configuration.new(nil)
|
|
3117
|
+
@config.page_id = "page_1"
|
|
3118
|
+
@config.access_token = "tok"
|
|
3119
|
+
@config.verify_token = "verify"
|
|
3120
|
+
@config.skip_signature_validation = true
|
|
3121
|
+
|
|
3122
|
+
@sent = []
|
|
3123
|
+
FlowChat::Config.cache = FlowChat::TestSupport::MockCache.new
|
|
3124
|
+
end
|
|
3125
|
+
|
|
3126
|
+
def test_full_conversation
|
|
3127
|
+
first = run_webhook(text: "Hello")
|
|
3128
|
+
assert_match(/What is your name/, last_sent_prompt)
|
|
3129
|
+
|
|
3130
|
+
run_webhook(text: "Ama")
|
|
3131
|
+
assert_match(/Choose a plan/, last_sent_prompt)
|
|
3132
|
+
|
|
3133
|
+
tapped = last_sent_choices.keys.first
|
|
3134
|
+
run_webhook(quick_reply: tapped)
|
|
3135
|
+
assert_match(/Thanks Ama/, last_sent_prompt)
|
|
3136
|
+
end
|
|
3137
|
+
end
|
|
3138
|
+
```
|
|
3139
|
+
|
|
3140
|
+
The three helpers, in full. `send_message` is replaced with a recorder rather than stubbed over HTTP, because what this test asserts is the prompt and choices the flow produced, not the wire format (Task 11 covers that):
|
|
3141
|
+
|
|
3142
|
+
```ruby
|
|
3143
|
+
private
|
|
3144
|
+
|
|
3145
|
+
def processor_for(controller)
|
|
3146
|
+
FlowChat::Processor.new(controller) do |config|
|
|
3147
|
+
config.use_gateway FlowChat::Messenger::Gateway::SendApi, @config
|
|
3148
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
3149
|
+
end
|
|
3150
|
+
end
|
|
3151
|
+
|
|
3152
|
+
# Replaces the client's send with a recorder. The gateway builds its client in
|
|
3153
|
+
# the constructor, so this reaches in after the processor is built.
|
|
3154
|
+
def record_sends(gateway)
|
|
3155
|
+
sent = @sent
|
|
3156
|
+
gateway.client.define_singleton_method(:send_message) do |to, prompt, choices: nil, media: nil|
|
|
3157
|
+
sent << {to: to, prompt: prompt, choices: choices, media: media}
|
|
3158
|
+
{"recipient_id" => to, "message_id" => "mid.#{sent.length}"}
|
|
3159
|
+
end
|
|
3160
|
+
end
|
|
3161
|
+
|
|
3162
|
+
def run_webhook(text: nil, quick_reply: nil)
|
|
3163
|
+
message = {"mid" => "mid.in.#{@sent.length}"}
|
|
3164
|
+
if quick_reply
|
|
3165
|
+
message["text"] = "tapped"
|
|
3166
|
+
message["quick_reply"] = {"payload" => quick_reply}
|
|
3167
|
+
else
|
|
3168
|
+
message["text"] = text
|
|
3169
|
+
end
|
|
3170
|
+
|
|
3171
|
+
run_raw_webhook({
|
|
3172
|
+
"object" => "page",
|
|
3173
|
+
"entry" => [{
|
|
3174
|
+
"id" => "page_1",
|
|
3175
|
+
"messaging" => [{
|
|
3176
|
+
"sender" => {"id" => "psid_1"},
|
|
3177
|
+
"recipient" => {"id" => "page_1"},
|
|
3178
|
+
"timestamp" => 1_700_000_000,
|
|
3179
|
+
"message" => message
|
|
3180
|
+
}]
|
|
3181
|
+
}]
|
|
3182
|
+
})
|
|
3183
|
+
end
|
|
3184
|
+
|
|
3185
|
+
def run_raw_webhook(body)
|
|
3186
|
+
context = build_messaging_context(body)
|
|
3187
|
+
processor = processor_for(context.controller)
|
|
3188
|
+
record_sends(processor.gateway)
|
|
3189
|
+
processor.run(RegistrationFlow, :main_page)
|
|
3190
|
+
context
|
|
3191
|
+
end
|
|
3192
|
+
|
|
3193
|
+
def last_sent_prompt
|
|
3194
|
+
@sent.last[:prompt]
|
|
3195
|
+
end
|
|
3196
|
+
|
|
3197
|
+
def last_sent_choices
|
|
3198
|
+
@sent.last[:choices]
|
|
3199
|
+
end
|
|
3200
|
+
```
|
|
3201
|
+
|
|
3202
|
+
`processor.gateway` may not be exposed. Check `lib/flow_chat/processor.rb` first: if the built gateway is not reachable from the processor, record sends by stubbing `FlowChat::Messenger::Client#send_message` on the class for the duration of the test instead, and note which approach you used.
|
|
3203
|
+
|
|
3204
|
+
- [ ] **Step 3: Run it**
|
|
3205
|
+
|
|
3206
|
+
Run: `ruby -Itest test/integration/messenger_integration_test.rb`
|
|
3207
|
+
Expected: PASS.
|
|
3208
|
+
|
|
3209
|
+
- [ ] **Step 4: Add the async case**
|
|
3210
|
+
|
|
3211
|
+
Add a test that builds the processor with `config.use_async(factory: :messenger)`, registers that factory, and asserts the webhook enqueues rather than sending. Follow `test/integration/async_flow_execution_test.rb` for the job double.
|
|
3212
|
+
|
|
3213
|
+
Run: `ruby -Itest test/integration/messenger_integration_test.rb`
|
|
3214
|
+
Expected: PASS.
|
|
3215
|
+
|
|
3216
|
+
- [ ] **Step 5: Run the full suite**
|
|
3217
|
+
|
|
3218
|
+
Run: `bundle exec rake test`
|
|
3219
|
+
Expected: PASS, 0 failures.
|
|
3220
|
+
|
|
3221
|
+
- [ ] **Step 6: Commit**
|
|
3222
|
+
|
|
3223
|
+
```bash
|
|
3224
|
+
git add test/integration/messenger_integration_test.rb test/support/test_helpers.rb
|
|
3225
|
+
git commit -m "test(messenger): drive a conversation end to end"
|
|
3226
|
+
```
|
|
3227
|
+
|
|
3228
|
+
---
|
|
3229
|
+
|
|
3230
|
+
# Phase 3: Instagram
|
|
3231
|
+
|
|
3232
|
+
## Task 15: Instagram configuration
|
|
3233
|
+
|
|
3234
|
+
**Goal:** Instagram credentials on the Facebook Login path, with Instagram's own limits.
|
|
3235
|
+
|
|
3236
|
+
**Files:**
|
|
3237
|
+
- Create: `lib/flow_chat/instagram/configuration.rb`
|
|
3238
|
+
- Create: `test/unit/instagram/configuration_test.rb`
|
|
3239
|
+
- Modify: `lib/flow_chat/config.rb` (add `Config.instagram`)
|
|
3240
|
+
|
|
3241
|
+
**Acceptance Criteria:**
|
|
3242
|
+
- [ ] `FlowChat::Instagram::Configuration` carries `page_id`, `instagram_account_id`, `access_token`, `verify_token`, `app_id`, `app_secret`, `skip_signature_validation`
|
|
3243
|
+
- [ ] `from_credentials` reads `instagram:` credentials with `INSTAGRAM_*` env fallback
|
|
3244
|
+
- [ ] `valid?` requires `access_token`, `page_id` and `verify_token`
|
|
3245
|
+
- [ ] `FlowChat::Config.instagram.max_text_length` is 1000
|
|
3246
|
+
- [ ] `account_id` returns `page_id`, since the Facebook Login path keys webhooks on the linked page
|
|
3247
|
+
|
|
3248
|
+
**Verify:** `ruby -Itest test/unit/instagram/configuration_test.rb` → PASS
|
|
3249
|
+
|
|
3250
|
+
**Steps:**
|
|
3251
|
+
|
|
3252
|
+
- [ ] **Step 1: Write the failing test**
|
|
3253
|
+
|
|
3254
|
+
Create `test/unit/instagram/configuration_test.rb` following the shape of `test/unit/messenger/configuration_test.rb`, asserting:
|
|
3255
|
+
|
|
3256
|
+
```ruby
|
|
3257
|
+
def test_limits_are_instagram_specific
|
|
3258
|
+
assert_equal 1000, FlowChat::Config.instagram.max_text_length
|
|
3259
|
+
assert_equal 13, FlowChat::Config.instagram.max_quick_replies
|
|
3260
|
+
assert_equal 10, FlowChat::Config.instagram.max_carousel_elements
|
|
3261
|
+
end
|
|
3262
|
+
|
|
3263
|
+
def test_account_id_is_the_linked_page
|
|
3264
|
+
config = FlowChat::Instagram::Configuration.new(nil)
|
|
3265
|
+
config.page_id = "page_1"
|
|
3266
|
+
config.instagram_account_id = "ig_1"
|
|
3267
|
+
|
|
3268
|
+
assert_equal "page_1", config.account_id
|
|
3269
|
+
end
|
|
3270
|
+
```
|
|
3271
|
+
|
|
3272
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
3273
|
+
|
|
3274
|
+
Run: `ruby -Itest test/unit/instagram/configuration_test.rb`
|
|
3275
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Instagram`.
|
|
3276
|
+
|
|
3277
|
+
- [ ] **Step 3: Add the global config object**
|
|
3278
|
+
|
|
3279
|
+
In `lib/flow_chat/config.rb`:
|
|
3280
|
+
|
|
3281
|
+
```ruby
|
|
3282
|
+
# Instagram-specific configuration object
|
|
3283
|
+
def self.instagram
|
|
3284
|
+
@instagram ||= InstagramConfig.new
|
|
3285
|
+
end
|
|
3286
|
+
```
|
|
3287
|
+
|
|
3288
|
+
```ruby
|
|
3289
|
+
class InstagramConfig
|
|
3290
|
+
attr_reader :api_base_url, :max_text_length, :max_quick_replies,
|
|
3291
|
+
:max_carousel_elements, :max_buttons_per_element,
|
|
3292
|
+
:max_quick_reply_title, :max_button_title, :max_element_title
|
|
3293
|
+
|
|
3294
|
+
def initialize
|
|
3295
|
+
@api_base_url = "https://graph.facebook.com/v23.0"
|
|
3296
|
+
# Meta: "Message text must be UTF-8 and be 1,000 bytes or less."
|
|
3297
|
+
@max_text_length = 1000
|
|
3298
|
+
@max_quick_replies = 13
|
|
3299
|
+
@max_quick_reply_title = 20
|
|
3300
|
+
@max_carousel_elements = 10
|
|
3301
|
+
@max_buttons_per_element = 3
|
|
3302
|
+
@max_button_title = 20
|
|
3303
|
+
@max_element_title = 80
|
|
3304
|
+
end
|
|
3305
|
+
end
|
|
3306
|
+
```
|
|
3307
|
+
|
|
3308
|
+
- [ ] **Step 4: Write the configuration class**
|
|
3309
|
+
|
|
3310
|
+
Create `lib/flow_chat/instagram/configuration.rb`, mirroring `lib/flow_chat/messenger/configuration.rb` with these differences: an extra `instagram_account_id` accessor, `INSTAGRAM_*` env keys, `Rails.application.credentials.instagram`, `api_base_url` from `FlowChat::Config.instagram`, and an `Instagram::ConfigurationError`. `account_id` returns `page_id`.
|
|
3311
|
+
|
|
3312
|
+
```ruby
|
|
3313
|
+
module FlowChat
|
|
3314
|
+
module Instagram
|
|
3315
|
+
class ConfigurationError < StandardError; end
|
|
3316
|
+
|
|
3317
|
+
class Configuration
|
|
3318
|
+
include FlowChat::NamedConfiguration
|
|
3319
|
+
|
|
3320
|
+
attr_accessor :access_token, :page_id, :instagram_account_id, :verify_token,
|
|
3321
|
+
:app_id, :app_secret, :name, :skip_signature_validation
|
|
3322
|
+
|
|
3323
|
+
def initialize(name)
|
|
3324
|
+
@name = name
|
|
3325
|
+
@skip_signature_validation = false
|
|
3326
|
+
|
|
3327
|
+
FlowChat.logger.debug { "Instagram::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
3328
|
+
|
|
3329
|
+
register_as(name) if name.present?
|
|
3330
|
+
end
|
|
3331
|
+
|
|
3332
|
+
def self.from_credentials
|
|
3333
|
+
config = new(nil)
|
|
3334
|
+
|
|
3335
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.instagram
|
|
3336
|
+
credentials = Rails.application.credentials.instagram
|
|
3337
|
+
config.access_token = credentials[:access_token]
|
|
3338
|
+
config.page_id = credentials[:page_id]
|
|
3339
|
+
config.instagram_account_id = credentials[:instagram_account_id]
|
|
3340
|
+
config.verify_token = credentials[:verify_token]
|
|
3341
|
+
config.app_id = credentials[:app_id]
|
|
3342
|
+
config.app_secret = credentials[:app_secret]
|
|
3343
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
3344
|
+
else
|
|
3345
|
+
config.access_token = ENV["INSTAGRAM_ACCESS_TOKEN"]
|
|
3346
|
+
config.page_id = ENV["INSTAGRAM_PAGE_ID"]
|
|
3347
|
+
config.instagram_account_id = ENV["INSTAGRAM_ACCOUNT_ID"]
|
|
3348
|
+
config.verify_token = ENV["INSTAGRAM_VERIFY_TOKEN"]
|
|
3349
|
+
config.app_id = ENV["INSTAGRAM_APP_ID"]
|
|
3350
|
+
config.app_secret = ENV["INSTAGRAM_APP_SECRET"]
|
|
3351
|
+
config.skip_signature_validation = ENV["INSTAGRAM_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
3352
|
+
end
|
|
3353
|
+
|
|
3354
|
+
config
|
|
3355
|
+
end
|
|
3356
|
+
|
|
3357
|
+
def valid?
|
|
3358
|
+
access_token.present? && page_id.present? && verify_token.present?
|
|
3359
|
+
end
|
|
3360
|
+
|
|
3361
|
+
# On the Facebook Login path the webhook entry is keyed on the linked page,
|
|
3362
|
+
# not the Instagram account, so that is what an inbound event is checked
|
|
3363
|
+
# against.
|
|
3364
|
+
def account_id
|
|
3365
|
+
page_id
|
|
3366
|
+
end
|
|
3367
|
+
|
|
3368
|
+
def messages_url
|
|
3369
|
+
"#{api_base_url}/#{page_id}/messages"
|
|
3370
|
+
end
|
|
3371
|
+
|
|
3372
|
+
def attachment_upload_url
|
|
3373
|
+
"#{api_base_url}/#{page_id}/message_attachments"
|
|
3374
|
+
end
|
|
3375
|
+
|
|
3376
|
+
def api_base_url
|
|
3377
|
+
FlowChat::Config.instagram.api_base_url
|
|
3378
|
+
end
|
|
3379
|
+
|
|
3380
|
+
def api_headers
|
|
3381
|
+
{
|
|
3382
|
+
"Authorization" => "Bearer #{access_token}",
|
|
3383
|
+
"Content-Type" => "application/json"
|
|
3384
|
+
}
|
|
3385
|
+
end
|
|
3386
|
+
end
|
|
3387
|
+
end
|
|
3388
|
+
end
|
|
3389
|
+
```
|
|
3390
|
+
|
|
3391
|
+
- [ ] **Step 5: Run the test**
|
|
3392
|
+
|
|
3393
|
+
Run: `ruby -Itest test/unit/instagram/configuration_test.rb`
|
|
3394
|
+
Expected: PASS.
|
|
3395
|
+
|
|
3396
|
+
- [ ] **Step 6: Commit**
|
|
3397
|
+
|
|
3398
|
+
```bash
|
|
3399
|
+
git add lib/flow_chat/instagram/configuration.rb lib/flow_chat/config.rb test/unit/instagram/configuration_test.rb
|
|
3400
|
+
git commit -m "feat(instagram): configure the account a gateway speaks for"
|
|
3401
|
+
```
|
|
3402
|
+
|
|
3403
|
+
---
|
|
3404
|
+
|
|
3405
|
+
## Task 16: Instagram renderer, client, gateway and choice mapper
|
|
3406
|
+
|
|
3407
|
+
**Goal:** The Instagram platform, differing from Messenger in three ways: the body is always numbered, text is measured in bytes, and the limits come from `Config.instagram`.
|
|
3408
|
+
|
|
3409
|
+
**Files:**
|
|
3410
|
+
- Create: `lib/flow_chat/instagram/renderer.rb`
|
|
3411
|
+
- Create: `lib/flow_chat/instagram/client.rb`
|
|
3412
|
+
- Create: `lib/flow_chat/instagram/gateway/send_api.rb`
|
|
3413
|
+
- Create: `lib/flow_chat/instagram/middleware/choice_mapper.rb`
|
|
3414
|
+
- Create: `test/unit/instagram/renderer_test.rb`
|
|
3415
|
+
- Create: `test/unit/instagram/client_test.rb`
|
|
3416
|
+
|
|
3417
|
+
**Acceptance Criteria:**
|
|
3418
|
+
- [ ] Quick replies and carousels both also carry a numbered list in the body
|
|
3419
|
+
- [ ] A screen with no choices has no numbered list
|
|
3420
|
+
- [ ] Text is split by **bytes**, not characters: a 1000-byte cap with multibyte characters produces more chunks than a naive character count would
|
|
3421
|
+
- [ ] The choice mapper always stores the position map, so a typed number works at every rung
|
|
3422
|
+
- [ ] `Instagram::Gateway::SendApi` subclasses `Meta::MessagingGateway` directly, not `Messenger::Gateway::SendApi`
|
|
3423
|
+
|
|
3424
|
+
**Verify:** `ruby -Itest test/unit/instagram/renderer_test.rb && ruby -Itest test/unit/instagram/client_test.rb` → PASS
|
|
3425
|
+
|
|
3426
|
+
**Steps:**
|
|
3427
|
+
|
|
3428
|
+
- [ ] **Step 1: Write the failing renderer test**
|
|
3429
|
+
|
|
3430
|
+
Create `test/unit/instagram/renderer_test.rb`:
|
|
3431
|
+
|
|
3432
|
+
```ruby
|
|
3433
|
+
require "test_helper"
|
|
3434
|
+
|
|
3435
|
+
class InstagramRendererTest < Minitest::Test
|
|
3436
|
+
def render(message, choices: nil, media: nil)
|
|
3437
|
+
FlowChat::Instagram::Renderer.new(message, choices: choices, media: media).render
|
|
3438
|
+
end
|
|
3439
|
+
|
|
3440
|
+
# Quick replies and carousels render on mobile only. Without numbers in the
|
|
3441
|
+
# body a user on desktop gets a prompt with no way to answer it.
|
|
3442
|
+
def test_quick_replies_also_number_the_body
|
|
3443
|
+
result = render("Pick one", choices: {"a" => "Alpha", "b" => "Beta"})
|
|
3444
|
+
|
|
3445
|
+
assert_equal :quick_replies, result[0]
|
|
3446
|
+
assert_includes result[1], "1. Alpha"
|
|
3447
|
+
assert_includes result[1], "2. Beta"
|
|
3448
|
+
end
|
|
3449
|
+
|
|
3450
|
+
def test_carousel_also_numbers_the_body
|
|
3451
|
+
choices = (1..14).to_h { |i| ["k#{i}", "Option #{i}"] }
|
|
3452
|
+
|
|
3453
|
+
result = render("Pick one", choices: choices)
|
|
3454
|
+
|
|
3455
|
+
assert_equal :carousel, result[0]
|
|
3456
|
+
assert_includes result[1], "14. Option 14"
|
|
3457
|
+
end
|
|
3458
|
+
|
|
3459
|
+
def test_no_choices_means_no_numbers
|
|
3460
|
+
result = render("Just a message")
|
|
3461
|
+
|
|
3462
|
+
assert_equal "Just a message", result[1]
|
|
3463
|
+
end
|
|
3464
|
+
end
|
|
3465
|
+
```
|
|
3466
|
+
|
|
3467
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
3468
|
+
|
|
3469
|
+
Run: `ruby -Itest test/unit/instagram/renderer_test.rb`
|
|
3470
|
+
Expected: FAIL with `NameError: uninitialized constant FlowChat::Instagram::Renderer`.
|
|
3471
|
+
|
|
3472
|
+
- [ ] **Step 3: Write the renderer**
|
|
3473
|
+
|
|
3474
|
+
Create `lib/flow_chat/instagram/renderer.rb`. It is `Messenger::Renderer` with two hooks flipped:
|
|
3475
|
+
|
|
3476
|
+
```ruby
|
|
3477
|
+
module FlowChat
|
|
3478
|
+
module Instagram
|
|
3479
|
+
class Renderer < FlowChat::Messenger::Renderer
|
|
3480
|
+
private
|
|
3481
|
+
|
|
3482
|
+
def limits
|
|
3483
|
+
FlowChat::Config.instagram
|
|
3484
|
+
end
|
|
3485
|
+
|
|
3486
|
+
# Quick replies and carousels are mobile only on Instagram, so the options
|
|
3487
|
+
# are always listed in the body as well. A user on desktop sees the prompt
|
|
3488
|
+
# and nothing tappable, and without the list has no way to reply.
|
|
3489
|
+
def always_number?
|
|
3490
|
+
true
|
|
3491
|
+
end
|
|
3492
|
+
end
|
|
3493
|
+
end
|
|
3494
|
+
end
|
|
3495
|
+
```
|
|
3496
|
+
|
|
3497
|
+
This is the one place Instagram inherits from Messenger, and it is deliberate: the renderers really are the same algorithm with different constants. The gateways are not, which is why they are siblings.
|
|
3498
|
+
|
|
3499
|
+
- [ ] **Step 4: Write the failing client test**
|
|
3500
|
+
|
|
3501
|
+
Create `test/unit/instagram/client_test.rb`, mirroring `test/unit/messenger/client_test.rb`, plus the byte-splitting case:
|
|
3502
|
+
|
|
3503
|
+
```ruby
|
|
3504
|
+
# Meta measures Instagram text in bytes, not characters.
|
|
3505
|
+
def test_text_is_split_by_bytes
|
|
3506
|
+
# Each "é" is 2 bytes, so 600 of them is 1200 bytes: over the 1000 cap
|
|
3507
|
+
# even though the character count is not.
|
|
3508
|
+
text = (["é" * 60] * 10).join(" ")
|
|
3509
|
+
assert_operator text.length, :<, 1000
|
|
3510
|
+
assert_operator text.bytesize, :>, 1000
|
|
3511
|
+
|
|
3512
|
+
@client.send_message("igsid_1", text)
|
|
3513
|
+
|
|
3514
|
+
assert_requested(:post, @config.messages_url, times: 2)
|
|
3515
|
+
end
|
|
3516
|
+
```
|
|
3517
|
+
|
|
3518
|
+
- [ ] **Step 5: Write the client**
|
|
3519
|
+
|
|
3520
|
+
Create `lib/flow_chat/instagram/client.rb`:
|
|
3521
|
+
|
|
3522
|
+
```ruby
|
|
3523
|
+
module FlowChat
|
|
3524
|
+
module Instagram
|
|
3525
|
+
class Client < FlowChat::Messenger::Client
|
|
3526
|
+
private
|
|
3527
|
+
|
|
3528
|
+
def renderer_class
|
|
3529
|
+
FlowChat::Instagram::Renderer
|
|
3530
|
+
end
|
|
3531
|
+
|
|
3532
|
+
def platform
|
|
3533
|
+
:instagram
|
|
3534
|
+
end
|
|
3535
|
+
|
|
3536
|
+
def limits
|
|
3537
|
+
FlowChat::Config.instagram
|
|
3538
|
+
end
|
|
3539
|
+
|
|
3540
|
+
# Meta: "Message text must be UTF-8 and be 1,000 bytes or less." A
|
|
3541
|
+
# character count would let multibyte text through and be rejected.
|
|
3542
|
+
def measure(string)
|
|
3543
|
+
string.bytesize
|
|
3544
|
+
end
|
|
3545
|
+
end
|
|
3546
|
+
end
|
|
3547
|
+
end
|
|
3548
|
+
```
|
|
3549
|
+
|
|
3550
|
+
- [ ] **Step 6: Write the gateway and choice mapper**
|
|
3551
|
+
|
|
3552
|
+
Create `lib/flow_chat/instagram/gateway/send_api.rb`:
|
|
3553
|
+
|
|
3554
|
+
```ruby
|
|
3555
|
+
module FlowChat
|
|
3556
|
+
module Instagram
|
|
3557
|
+
module Gateway
|
|
3558
|
+
# Instagram DMs, on the shared Messenger Platform envelope.
|
|
3559
|
+
#
|
|
3560
|
+
# A sibling of the Messenger gateway rather than a subclass of it: the two
|
|
3561
|
+
# differ in credentials, limits and subscription object, and neither owns
|
|
3562
|
+
# the other.
|
|
3563
|
+
class SendApi < FlowChat::Meta::MessagingGateway
|
|
3564
|
+
def platform
|
|
3565
|
+
:instagram
|
|
3566
|
+
end
|
|
3567
|
+
|
|
3568
|
+
def gateway_name
|
|
3569
|
+
:instagram_send_api
|
|
3570
|
+
end
|
|
3571
|
+
|
|
3572
|
+
def configuration_class
|
|
3573
|
+
FlowChat::Instagram::Configuration
|
|
3574
|
+
end
|
|
3575
|
+
|
|
3576
|
+
def client_class
|
|
3577
|
+
FlowChat::Instagram::Client
|
|
3578
|
+
end
|
|
3579
|
+
|
|
3580
|
+
def renderer_class
|
|
3581
|
+
FlowChat::Instagram::Renderer
|
|
3582
|
+
end
|
|
3583
|
+
|
|
3584
|
+
def self.choice_mapper_class
|
|
3585
|
+
FlowChat::Instagram::Middleware::ChoiceMapper
|
|
3586
|
+
end
|
|
3587
|
+
|
|
3588
|
+
# Confirm against the Meta app dashboard before relying on this. On the
|
|
3589
|
+
# Facebook Login path Meta's own docs were ambiguous about whether these
|
|
3590
|
+
# arrive under "page" or "instagram", which is why it is a hook.
|
|
3591
|
+
def expected_webhook_object
|
|
3592
|
+
"instagram"
|
|
3593
|
+
end
|
|
3594
|
+
|
|
3595
|
+
private
|
|
3596
|
+
|
|
3597
|
+
def configuration_error_class
|
|
3598
|
+
FlowChat::Instagram::ConfigurationError
|
|
3599
|
+
end
|
|
3600
|
+
|
|
3601
|
+
def platform_label
|
|
3602
|
+
"Instagram"
|
|
3603
|
+
end
|
|
3604
|
+
end
|
|
3605
|
+
end
|
|
3606
|
+
end
|
|
3607
|
+
end
|
|
3608
|
+
```
|
|
3609
|
+
|
|
3610
|
+
Create `lib/flow_chat/instagram/middleware/choice_mapper.rb`:
|
|
3611
|
+
|
|
3612
|
+
```ruby
|
|
3613
|
+
module FlowChat
|
|
3614
|
+
module Instagram
|
|
3615
|
+
module Middleware
|
|
3616
|
+
class ChoiceMapper < FlowChat::Messenger::Middleware::ChoiceMapper
|
|
3617
|
+
ID_KEY = "instagram.choice_mapping"
|
|
3618
|
+
POSITION_KEY = "instagram.position_mapping"
|
|
3619
|
+
|
|
3620
|
+
private
|
|
3621
|
+
|
|
3622
|
+
def platform_limits
|
|
3623
|
+
FlowChat::Config.instagram
|
|
3624
|
+
end
|
|
3625
|
+
|
|
3626
|
+
# The body always carries numbers here, so a typed number must always
|
|
3627
|
+
# resolve.
|
|
3628
|
+
def always_number?
|
|
3629
|
+
true
|
|
3630
|
+
end
|
|
3631
|
+
end
|
|
3632
|
+
end
|
|
3633
|
+
end
|
|
3634
|
+
end
|
|
3635
|
+
```
|
|
3636
|
+
|
|
3637
|
+
- [ ] **Step 7: Run the tests**
|
|
3638
|
+
|
|
3639
|
+
Run: `ruby -Itest test/unit/instagram/renderer_test.rb && ruby -Itest test/unit/instagram/client_test.rb`
|
|
3640
|
+
Expected: PASS.
|
|
3641
|
+
|
|
3642
|
+
- [ ] **Step 8: Run the full suite**
|
|
3643
|
+
|
|
3644
|
+
Run: `bundle exec rake test`
|
|
3645
|
+
Expected: PASS, 0 failures.
|
|
3646
|
+
|
|
3647
|
+
- [ ] **Step 9: Commit**
|
|
3648
|
+
|
|
3649
|
+
```bash
|
|
3650
|
+
git add lib/flow_chat/instagram/ test/unit/instagram/
|
|
3651
|
+
git commit -m "feat(instagram): add the gateway, numbering options for desktop
|
|
3652
|
+
|
|
3653
|
+
Quick replies and carousels render on mobile Instagram only, so the
|
|
3654
|
+
renderer lists the options numbered in the body at every rung and the
|
|
3655
|
+
choice mapper always keeps the positions. Without that a user on a
|
|
3656
|
+
browser sees a prompt with nothing tappable and no way to answer.
|
|
3657
|
+
|
|
3658
|
+
Text is measured in bytes, since Meta caps Instagram messages at 1,000
|
|
3659
|
+
bytes rather than 1,000 characters."
|
|
3660
|
+
```
|
|
3661
|
+
|
|
3662
|
+
---
|
|
3663
|
+
|
|
3664
|
+
## Task 17: Instagram integration test
|
|
3665
|
+
|
|
3666
|
+
**Goal:** A full webhook-to-send cycle on Instagram, including a typed-number reply.
|
|
3667
|
+
|
|
3668
|
+
**Files:**
|
|
3669
|
+
- Create: `test/integration/instagram_integration_test.rb`
|
|
3670
|
+
|
|
3671
|
+
**Acceptance Criteria:**
|
|
3672
|
+
- [ ] A conversation advances via a tapped quick reply
|
|
3673
|
+
- [ ] The same conversation advances via a typed number, proving the desktop path works
|
|
3674
|
+
- [ ] A webhook whose `object` does not match `expected_webhook_object` is ignored with 200
|
|
3675
|
+
|
|
3676
|
+
**Verify:** `ruby -Itest test/integration/instagram_integration_test.rb` → PASS
|
|
3677
|
+
|
|
3678
|
+
**Steps:**
|
|
3679
|
+
|
|
3680
|
+
- [ ] **Step 1: Write the test**
|
|
3681
|
+
|
|
3682
|
+
Create `test/integration/instagram_integration_test.rb`, modelled on `test/integration/messenger_integration_test.rb` from Task 14, with the two reply styles:
|
|
3683
|
+
|
|
3684
|
+
```ruby
|
|
3685
|
+
def test_typed_number_advances_the_flow
|
|
3686
|
+
run_webhook(text: "Hello")
|
|
3687
|
+
assert_match(/What is your name/, last_sent_prompt)
|
|
3688
|
+
|
|
3689
|
+
run_webhook(text: "Ama")
|
|
3690
|
+
assert_match(/Choose a plan/, last_sent_prompt)
|
|
3691
|
+
assert_match(/1\. Basic/, last_sent_prompt)
|
|
3692
|
+
|
|
3693
|
+
run_webhook(text: "2")
|
|
3694
|
+
assert_match(/you chose pro/, last_sent_prompt)
|
|
3695
|
+
end
|
|
3696
|
+
|
|
3697
|
+
def test_webhook_for_another_object_is_ignored
|
|
3698
|
+
context = run_raw_webhook({"object" => "page", "entry" => []})
|
|
3699
|
+
|
|
3700
|
+
assert_equal :ok, context.controller.last_head_status
|
|
3701
|
+
end
|
|
3702
|
+
```
|
|
3703
|
+
|
|
3704
|
+
- [ ] **Step 2: Run it**
|
|
3705
|
+
|
|
3706
|
+
Run: `ruby -Itest test/integration/instagram_integration_test.rb`
|
|
3707
|
+
Expected: PASS.
|
|
3708
|
+
|
|
3709
|
+
- [ ] **Step 3: Run the full suite**
|
|
3710
|
+
|
|
3711
|
+
Run: `bundle exec rake test`
|
|
3712
|
+
Expected: PASS, 0 failures.
|
|
3713
|
+
|
|
3714
|
+
- [ ] **Step 4: Commit**
|
|
3715
|
+
|
|
3716
|
+
```bash
|
|
3717
|
+
git add test/integration/instagram_integration_test.rb
|
|
3718
|
+
git commit -m "test(instagram): drive a conversation by tap and by typed number"
|
|
3719
|
+
```
|
|
3720
|
+
|
|
3721
|
+
---
|
|
3722
|
+
|
|
3723
|
+
## Task 18: Simulator support
|
|
3724
|
+
|
|
3725
|
+
**Goal:** Both platforms selectable in the built-in simulator.
|
|
3726
|
+
|
|
3727
|
+
**Note on scope:** the simulator is not extensible by configuration alone. `lib/flow_chat/simulator/controller.rb:29-70` holds a `configurations` hash, which is the easy part, but `lib/flow_chat/simulator/views/simulator.html.erb` branches on `processor_type` in embedded JavaScript at six points (lines 1109, 1129-1136, 1187-1191, 1212-1214, 1221-1225) and carries per-platform screen chrome (`#whatsapp-screen`, line 941). Both new platforms render as chat bubbles exactly like WhatsApp, so they reuse the WhatsApp screen chrome rather than growing two more copies of it.
|
|
3728
|
+
|
|
3729
|
+
**Files:**
|
|
3730
|
+
- Modify: `lib/flow_chat/simulator/controller.rb:29-70`
|
|
3731
|
+
- Modify: `lib/flow_chat/simulator/views/simulator.html.erb` (the six `processor_type` branches and the screen selector)
|
|
3732
|
+
|
|
3733
|
+
**Acceptance Criteria:**
|
|
3734
|
+
- [ ] Messenger and Instagram appear in the simulator's platform selector
|
|
3735
|
+
- [ ] Both post the `entry[].messaging[]` body shape their gateway parses, with `simulator_mode: true`
|
|
3736
|
+
- [ ] Both render in the chat-bubble screen, not the USSD screen
|
|
3737
|
+
- [ ] A simulated send returns the rendered payload as JSON rather than calling the Send API
|
|
3738
|
+
- [ ] The signed-cookie gate still applies: no valid cookie means no simulator mode
|
|
3739
|
+
|
|
3740
|
+
**Verify:** `bundle exec rake test` → PASS, and loading the simulator page shows five platforms
|
|
3741
|
+
|
|
3742
|
+
**Steps:**
|
|
3743
|
+
|
|
3744
|
+
- [ ] **Step 1: Add the two configurations**
|
|
3745
|
+
|
|
3746
|
+
In `lib/flow_chat/simulator/controller.rb`, add to the `configurations` hash after the `whatsapp` entry:
|
|
3747
|
+
|
|
3748
|
+
```ruby
|
|
3749
|
+
messenger: {
|
|
3750
|
+
name: "Messenger (Send API)",
|
|
3751
|
+
description: "Facebook Messenger integration using the Send API",
|
|
3752
|
+
processor_type: "messenger",
|
|
3753
|
+
gateway: "send_api",
|
|
3754
|
+
endpoint: "/messenger/webhook",
|
|
3755
|
+
icon: "💬",
|
|
3756
|
+
color: "#0084FF",
|
|
3757
|
+
settings: {
|
|
3758
|
+
user_id: default_phone_number,
|
|
3759
|
+
contact_name: default_contact_name
|
|
3760
|
+
}
|
|
3761
|
+
},
|
|
3762
|
+
instagram: {
|
|
3763
|
+
name: "Instagram (Send API)",
|
|
3764
|
+
description: "Instagram DM integration using the Send API",
|
|
3765
|
+
processor_type: "instagram",
|
|
3766
|
+
gateway: "send_api",
|
|
3767
|
+
endpoint: "/instagram/webhook",
|
|
3768
|
+
icon: "📷",
|
|
3769
|
+
color: "#E1306C",
|
|
3770
|
+
settings: {
|
|
3771
|
+
user_id: default_phone_number,
|
|
3772
|
+
contact_name: default_contact_name
|
|
3773
|
+
}
|
|
3774
|
+
},
|
|
3775
|
+
```
|
|
3776
|
+
|
|
3777
|
+
- [ ] **Step 2: Introduce one predicate instead of six string comparisons**
|
|
3778
|
+
|
|
3779
|
+
In `simulator.html.erb`, the JS asks `processor_type === 'whatsapp'` in six places to mean "this platform is a chat bubble UI". Replace those comparisons with a single helper defined next to the other state helpers, so adding a sixth platform later is one edit rather than six:
|
|
3780
|
+
|
|
3781
|
+
```javascript
|
|
3782
|
+
const CHAT_PLATFORMS = ['whatsapp', 'messenger', 'instagram']
|
|
3783
|
+
|
|
3784
|
+
function isChatPlatform(processorType) {
|
|
3785
|
+
return CHAT_PLATFORMS.includes(processorType)
|
|
3786
|
+
}
|
|
3787
|
+
```
|
|
3788
|
+
|
|
3789
|
+
Then at each of lines 1109, 1130, 1136, 1189, 1212 and 1223, replace the `=== 'whatsapp'` test with `isChatPlatform(...)`. Read each branch before editing: some build the request body and need the per-platform shape from Step 3, not just the shared predicate.
|
|
3790
|
+
|
|
3791
|
+
- [ ] **Step 3: Build the right webhook body per platform**
|
|
3792
|
+
|
|
3793
|
+
The body-building branches (around lines 1187-1191 and 1212-1225) must send the Messenger Platform envelope for the two new platforms, not the WhatsApp one:
|
|
3794
|
+
|
|
3795
|
+
```javascript
|
|
3796
|
+
function buildMessagingBody(processorType, text) {
|
|
3797
|
+
return {
|
|
3798
|
+
object: processorType === 'instagram' ? 'instagram' : 'page',
|
|
3799
|
+
entry: [{
|
|
3800
|
+
id: state.currentConfig.settings.page_id || 'page_1',
|
|
3801
|
+
messaging: [{
|
|
3802
|
+
sender: { id: state.currentConfig.settings.user_id },
|
|
3803
|
+
recipient: { id: state.currentConfig.settings.page_id || 'page_1' },
|
|
3804
|
+
timestamp: Date.now(),
|
|
3805
|
+
message: { mid: 'mid.' + Date.now(), text: text }
|
|
3806
|
+
}]
|
|
3807
|
+
}],
|
|
3808
|
+
simulator_mode: true
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
```
|
|
3812
|
+
|
|
3813
|
+
Wire `messenger` and `instagram` to this builder. If Task 20 changes Instagram's `expected_webhook_object` to `page`, this `object` expression changes with it.
|
|
3814
|
+
|
|
3815
|
+
- [ ] **Step 4: Point both at the chat screen**
|
|
3816
|
+
|
|
3817
|
+
At line 1136 the screen toggle hides `#whatsapp-screen` unless the platform is WhatsApp. Use `isChatPlatform` so both new platforms show the same bubble screen. Confirm the header label at line 942 reads from config rather than being hardcoded to "WhatsApp"; if it is hardcoded, drive it from `state.currentConfig.name`.
|
|
3818
|
+
|
|
3819
|
+
- [ ] **Step 5: Verify the gate still holds**
|
|
3820
|
+
|
|
3821
|
+
Confirm `simulate?` in `Meta::MessagingGateway` requires both `context["enable_simulator"]` and a valid cookie. Then confirm no test passes with the cookie removed.
|
|
3822
|
+
|
|
3823
|
+
Run: `ruby -Itest test/unit/security_test.rb`
|
|
3824
|
+
Expected: PASS.
|
|
3825
|
+
|
|
3826
|
+
- [ ] **Step 6: Load the page**
|
|
3827
|
+
|
|
3828
|
+
Start the simulator however the project normally does (check `docs/testing.md` for the documented route) and confirm five platforms appear, that selecting Messenger shows the bubble screen, and that sending a message returns a JSON `would_send` payload.
|
|
3829
|
+
|
|
3830
|
+
- [ ] **Step 7: Run the full suite**
|
|
3831
|
+
|
|
3832
|
+
Run: `bundle exec rake test`
|
|
3833
|
+
Expected: PASS, 0 failures.
|
|
3834
|
+
|
|
3835
|
+
- [ ] **Step 8: Commit**
|
|
3836
|
+
|
|
3837
|
+
```bash
|
|
3838
|
+
git add lib/flow_chat/simulator/
|
|
3839
|
+
git commit -m "feat(simulator): add messenger and instagram
|
|
3840
|
+
|
|
3841
|
+
The view asked processor_type === 'whatsapp' in six places to mean \"this
|
|
3842
|
+
platform draws chat bubbles\". That is now one predicate over a list, so
|
|
3843
|
+
the two new platforms reuse the bubble screen instead of copying it."
|
|
3844
|
+
```
|
|
3845
|
+
|
|
3846
|
+
---
|
|
3847
|
+
|
|
3848
|
+
## Task 19: Documentation
|
|
3849
|
+
|
|
3850
|
+
**Goal:** Both platforms documented to the standard of the existing platform guides.
|
|
3851
|
+
|
|
3852
|
+
**Files:**
|
|
3853
|
+
- Create: `docs/platforms/messenger.md`, `docs/platforms/instagram.md`
|
|
3854
|
+
- Modify: `README.md:26`, `README.md:74`, `README.md:186`, `README.md:236`
|
|
3855
|
+
- Modify: `docs/gateway-context-variables.md`
|
|
3856
|
+
|
|
3857
|
+
**Acceptance Criteria:**
|
|
3858
|
+
- [ ] Each platform guide covers setup, credentials, webhook fields, the choice ladder with its real numbers, media, echoes and coexistence, and limits
|
|
3859
|
+
- [ ] The README platform table gains both gateway classes, platform symbols and rendering summaries
|
|
3860
|
+
- [ ] The README platform-differences table states the real caps: 13 quick replies, 30 via carousel, Instagram mobile only, 1000 bytes
|
|
3861
|
+
- [ ] `docs/gateway-context-variables.md` lists what both gateways set, including `request.msisdn` being nil
|
|
3862
|
+
- [ ] No em-dashes anywhere in the new or edited prose
|
|
3863
|
+
|
|
3864
|
+
**Verify:** `grep -c "—" docs/platforms/messenger.md docs/platforms/instagram.md` → 0 for both
|
|
3865
|
+
|
|
3866
|
+
**Steps:**
|
|
3867
|
+
|
|
3868
|
+
- [ ] **Step 1: Read the model**
|
|
3869
|
+
|
|
3870
|
+
Read `docs/platforms/whatsapp.md` and `docs/platforms/telegram.md` in full. Match their structure and register: dense plain prose, real limits, documented edge cases, no marketing adjectives.
|
|
3871
|
+
|
|
3872
|
+
- [ ] **Step 2: Write the two guides**
|
|
3873
|
+
|
|
3874
|
+
Each guide covers, in this order: what the platform is and which Meta product it uses; credentials and where they come from; webhook setup and the fields to subscribe; a wiring example with `use_gateway`; the choice ladder table with real numbers; media in and out; echoes and what `echo_origin` means for coexistence; the 24 hour window and what happens when a send is rejected; and a limits table.
|
|
3875
|
+
|
|
3876
|
+
For Instagram, state plainly that quick replies and carousels do not render on desktop, and that this is why the options are always numbered in the body.
|
|
3877
|
+
|
|
3878
|
+
- [ ] **Step 3: Update the README**
|
|
3879
|
+
|
|
3880
|
+
Add rows to the platform table at `README.md:74`:
|
|
3881
|
+
|
|
3882
|
+
| Platform | Gateway class | Platform symbol | Rendering |
|
|
3883
|
+
|---|---|---|---|
|
|
3884
|
+
| Messenger | `FlowChat::Messenger::Gateway::SendApi` | `:messenger` | Quick replies, carousel, numbered text |
|
|
3885
|
+
| Instagram | `FlowChat::Instagram::Gateway::SendApi` | `:instagram` | Quick replies, carousel, always numbered |
|
|
3886
|
+
|
|
3887
|
+
Add both to the platform-differences table at `README.md:186`, the intro sentence at `README.md:26`, and the docs index at `README.md:236`.
|
|
3888
|
+
|
|
3889
|
+
- [ ] **Step 4: Update the context-variables doc**
|
|
3890
|
+
|
|
3891
|
+
Add a column or section for both gateways in `docs/gateway-context-variables.md`, noting `request.msisdn` is nil and `request.user_id` holds the PSID or IGSID.
|
|
3892
|
+
|
|
3893
|
+
- [ ] **Step 5: Check the prose**
|
|
3894
|
+
|
|
3895
|
+
Run: `grep -n "—" docs/platforms/messenger.md docs/platforms/instagram.md README.md docs/gateway-context-variables.md`
|
|
3896
|
+
Expected: no output for the new files. Pre-existing em-dashes elsewhere in the README are not this task's business unless they are in a line you edited.
|
|
3897
|
+
|
|
3898
|
+
- [ ] **Step 6: Commit**
|
|
3899
|
+
|
|
3900
|
+
```bash
|
|
3901
|
+
git add docs/platforms/messenger.md docs/platforms/instagram.md README.md docs/gateway-context-variables.md
|
|
3902
|
+
git commit -m "docs: document messenger and instagram"
|
|
3903
|
+
```
|
|
3904
|
+
|
|
3905
|
+
---
|
|
3906
|
+
|
|
3907
|
+
## Task 20: Verify the two unresolvable facts with the user
|
|
3908
|
+
|
|
3909
|
+
**Goal:** Close the two gaps that cannot be settled from this machine.
|
|
3910
|
+
|
|
3911
|
+
**Files:**
|
|
3912
|
+
- Modify: `lib/flow_chat/instagram/gateway/send_api.rb` (`expected_webhook_object`, if the answer differs)
|
|
3913
|
+
- Modify: `lib/flow_chat/instagram/renderer.rb` (drop the carousel rung, if it reads badly)
|
|
3914
|
+
|
|
3915
|
+
**Acceptance Criteria:**
|
|
3916
|
+
- [ ] The Instagram `expected_webhook_object` matches what the user's Meta app dashboard actually shows
|
|
3917
|
+
- [ ] The Instagram carousel decision is confirmed against a real device, or the rung is dropped
|
|
3918
|
+
- [ ] Any code change from the answers is made and tested
|
|
3919
|
+
|
|
3920
|
+
**Verify:** `bundle exec rake test` → PASS after any change
|
|
3921
|
+
|
|
3922
|
+
**User Verification Required:**
|
|
3923
|
+
Before marking this task complete, you MUST call AskUserQuestion:
|
|
3924
|
+
```yaml
|
|
3925
|
+
AskUserQuestion:
|
|
3926
|
+
question: "Two things I cannot check from here. In your Meta app dashboard, which webhook object are Instagram messaging events subscribed under, and does the Instagram carousel look acceptable for a plain list of options on a real device?"
|
|
3927
|
+
header: "Verification"
|
|
3928
|
+
options:
|
|
3929
|
+
- label: "object is instagram, carousel is fine"
|
|
3930
|
+
description: "Keep expected_webhook_object as \"instagram\" and keep the carousel rung between 14 and 30 choices"
|
|
3931
|
+
- label: "object is page, carousel is fine"
|
|
3932
|
+
description: "Change expected_webhook_object to \"page\" and keep the carousel rung"
|
|
3933
|
+
- label: "object is instagram, carousel reads badly"
|
|
3934
|
+
description: "Keep the object and drop the carousel rung on Instagram, so above 13 choices goes straight to numbered text"
|
|
3935
|
+
- label: "object is page, carousel reads badly"
|
|
3936
|
+
description: "Change the object to \"page\" and drop the carousel rung on Instagram"
|
|
3937
|
+
```
|
|
3938
|
+
|
|
3939
|
+
**If the user selects an option indicating rework:** apply the change, re-run the suite, and re-verify with AskUserQuestion again.
|
|
3940
|
+
|
|
3941
|
+
**Steps:**
|
|
3942
|
+
|
|
3943
|
+
- [ ] **Step 1: Ask**
|
|
3944
|
+
|
|
3945
|
+
Call `AskUserQuestion` exactly as above. Do not guess either answer.
|
|
3946
|
+
|
|
3947
|
+
- [ ] **Step 2: Apply the webhook object answer**
|
|
3948
|
+
|
|
3949
|
+
If the answer is `page`, change `expected_webhook_object` in `lib/flow_chat/instagram/gateway/send_api.rb` to `"page"` and update the comment to record that it was confirmed rather than assumed. Update the Instagram integration test's payloads to match.
|
|
3950
|
+
|
|
3951
|
+
- [ ] **Step 3: Apply the carousel answer**
|
|
3952
|
+
|
|
3953
|
+
If the carousel reads badly, override the ladder in `lib/flow_chat/instagram/renderer.rb` so `:carousel` is never chosen:
|
|
3954
|
+
|
|
3955
|
+
```ruby
|
|
3956
|
+
# Confirmed on a real device: the carousel needs a title per card and a
|
|
3957
|
+
# plain option list has none, so it reads as noise. Above the quick-reply
|
|
3958
|
+
# cap the options go in the body instead.
|
|
3959
|
+
def render
|
|
3960
|
+
return build_attachment if media && choices.blank?
|
|
3961
|
+
|
|
3962
|
+
rung = FlowChat::Meta::ChoiceLadder.rung_for(choice_count, limits)
|
|
3963
|
+
rung = :numbered if rung == :carousel
|
|
3964
|
+
|
|
3965
|
+
case rung
|
|
3966
|
+
when :none then build_text
|
|
3967
|
+
when :quick_replies then build_quick_replies
|
|
3968
|
+
when :numbered then build_numbered
|
|
3969
|
+
end
|
|
3970
|
+
end
|
|
3971
|
+
```
|
|
3972
|
+
|
|
3973
|
+
Update `test/unit/instagram/renderer_test.rb` to assert the new behavior, replacing `test_carousel_also_numbers_the_body`.
|
|
3974
|
+
|
|
3975
|
+
- [ ] **Step 4: Run the full suite**
|
|
3976
|
+
|
|
3977
|
+
Run: `bundle exec rake test`
|
|
3978
|
+
Expected: PASS, 0 failures.
|
|
3979
|
+
|
|
3980
|
+
- [ ] **Step 5: Update the spec's open items**
|
|
3981
|
+
|
|
3982
|
+
In `docs/superpowers/specs/2026-08-10-messenger-instagram-design.md`, replace the "Open items for implementation" entries 2 and 3 with what was confirmed, so the spec stops claiming they are unknown.
|
|
3983
|
+
|
|
3984
|
+
- [ ] **Step 6: Commit**
|
|
3985
|
+
|
|
3986
|
+
```bash
|
|
3987
|
+
git add lib/flow_chat/instagram/ test/ docs/superpowers/specs/2026-08-10-messenger-instagram-design.md
|
|
3988
|
+
git commit -m "fix(instagram): settle the webhook object and carousel questions"
|
|
3989
|
+
```
|
|
3990
|
+
|
|
3991
|
+
---
|
|
3992
|
+
|
|
3993
|
+
## Task 21: Confirm the Messenger text cap and close out
|
|
3994
|
+
|
|
3995
|
+
**Goal:** Replace the one assumed constant with a verified one, and confirm the whole branch is green.
|
|
3996
|
+
|
|
3997
|
+
**Files:**
|
|
3998
|
+
- Modify: `lib/flow_chat/config.rb` (`MessengerConfig#max_text_length`, if wrong)
|
|
3999
|
+
- Modify: `docs/superpowers/specs/2026-08-10-messenger-instagram-design.md` (open item 1)
|
|
4000
|
+
|
|
4001
|
+
**Acceptance Criteria:**
|
|
4002
|
+
- [ ] `max_text_length` for Messenger matches Meta's documented cap, verified rather than assumed
|
|
4003
|
+
- [ ] The spec's open item 1 is resolved
|
|
4004
|
+
- [ ] `bundle exec rake test` passes with 0 failures and 0 errors
|
|
4005
|
+
- [ ] `standardrb` (or whatever linter the Rakefile runs) is clean
|
|
4006
|
+
|
|
4007
|
+
**Verify:** `bundle exec rake test` → PASS, 0 failures, 0 errors
|
|
4008
|
+
|
|
4009
|
+
**Steps:**
|
|
4010
|
+
|
|
4011
|
+
- [ ] **Step 1: Verify the cap**
|
|
4012
|
+
|
|
4013
|
+
Fetch Meta's Send API reference for the `message.text` limit. The design assumed 2000 because the reference page would not render during design. If it is different, change `@max_text_length` in `MessengerConfig` and update the client test's long-text fixture so it still crosses the boundary.
|
|
4014
|
+
|
|
4015
|
+
- [ ] **Step 2: Update the spec**
|
|
4016
|
+
|
|
4017
|
+
Replace open item 1 with the confirmed number and its source.
|
|
4018
|
+
|
|
4019
|
+
- [ ] **Step 3: Run everything**
|
|
4020
|
+
|
|
4021
|
+
Run: `bundle exec rake test`
|
|
4022
|
+
Expected: PASS, 0 failures, 0 errors.
|
|
4023
|
+
|
|
4024
|
+
Run: `bundle exec rake -T` to find the lint task, then run it.
|
|
4025
|
+
Expected: clean.
|
|
4026
|
+
|
|
4027
|
+
- [ ] **Step 4: Review the whole diff**
|
|
4028
|
+
|
|
4029
|
+
Run: `git diff feat/coexistence-webhooks...HEAD --stat`
|
|
4030
|
+
Confirm nothing unintended was touched, particularly that `lib/flow_chat/whatsapp/id_generator.rb` is gone and the three configuration classes lost their duplicated registries.
|
|
4031
|
+
|
|
4032
|
+
- [ ] **Step 5: Commit**
|
|
4033
|
+
|
|
4034
|
+
```bash
|
|
4035
|
+
git add lib/flow_chat/config.rb docs/superpowers/specs/2026-08-10-messenger-instagram-design.md
|
|
4036
|
+
git commit -m "fix(messenger): use the documented text cap"
|
|
4037
|
+
```
|
|
4038
|
+
|
|
4039
|
+
---
|
|
4040
|
+
|
|
4041
|
+
## Spec coverage check
|
|
4042
|
+
|
|
4043
|
+
| Spec section | Task |
|
|
4044
|
+
|---|---|
|
|
4045
|
+
| Meta shared modules | 1, 2 |
|
|
4046
|
+
| NamedConfiguration extraction, all five | 3, 8, 15 |
|
|
4047
|
+
| IdGenerator with configurable cap | 4 |
|
|
4048
|
+
| `to_plain_text` | 5 |
|
|
4049
|
+
| WhatsApp fix 1, lists above 10 | 6 |
|
|
4050
|
+
| WhatsApp fix 2, echo origin | 7 |
|
|
4051
|
+
| Messenger configuration and `Config.messenger` | 8 |
|
|
4052
|
+
| Choice ladder | 9 |
|
|
4053
|
+
| Messenger renderer | 10 |
|
|
4054
|
+
| Messenger client, delivery hooks, splitting | 11 |
|
|
4055
|
+
| `Meta::MessagingGateway`, inbound dispatch, echoes, statuses, context, sessions | 12 |
|
|
4056
|
+
| Messenger gateway and choice mapper | 13 |
|
|
4057
|
+
| Messenger tests, async | 14 |
|
|
4058
|
+
| Instagram configuration | 15 |
|
|
4059
|
+
| Instagram renderer, client, gateway, choice mapper | 16 |
|
|
4060
|
+
| Instagram tests | 17 |
|
|
4061
|
+
| Simulator | 18 |
|
|
4062
|
+
| Docs and README | 19 |
|
|
4063
|
+
| Open items 2 and 3 | 20 |
|
|
4064
|
+
| Open item 1 | 21 |
|