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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -0,0 +1,732 @@
1
+ # Inbound Media Support Implementation Plan
2
+
3
+ > **Status: Superseded (historical).** This plan reflects the original approach. The
4
+ > shipped implementation diverged: input sentinels (`FlowChat::Input::MEDIA`/`$media$`,
5
+ > etc.) were replaced by a `FlowChat::Input` turn value object (`context.input` is
6
+ > always plain text), and `app.media` is now **always** an `Array<FlowChat::Media>`
7
+ > (the `media_items` accessor described below was dropped). See
8
+ > `docs/gateway-context-variables.md` for the current API.
9
+
10
+ > **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.
11
+
12
+ **Goal:** Expose inbound media (and location/contact) to flows across WhatsApp, Telegram, Intercom, and HTTP via a `FlowChat::Media` value object with metadata plus `url`/`download`.
13
+
14
+ **Architecture:** A new `FlowChat::Media` value object encapsulates all cross-platform inbound-media quirks (WhatsApp media-id, Telegram file_id, Intercom/HTTP direct URL) behind `#url`/`#download`. `App` normalizes `context["request.media"]` (single hash or array) into `Array<FlowChat::Media>` via `media_items`, with `media` as the first-item convenience. The Telegram client gains `getFile`-based download; the Intercom and HTTP gateways gain inbound media parsing (Intercom supports multiple attachments per message).
15
+
16
+ **Tech Stack:** Ruby, Zeitwerk autoloading, Minitest (minitest/mock), Net::HTTP.
17
+
18
+ **User Verification:** NO — no user verification required.
19
+
20
+ ---
21
+
22
+ ### Task 1: `FlowChat::Media` value object
23
+
24
+ **Goal:** A value object wrapping one inbound media item, exposing metadata plus per-platform `url`/`download`.
25
+
26
+ **Files:**
27
+ - Create: `lib/flow_chat/media.rb`
28
+ - Test: `test/unit/media_test.rb`
29
+
30
+ **Acceptance Criteria:**
31
+ - [ ] Readers expose `type`, `mime_type`, `caption`, `filename` (handles `:filename` and `:file_name`), `id`, `file_id`, `[]`, `to_h`.
32
+ - [ ] `#url` dispatches: WhatsApp → `client.get_media_url(id)`; Telegram → `client.file_url(file_id)`; else → `data[:url]`.
33
+ - [ ] `#download` dispatches: WhatsApp → `client.download_media(id)`; Telegram → `client.download_file(file_id)`; else → HTTP GET of `#url`.
34
+ - [ ] Autoloads as `FlowChat::Media` with no explicit require (Zeitwerk).
35
+
36
+ **Verify:** `ruby -Itest test/unit/media_test.rb -v` → all pass
37
+
38
+ **Steps:**
39
+
40
+ - [ ] **Step 1: Write the failing test**
41
+
42
+ Create `test/unit/media_test.rb`:
43
+
44
+ ```ruby
45
+ require "test_helper"
46
+
47
+ class MediaTest < Minitest::Test
48
+ def test_metadata_readers
49
+ m = FlowChat::Media.new(
50
+ {type: :image, mime_type: "image/jpeg", caption: "hi", filename: "a.jpg", id: "MID"},
51
+ platform: :whatsapp
52
+ )
53
+ assert_equal :image, m.type
54
+ assert_equal "image/jpeg", m.mime_type
55
+ assert_equal "hi", m.caption
56
+ assert_equal "a.jpg", m.filename
57
+ assert_equal "MID", m.id
58
+ assert_equal "image/jpeg", m[:mime_type]
59
+ end
60
+
61
+ def test_filename_falls_back_to_file_name_key
62
+ m = FlowChat::Media.new({type: :document, file_name: "doc.pdf"}, platform: :telegram)
63
+ assert_equal "doc.pdf", m.filename
64
+ end
65
+
66
+ def test_whatsapp_url_and_download_delegate_to_client
67
+ client = Minitest::Mock.new
68
+ client.expect(:get_media_url, "https://cdn/x.jpg", ["MID"])
69
+ client.expect(:download_media, "BYTES", ["MID"])
70
+ m = FlowChat::Media.new({type: :image, id: "MID"}, platform: :whatsapp, client: client)
71
+ assert_equal "https://cdn/x.jpg", m.url
72
+ assert_equal "BYTES", m.download
73
+ client.verify
74
+ end
75
+
76
+ def test_telegram_url_and_download_delegate_to_client
77
+ client = Minitest::Mock.new
78
+ client.expect(:file_url, "https://tg/file.jpg", ["FID"])
79
+ client.expect(:download_file, "TGBYTES", ["FID"])
80
+ m = FlowChat::Media.new({type: :photo, file_id: "FID"}, platform: :telegram, client: client)
81
+ assert_equal "https://tg/file.jpg", m.url
82
+ assert_equal "TGBYTES", m.download
83
+ client.verify
84
+ end
85
+
86
+ def test_url_based_platform_uses_direct_url
87
+ m = FlowChat::Media.new({type: :image, url: "https://intercom/a.png"}, platform: :intercom)
88
+ assert_equal "https://intercom/a.png", m.url
89
+ end
90
+ end
91
+ ```
92
+
93
+ - [ ] **Step 2: Run test to verify it fails**
94
+
95
+ Run: `ruby -Itest test/unit/media_test.rb -v`
96
+ Expected: FAIL — `uninitialized constant FlowChat::Media`
97
+
98
+ - [ ] **Step 3: Write the implementation**
99
+
100
+ Create `lib/flow_chat/media.rb`:
101
+
102
+ ```ruby
103
+ require "net/http"
104
+ require "uri"
105
+
106
+ module FlowChat
107
+ # Value object wrapping a single inbound media item parsed by a gateway.
108
+ # Normalizes cross-platform differences (WhatsApp media-id, Telegram file_id,
109
+ # Intercom/HTTP direct URL) behind #url and #download.
110
+ class Media
111
+ attr_reader :platform, :client
112
+
113
+ def initialize(data, platform:, client: nil)
114
+ @data = data
115
+ @platform = platform
116
+ @client = client
117
+ end
118
+
119
+ def type
120
+ @data[:type]
121
+ end
122
+
123
+ def mime_type
124
+ @data[:mime_type]
125
+ end
126
+
127
+ def caption
128
+ @data[:caption]
129
+ end
130
+
131
+ def filename
132
+ @data[:filename] || @data[:file_name]
133
+ end
134
+
135
+ def id
136
+ @data[:id]
137
+ end
138
+
139
+ def file_id
140
+ @data[:file_id]
141
+ end
142
+
143
+ def [](key)
144
+ @data[key]
145
+ end
146
+
147
+ def to_h
148
+ @data
149
+ end
150
+
151
+ # Resolve a fetchable URL for the media.
152
+ def url
153
+ case platform
154
+ when :whatsapp then client.get_media_url(id)
155
+ when :telegram then client.file_url(file_id)
156
+ else @data[:url]
157
+ end
158
+ end
159
+
160
+ # Fetch the raw bytes of the media.
161
+ def download
162
+ case platform
163
+ when :whatsapp then client.download_media(id)
164
+ when :telegram then client.download_file(file_id)
165
+ else fetch(url)
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ def fetch(resource_url)
172
+ return nil unless resource_url
173
+
174
+ uri = URI(resource_url)
175
+ http = Net::HTTP.new(uri.host, uri.port)
176
+ http.use_ssl = (uri.scheme == "https")
177
+ response = http.get(uri.request_uri)
178
+ response.body if response.is_a?(Net::HTTPSuccess)
179
+ end
180
+ end
181
+ end
182
+ ```
183
+
184
+ - [ ] **Step 4: Run test to verify it passes**
185
+
186
+ Run: `ruby -Itest test/unit/media_test.rb -v`
187
+ Expected: PASS (all 5 tests)
188
+
189
+ - [ ] **Step 5: Commit**
190
+
191
+ ```bash
192
+ git add lib/flow_chat/media.rb test/unit/media_test.rb
193
+ git commit -m "feat: add FlowChat::Media inbound media value object"
194
+ ```
195
+
196
+ ```json:metadata
197
+ {"files": ["lib/flow_chat/media.rb", "test/unit/media_test.rb"], "verifyCommand": "ruby -Itest test/unit/media_test.rb -v", "acceptanceCriteria": ["metadata readers", "url/download dispatch per platform", "autoloads via Zeitwerk"], "requiresUserVerification": false}
198
+ ```
199
+
200
+ ---
201
+
202
+ ### Task 2: Telegram client media download
203
+
204
+ **Goal:** Add `get_file`, `file_url`, and `download_file` to the Telegram client so inbound Telegram media can be fetched.
205
+
206
+ **Files:**
207
+ - Modify: `lib/flow_chat/telegram/client.rb` (add public methods before `private` at line 182)
208
+ - Test: `test/unit/telegram/client_test.rb`
209
+
210
+ **Acceptance Criteria:**
211
+ - [ ] `get_file(file_id)` calls the `getFile` API method with `{file_id:}`.
212
+ - [ ] `file_url(file_id)` returns `https://api.telegram.org/file/bot<bot_token>/<file_path>`, or `nil` if no `file_path`.
213
+ - [ ] `download_file(file_id)` GETs `file_url` and returns the body on success, `nil` otherwise.
214
+
215
+ **Verify:** `ruby -Itest test/unit/telegram/client_test.rb -v` → all pass
216
+
217
+ **Steps:**
218
+
219
+ - [ ] **Step 1: Write the failing test**
220
+
221
+ Add to `test/unit/telegram/client_test.rb` (match the file's existing setup — a `FlowChat::Telegram::Configuration` with `bot_token` and a `FlowChat::Telegram::Client`). Add:
222
+
223
+ ```ruby
224
+ def test_get_file_calls_getFile
225
+ client = FlowChat::Telegram::Client.new(@config)
226
+ client.stub(:api_request, {"ok" => true, "result" => {"file_path" => "photos/f.jpg"}}) do
227
+ result = client.get_file("FID")
228
+ assert_equal "photos/f.jpg", result.dig("result", "file_path")
229
+ end
230
+ end
231
+
232
+ def test_file_url_builds_download_url
233
+ client = FlowChat::Telegram::Client.new(@config)
234
+ client.stub(:get_file, {"ok" => true, "result" => {"file_path" => "photos/f.jpg"}}) do
235
+ assert_equal "https://api.telegram.org/file/bot#{@config.bot_token}/photos/f.jpg", client.file_url("FID")
236
+ end
237
+ end
238
+
239
+ def test_file_url_returns_nil_without_file_path
240
+ client = FlowChat::Telegram::Client.new(@config)
241
+ client.stub(:get_file, {"ok" => false}) do
242
+ assert_nil client.file_url("FID")
243
+ end
244
+ end
245
+ ```
246
+
247
+ Note: if the existing test file defines `@config`/`@client` differently in `setup`, reuse that instead of re-instantiating.
248
+
249
+ - [ ] **Step 2: Run test to verify it fails**
250
+
251
+ Run: `ruby -Itest test/unit/telegram/client_test.rb -v`
252
+ Expected: FAIL — `NoMethodError: undefined method 'get_file'`
253
+
254
+ - [ ] **Step 3: Write the implementation**
255
+
256
+ In `lib/flow_chat/telegram/client.rb`, immediately before the `private` keyword (line 182), add:
257
+
258
+ ```ruby
259
+ # Get file metadata (including file_path) for an inbound file_id
260
+ def get_file(file_id)
261
+ api_request("getFile", {file_id: file_id})
262
+ end
263
+
264
+ # Build the download URL for an inbound file_id
265
+ def file_url(file_id)
266
+ file_path = get_file(file_id).dig("result", "file_path")
267
+ return nil unless file_path
268
+
269
+ "https://api.telegram.org/file/bot#{@config.bot_token}/#{file_path}"
270
+ end
271
+
272
+ # Download the raw bytes for an inbound file_id
273
+ def download_file(file_id)
274
+ url = file_url(file_id)
275
+ return nil unless url
276
+
277
+ uri = URI(url)
278
+ http = Net::HTTP.new(uri.host, uri.port)
279
+ http.use_ssl = true
280
+ response = http.get(uri.request_uri)
281
+
282
+ if response.is_a?(Net::HTTPSuccess)
283
+ response.body
284
+ else
285
+ FlowChat.logger.error { "Telegram::Client: File download error: #{response.code}" }
286
+ nil
287
+ end
288
+ end
289
+ ```
290
+
291
+ - [ ] **Step 4: Run test to verify it passes**
292
+
293
+ Run: `ruby -Itest test/unit/telegram/client_test.rb -v`
294
+ Expected: PASS
295
+
296
+ - [ ] **Step 5: Commit**
297
+
298
+ ```bash
299
+ git add lib/flow_chat/telegram/client.rb test/unit/telegram/client_test.rb
300
+ git commit -m "feat: add Telegram client inbound file download (getFile)"
301
+ ```
302
+
303
+ ```json:metadata
304
+ {"files": ["lib/flow_chat/telegram/client.rb", "test/unit/telegram/client_test.rb"], "verifyCommand": "ruby -Itest test/unit/telegram/client_test.rb -v", "acceptanceCriteria": ["get_file", "file_url", "download_file"], "requiresUserVerification": false}
305
+ ```
306
+
307
+ ---
308
+
309
+ ### Task 3: Wire `App` to expose inbound media, location, and contact
310
+
311
+ **Goal:** Replace the `nil` stubs in `App` so parsed inbound data reaches flows, normalizing single/array media into `media_items` with `media` as the first-item convenience.
312
+
313
+ **Files:**
314
+ - Modify: `lib/flow_chat/app.rb` (methods at lines 66-76; add private helper)
315
+ - Test: `test/integration/media_support_test.rb`
316
+
317
+ **Acceptance Criteria:**
318
+ - [ ] `media_items` returns `[]` when no media, one `FlowChat::Media` for a single hash, N for an array.
319
+ - [ ] `media` returns `media_items.first` (or `nil`).
320
+ - [ ] `location` returns `context["request.location"]`; `contact` returns `context["request.contact"]`; `contact_name` returns `context["request.user_name"]`.
321
+ - [ ] Each `FlowChat::Media` gets the right platform client from context.
322
+
323
+ **Verify:** `ruby -Itest test/integration/media_support_test.rb -v` → all pass
324
+
325
+ **Steps:**
326
+
327
+ - [ ] **Step 1: Write the failing test**
328
+
329
+ Add to `test/integration/media_support_test.rb`:
330
+
331
+ ```ruby
332
+ # ==========================================================================
333
+ # INBOUND MEDIA EXPOSURE
334
+ # ==========================================================================
335
+
336
+ def test_whatsapp_inbound_media_exposed_via_app
337
+ @whatsapp_context.input = FlowChat::Input::MEDIA
338
+ @whatsapp_context["request.platform"] = :whatsapp
339
+ @whatsapp_context["request.media"] = {type: :image, id: "MID", mime_type: "image/jpeg", caption: "hi"}
340
+ app = FlowChat::App.new(@whatsapp_context)
341
+
342
+ assert_instance_of FlowChat::Media, app.media
343
+ assert_equal :image, app.media.type
344
+ assert_equal "MID", app.media.id
345
+ assert_equal 1, app.media_items.size
346
+ end
347
+
348
+ def test_intercom_inbound_multiple_media_exposed_via_app
349
+ ctx = FlowChat::Context.new
350
+ ctx.session = create_test_session_store
351
+ ctx["request.platform"] = :intercom
352
+ ctx["request.media"] = [
353
+ {type: :image, url: "https://i/1.png", mime_type: "image/png"},
354
+ {type: :document, url: "https://i/2.pdf", mime_type: "application/pdf"}
355
+ ]
356
+ app = FlowChat::App.new(ctx)
357
+
358
+ assert_equal 2, app.media_items.size
359
+ assert_equal "https://i/1.png", app.media.url
360
+ assert_equal :document, app.media_items.last.type
361
+ end
362
+
363
+ def test_inbound_media_absent_returns_nil_and_empty
364
+ @whatsapp_context.input = "just text"
365
+ @whatsapp_context["request.platform"] = :whatsapp
366
+ app = FlowChat::App.new(@whatsapp_context)
367
+
368
+ assert_nil app.media
369
+ assert_equal [], app.media_items
370
+ end
371
+
372
+ def test_location_and_contact_exposed_via_app
373
+ @whatsapp_context["request.platform"] = :whatsapp
374
+ @whatsapp_context["request.location"] = {latitude: 1.0, longitude: 2.0}
375
+ @whatsapp_context["request.user_name"] = "John Doe"
376
+ @whatsapp_context["request.contact"] = {name: "Jane", first_name: "Jane"}
377
+ app = FlowChat::App.new(@whatsapp_context)
378
+
379
+ assert_equal 1.0, app.location[:latitude]
380
+ assert_equal "John Doe", app.contact_name
381
+ assert_equal "Jane", app.contact[:name]
382
+ end
383
+ ```
384
+
385
+ - [ ] **Step 2: Run test to verify it fails**
386
+
387
+ Run: `ruby -Itest test/integration/media_support_test.rb -v`
388
+ Expected: FAIL — `app.media` is `nil` / `NoMethodError: undefined method 'media_items'`
389
+
390
+ - [ ] **Step 3: Write the implementation**
391
+
392
+ In `lib/flow_chat/app.rb`, replace the three stub methods (lines 66-76):
393
+
394
+ ```ruby
395
+ def contact_name
396
+ context["request.user_name"]
397
+ end
398
+
399
+ def contact
400
+ context["request.contact"]
401
+ end
402
+
403
+ def location
404
+ context["request.location"]
405
+ end
406
+
407
+ def media
408
+ media_items.first
409
+ end
410
+
411
+ def media_items
412
+ raw = context["request.media"]
413
+ return [] unless raw
414
+
415
+ items = raw.is_a?(Array) ? raw : [raw]
416
+ items.map { |data| FlowChat::Media.new(data, platform: platform, client: media_client) }
417
+ end
418
+ ```
419
+
420
+ Then add to the `protected`/`private` section (after `prepare_user_input`):
421
+
422
+ ```ruby
423
+ def media_client
424
+ case platform
425
+ when :whatsapp then context["whatsapp.client"]
426
+ when :telegram then context["telegram.client"]
427
+ when :intercom then context["intercom.client"]
428
+ end
429
+ end
430
+ ```
431
+
432
+ - [ ] **Step 4: Run test to verify it passes**
433
+
434
+ Run: `ruby -Itest test/integration/media_support_test.rb -v`
435
+ Expected: PASS
436
+
437
+ - [ ] **Step 5: Commit**
438
+
439
+ ```bash
440
+ git add lib/flow_chat/app.rb test/integration/media_support_test.rb
441
+ git commit -m "feat: expose inbound media, location, and contact to flows"
442
+ ```
443
+
444
+ ```json:metadata
445
+ {"files": ["lib/flow_chat/app.rb", "test/integration/media_support_test.rb"], "verifyCommand": "ruby -Itest test/integration/media_support_test.rb -v", "acceptanceCriteria": ["media_items normalizes single/array/none", "media is first item", "location/contact/contact_name exposed", "correct platform client"], "requiresUserVerification": false}
446
+ ```
447
+
448
+ ---
449
+
450
+ ### Task 4: Intercom gateway inbound media parsing
451
+
452
+ **Goal:** Parse Intercom message attachments (an array) into `context["request.media"]`, supporting multiple attachments per message.
453
+
454
+ **Files:**
455
+ - Modify: `lib/flow_chat/intercom/gateway/intercom_api.rb` (`extract_latest_user_message`, lines 266-299; and the caller that sets `context.input`, ~line 143-146)
456
+ - Test: `test/unit/intercom/gateway/intercom_api_test.rb`
457
+
458
+ **Acceptance Criteria:**
459
+ - [ ] Attachments on the initial message (`source`) and on reply parts are extracted into an array of media hashes.
460
+ - [ ] `content_type` maps to type: `image/*`→`:image`, `video/*`→`:video`, `audio/*`→`:audio`, else `:document`.
461
+ - [ ] When attachments exist, `context["request.media"]` is set and `context.input = FlowChat::Input::MEDIA`.
462
+ - [ ] Messages without attachments are unaffected (text still flows through unchanged).
463
+
464
+ **Verify:** `ruby -Itest test/unit/intercom/gateway/intercom_api_test.rb -v` → all pass
465
+
466
+ **Steps:**
467
+
468
+ - [ ] **Step 1: Write the failing test**
469
+
470
+ Add to `test/unit/intercom/gateway/intercom_api_test.rb` a test that drives the extraction. Match the file's existing style for building a gateway + webhook body. The core assertion:
471
+
472
+ ```ruby
473
+ def test_extract_maps_multiple_attachments_to_media_array
474
+ gateway = build_gateway # use existing helper/setup in this file
475
+ conversation = {
476
+ "source" => {
477
+ "id" => "msg_1",
478
+ "body" => "see attached",
479
+ "attachments" => [
480
+ {"name" => "a.png", "url" => "https://i/a.png", "content_type" => "image/png"},
481
+ {"name" => "b.pdf", "url" => "https://i/b.pdf", "content_type" => "application/pdf"}
482
+ ]
483
+ }
484
+ }
485
+ result = gateway.send(:extract_latest_user_message, conversation, "conversation.user.created")
486
+ assert_equal 2, result[:media].size
487
+ assert_equal :image, result[:media][0][:type]
488
+ assert_equal :document, result[:media][1][:type]
489
+ assert_equal "https://i/a.png", result[:media][0][:url]
490
+ end
491
+ ```
492
+
493
+ Note: `extract_latest_user_message` returns a hash `{id:, body:}`. This task extends it to also include `media:` when attachments are present. If the existing test file lacks a `build_gateway` helper, instantiate the gateway the same way `setup` in that file does.
494
+
495
+ - [ ] **Step 2: Run test to verify it fails**
496
+
497
+ Run: `ruby -Itest test/unit/intercom/gateway/intercom_api_test.rb -v`
498
+ Expected: FAIL — `result[:media]` is `nil`
499
+
500
+ - [ ] **Step 3: Write the implementation**
501
+
502
+ In `lib/flow_chat/intercom/gateway/intercom_api.rb`, add a private helper and use it in both branches of `extract_latest_user_message`. Add near the other private helpers:
503
+
504
+ ```ruby
505
+ def extract_attachments(raw)
506
+ attachments = raw["attachments"] || []
507
+ return nil if attachments.empty?
508
+
509
+ attachments.map do |a|
510
+ {
511
+ type: intercom_media_type(a["content_type"]),
512
+ url: a["url"],
513
+ mime_type: a["content_type"],
514
+ filename: a["name"]
515
+ }
516
+ end
517
+ end
518
+
519
+ def intercom_media_type(content_type)
520
+ case content_type
521
+ when %r{\Aimage/} then :image
522
+ when %r{\Avideo/} then :video
523
+ when %r{\Aaudio/} then :audio
524
+ else :document
525
+ end
526
+ end
527
+ ```
528
+
529
+ Update `extract_latest_user_message` to include media. In the `conversation.user.created` branch:
530
+
531
+ ```ruby
532
+ when "conversation.user.created"
533
+ source = conversation["source"]
534
+ if source && (source["body"] || source["attachments"]&.any?)
535
+ {
536
+ id: source["id"],
537
+ body: source["body"],
538
+ media: extract_attachments(source)
539
+ }.compact
540
+ end
541
+ ```
542
+
543
+ In the `conversation.user.replied` branch, replace the returned hash:
544
+
545
+ ```ruby
546
+ if user_parts.any?
547
+ latest_part = user_parts.last
548
+ {
549
+ id: latest_part["id"],
550
+ body: latest_part["body"],
551
+ media: extract_attachments(latest_part)
552
+ }.compact
553
+ end
554
+ ```
555
+
556
+ Then, in the caller that sets `context.input` (around lines 143-146, where `latest_message` is used), set media and the sentinel input when present:
557
+
558
+ ```ruby
559
+ context["request.message_id"] = latest_message[:id]
560
+ if latest_message[:media]
561
+ context["request.media"] = latest_message[:media]
562
+ context.input = FlowChat::Input::MEDIA
563
+ else
564
+ context.input = @client.parse_message(raw_body)
565
+ end
566
+ ```
567
+
568
+ Adjust to match the exact existing structure around line 143-150 (preserve the debug log line).
569
+
570
+ - [ ] **Step 4: Run test to verify it passes**
571
+
572
+ Run: `ruby -Itest test/unit/intercom/gateway/intercom_api_test.rb -v`
573
+ Expected: PASS
574
+
575
+ - [ ] **Step 5: Run the full Intercom + integration suites for regressions**
576
+
577
+ Run: `ruby -Itest test/unit/intercom/gateway/intercom_api_test.rb test/integration/intercom_integration_test.rb -v`
578
+ Expected: PASS
579
+
580
+ - [ ] **Step 6: Commit**
581
+
582
+ ```bash
583
+ git add lib/flow_chat/intercom/gateway/intercom_api.rb test/unit/intercom/gateway/intercom_api_test.rb
584
+ git commit -m "feat: parse inbound Intercom attachments into request media"
585
+ ```
586
+
587
+ ```json:metadata
588
+ {"files": ["lib/flow_chat/intercom/gateway/intercom_api.rb", "test/unit/intercom/gateway/intercom_api_test.rb"], "verifyCommand": "ruby -Itest test/unit/intercom/gateway/intercom_api_test.rb -v", "acceptanceCriteria": ["attachments array parsed", "content_type mapping", "media sentinel input set", "text unaffected"], "requiresUserVerification": false}
589
+ ```
590
+
591
+ ---
592
+
593
+ ### Task 5: HTTP gateway inbound media parsing
594
+
595
+ **Goal:** Let the HTTP gateway accept inbound media via request params so the simulator and API callers can submit media.
596
+
597
+ **Files:**
598
+ - Modify: `lib/flow_chat/http/gateway/simple.rb` (after `context.input` is set, ~line 47)
599
+ - Test: `test/unit/http/gateway/simple_test.rb`
600
+
601
+ **Acceptance Criteria:**
602
+ - [ ] When params include `media_url`, `context["request.media"]` is set to `{type:, url:, mime_type:}` (type from `media_type` param, default `:document`).
603
+ - [ ] When no text `input` is present but media is, `context.input = FlowChat::Input::MEDIA`.
604
+ - [ ] When text `input` is present, it still takes precedence (existing behavior preserved).
605
+ - [ ] Requests with neither are unaffected.
606
+
607
+ **Verify:** `ruby -Itest test/unit/http/gateway/simple_test.rb -v` → all pass
608
+
609
+ **Steps:**
610
+
611
+ - [ ] **Step 1: Write the failing test**
612
+
613
+ Add to `test/unit/http/gateway/simple_test.rb`, matching the file's existing controller/params mocking style. Core assertions:
614
+
615
+ ```ruby
616
+ def test_inbound_media_url_sets_request_media
617
+ # Build controller whose request.params includes media_url (reuse this file's helper)
618
+ controller = build_controller(params: {"media_url" => "https://x/a.jpg", "media_type" => "image", "mime_type" => "image/jpeg"})
619
+ context = FlowChat::Context.new
620
+ context.controller = controller
621
+ gateway = FlowChat::Http::Gateway::Simple.new(->(_ctx) { [:text, "ok", nil, nil] }, {session_id: "s", user_id: "u"})
622
+ gateway.call(context)
623
+
624
+ assert_equal :image, context["request.media"][:type]
625
+ assert_equal "https://x/a.jpg", context["request.media"][:url]
626
+ assert_equal FlowChat::Input::MEDIA, context.input
627
+ end
628
+
629
+ def test_text_input_takes_precedence_over_media
630
+ controller = build_controller(params: {"input" => "hello", "media_url" => "https://x/a.jpg"})
631
+ context = FlowChat::Context.new
632
+ context.controller = controller
633
+ gateway = FlowChat::Http::Gateway::Simple.new(->(_ctx) { [:text, "ok", nil, nil] }, {session_id: "s", user_id: "u"})
634
+ gateway.call(context)
635
+
636
+ assert_equal "hello", context.input
637
+ end
638
+ ```
639
+
640
+ Note: reuse whatever controller/params builder the existing tests in this file use (e.g. a `mock_controller` helper). Do not invent a new mocking approach — mirror the file.
641
+
642
+ - [ ] **Step 2: Run test to verify it fails**
643
+
644
+ Run: `ruby -Itest test/unit/http/gateway/simple_test.rb -v`
645
+ Expected: FAIL — `context["request.media"]` is `nil`
646
+
647
+ - [ ] **Step 3: Write the implementation**
648
+
649
+ In `lib/flow_chat/http/gateway/simple.rb`, after the existing `context.input = params["input"].presence || ""` (line 47), add:
650
+
651
+ ```ruby
652
+ # Inbound media (optional): callers may submit a media URL
653
+ if params["media_url"].present?
654
+ context["request.media"] = {
655
+ type: (params["media_type"].presence || "document").to_sym,
656
+ url: params["media_url"],
657
+ mime_type: params["mime_type"].presence
658
+ }
659
+ context.input = FlowChat::Input::MEDIA if context.input.blank?
660
+ end
661
+ ```
662
+
663
+ - [ ] **Step 4: Run test to verify it passes**
664
+
665
+ Run: `ruby -Itest test/unit/http/gateway/simple_test.rb -v`
666
+ Expected: PASS
667
+
668
+ - [ ] **Step 5: Commit**
669
+
670
+ ```bash
671
+ git add lib/flow_chat/http/gateway/simple.rb test/unit/http/gateway/simple_test.rb
672
+ git commit -m "feat: parse inbound media params in HTTP gateway"
673
+ ```
674
+
675
+ ```json:metadata
676
+ {"files": ["lib/flow_chat/http/gateway/simple.rb", "test/unit/http/gateway/simple_test.rb"], "verifyCommand": "ruby -Itest test/unit/http/gateway/simple_test.rb -v", "acceptanceCriteria": ["media_url sets request.media", "media sentinel when no text", "text precedence preserved"], "requiresUserVerification": false}
677
+ ```
678
+
679
+ ---
680
+
681
+ ### Task 6: Full suite regression + docs
682
+
683
+ **Goal:** Confirm the whole test suite is green and note the new inbound media API in project docs.
684
+
685
+ **Files:**
686
+ - Modify: `README.md` or `docs/` (whichever documents flow APIs — add `app.media` / `app.media_items` / `app.location` / `app.contact` usage)
687
+
688
+ **Acceptance Criteria:**
689
+ - [ ] `rake test` passes with zero failures.
690
+ - [ ] Docs mention inbound media access (`app.media.download`, `media_items` for multi-attachment Intercom).
691
+
692
+ **Verify:** `bundle exec rake test` → 0 failures, 0 errors
693
+
694
+ **Steps:**
695
+
696
+ - [ ] **Step 1: Run the full suite**
697
+
698
+ Run: `bundle exec rake test`
699
+ Expected: 0 failures, 0 errors. Fix any regressions before proceeding.
700
+
701
+ - [ ] **Step 2: Add a short docs section**
702
+
703
+ Find where flow input APIs are documented (grep the README/docs for `app.msisdn` or `app.location`). Add a concise inbound-media example:
704
+
705
+ ```ruby
706
+ photo = app.screen(:upload) { |p| p.ask "Send a photo" }
707
+ if app.media
708
+ bytes = app.media.download # raw file bytes
709
+ url = app.media.url # fetchable URL
710
+ app.media.type # :image, :document, ...
711
+ end
712
+ app.media_items # Array (Intercom may attach several)
713
+ ```
714
+
715
+ - [ ] **Step 3: Commit**
716
+
717
+ ```bash
718
+ git add -A
719
+ git commit -m "docs: document inbound media access API"
720
+ ```
721
+
722
+ ```json:metadata
723
+ {"files": ["README.md"], "verifyCommand": "bundle exec rake test", "acceptanceCriteria": ["full suite green", "docs updated"], "requiresUserVerification": false}
724
+ ```
725
+
726
+ ---
727
+
728
+ ## Self-Review Notes
729
+
730
+ - **Spec coverage:** Media object (Task 1), App wiring incl. media_items/location/contact (Task 3), Telegram download (Task 2), Intercom multi-attachment parsing (Task 4), HTTP parsing (Task 5), tests throughout, docs (Task 6). All spec sections mapped.
731
+ - **Type consistency:** `FlowChat::Media.new(data, platform:, client:)`, `media_items`, `media`, `client.file_url`/`download_file`/`get_media_url`/`download_media`, `intercom_media_type` used consistently across tasks.
732
+ - **Verification requirement scan:** Prompt "we need to add support for media" requires no user sign-off → NO. No verification task required.