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,88 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-08-16-unified-choice-resolution.md",
3
+ "tasks": [
4
+ {
5
+ "id": 0,
6
+ "subject": "Task 1: Give TextTruncator a pluggable measure",
7
+ "status": "completed",
8
+ "description": "**Goal:** TextTruncator can cut to a byte budget on character boundaries, so Telegram's 64-byte field is expressible without a Telegram-specific branch.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/text_truncator.rb\", \"test/unit/text_truncator_test.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/text_truncator_test.rb\", \"acceptanceCriteria\": [\"byte truncation never exceeds cap\", \"never splits a multi-byte character\", \"ellipsis charged in active measure\", \"character measure remains the default\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 1,
12
+ "subject": "Task 2: Make ChoiceTitles decide ambiguity under the resolver's fold",
13
+ "status": "completed",
14
+ "blockedBy": [
15
+ 0
16
+ ],
17
+ "description": "**Goal:** ambiguity_reason treats two titles as duplicates when they collapse under the fold the resolver matches on, and numbers the set when they do.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/choice_titles.rb\", \"test/unit/choice_titles_test.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/choice_titles_test.rb\", \"acceptanceCriteria\": [\"build/ambiguous?/ambiguity_reason accept fold: and measure:\", \"titles equal after fold are duplicates\", \"aliases_for deleted\"], \"requiresUserVerification\": false}\n```"
18
+ },
19
+ {
20
+ "id": 2,
21
+ "subject": "Task 3: Meta mappers send the displayed title, not a generated id",
22
+ "status": "completed",
23
+ "blockedBy": [
24
+ 1
25
+ ],
26
+ "description": "**Goal:** WhatsApp, Messenger and Instagram put the displayed title on the wire, collapsing the id map and alias map into one.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/whatsapp/middleware/choice_mapper.rb\", \"lib/flow_chat/messenger/middleware/choice_mapper.rb\", \"lib/flow_chat/instagram/middleware/choice_mapper.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb\", \"acceptanceCriteria\": [\"typing Yes resolves to the choice showing Yes\", \"alias_mapping removed everywhere\", \"ids never exceed platform caps\"], \"requiresUserVerification\": false}\n```"
27
+ },
28
+ {
29
+ "id": 3,
30
+ "subject": "Task 4: Delete IdGenerator",
31
+ "status": "completed",
32
+ "blockedBy": [
33
+ 2
34
+ ],
35
+ "description": "**Goal:** Remove the class whose lossy normalization caused the bug.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/id_generator.rb\", \"test/unit/id_generator_test.rb\"], \"verifyCommand\": \"grep -rn IdGenerator lib/ test/ ; bundle exec rake test\", \"acceptanceCriteria\": [\"no IdGenerator references remain\", \"full suite passes\"], \"requiresUserVerification\": false}\n```"
36
+ },
37
+ {
38
+ "id": 4,
39
+ "subject": "Task 5: HTTP mapper adopts the fold",
40
+ "status": "completed",
41
+ "blockedBy": [
42
+ 1
43
+ ],
44
+ "description": "**Goal:** HTTP stops silently dropping the second of two labels that match case-insensitively.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/http/middleware/choice_mapper.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/http/middleware/choice_mapper_test.rb\", \"acceptanceCriteria\": [\"duplicate labels both resolve\", \"no ||= remains\"], \"requiresUserVerification\": false}\n```"
45
+ },
46
+ {
47
+ "id": 5,
48
+ "subject": "Task 6: Intercom mapper adopts the fold",
49
+ "status": "completed",
50
+ "blockedBy": [
51
+ 1
52
+ ],
53
+ "description": "**Goal:** Same first-wins fix for Intercom.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/intercom/middleware/choice_mapper.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/intercom/middleware/choice_mapper_test.rb\", \"acceptanceCriteria\": [\"duplicate labels both resolve\", \"no ||= remains\"], \"requiresUserVerification\": false}\n```"
54
+ },
55
+ {
56
+ "id": 6,
57
+ "subject": "Task 7: Telegram resolves against a real map, sized in bytes",
58
+ "status": "completed",
59
+ "blockedBy": [
60
+ 1
61
+ ],
62
+ "description": "**Goal:** Telegram stops overflowing its 64-byte field and stops making a choice unselectable on prefix collision.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/telegram/middleware/choice_mapper.rb\", \"lib/flow_chat/telegram/renderer.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/telegram/middleware/choice_mapper_test.rb\", \"acceptanceCriteria\": [\"callback_data never exceeds 64 bytes\", \"always valid UTF-8\", \"prefix-sharing labels both resolve\"], \"requiresUserVerification\": false}\n```"
63
+ },
64
+ {
65
+ "id": 7,
66
+ "subject": "Task 8: Pin USSD's exemption",
67
+ "status": "completed",
68
+ "blockedBy": [
69
+ 1
70
+ ],
71
+ "description": "**Goal:** Record why USSD needs no fold, so a later change does not unify it into a bug.\n\n```json:metadata\n{\"files\": [\"lib/flow_chat/ussd/middleware/choice_mapper.rb\"], \"verifyCommand\": \"bundle exec ruby -Itest test/unit/ussd/middleware/choice_mapper_test.rb\", \"acceptanceCriteria\": [\"duplicate labels resolve distinctly\", \"invariant comment present\"], \"requiresUserVerification\": false}\n```"
72
+ },
73
+ {
74
+ "id": 8,
75
+ "subject": "Task 9: Update the choice documentation",
76
+ "status": "completed",
77
+ "blockedBy": [
78
+ 2,
79
+ 4,
80
+ 5,
81
+ 6,
82
+ 7
83
+ ],
84
+ "description": "**Goal:** Bring docs in line; document the fold/measure contract for new gateways.\n\n```json:metadata\n{\"files\": [\"docs/gateway-development.md\", \"docs/platforms/whatsapp.md\", \"docs/platforms/telegram.md\"], \"verifyCommand\": \"grep -rn 'IdGenerator\\\\|alias_mapping' docs/ --include=*.md | grep -v superpowers/\", \"acceptanceCriteria\": [\"no stale references\", \"fold/measure contract documented\"], \"requiresUserVerification\": false}\n```"
85
+ }
86
+ ],
87
+ "lastUpdated": "2026-08-17"
88
+ }
@@ -0,0 +1,195 @@
1
+ # Inbound Media Support — Design
2
+
3
+ **Date:** 2026-07-09
4
+ **Status:** Superseded (historical). This document describes the original design as
5
+ approved. The shipped API diverged during implementation: the `$media$`/`$location$`/
6
+ `$contact$` input sentinels were removed in favor of a `FlowChat::Input` turn value
7
+ object (`context.input` is always plain text), and `app.media` is now **always** an
8
+ `Array<FlowChat::Media>` (the interim `media_items` accessor was dropped). For the
9
+ current API, see `docs/gateway-context-variables.md` and `docs/platforms/whatsapp.md`.
10
+
11
+ ## Problem
12
+
13
+ FlowChat has complete *outbound* media support (`ask`/`say`/`select` with `media:`,
14
+ per-platform renderers, WhatsApp `upload_media`/`download_media`/`get_media_url`).
15
+
16
+ *Inbound* media is broken in the middle of the pipe:
17
+
18
+ - The **WhatsApp** and **Telegram** gateways already parse incoming media into
19
+ `context["request.media"]` (and `request.location`, `request.contact`), and set
20
+ `context.input` to the sentinels `FlowChat::Input::MEDIA` (`$media$`) /
21
+ `FlowChat::Input::LOCATION` (`$location$`).
22
+ - But `FlowChat::App#media`, `#location`, and `#contact_name` return a hardcoded
23
+ `nil` (`lib/flow_chat/app.rb:66-76`), so parsed data never reaches a flow.
24
+ - The **Intercom** and **HTTP** gateways do not parse inbound media at all.
25
+ - **USSD** is text-only and out of scope.
26
+
27
+ ## Goals
28
+
29
+ 1. Expose inbound media to flows via `app.media`, returning both metadata **and** a
30
+ way to fetch the bytes/URL.
31
+ 2. Also expose the co-located, equally-stranded `app.location` and contact data.
32
+ 3. Add inbound media parsing to the Intercom and HTTP gateways (USSD skipped).
33
+ 4. Keep outbound behavior unchanged (parity only).
34
+
35
+ ## Non-Goals
36
+
37
+ - No new outbound features (local-file auto-upload, new platforms/types).
38
+ - No USSD inbound media.
39
+ - No change to the `$media$`/`$location$` sentinel input mechanism.
40
+
41
+ ## Approach
42
+
43
+ A dedicated `FlowChat::Media` value object owns all cross-platform inbound-media
44
+ normalization. `app.media` wraps `context["request.media"]` in it. Per-platform
45
+ quirks (WhatsApp media-id vs Telegram file_id vs Intercom/HTTP direct URL) live in
46
+ this single testable class, delegating to existing client download methods where
47
+ they exist.
48
+
49
+ Rejected alternatives:
50
+ - **Hash + helper methods on `App`** — hash isn't self-describing; platform
51
+ branching leaks into `App`.
52
+ - **`download_inbound` on each client** — parallel code in every client and still
53
+ needs an app-level entry point.
54
+
55
+ ## Components
56
+
57
+ ### 1. `FlowChat::Media` (new — `lib/flow_chat/media.rb`)
58
+
59
+ A value object wrapping one inbound media item.
60
+
61
+ - **Construction:** `FlowChat::Media.new(data, platform:, client:)` where `data` is
62
+ the raw `context["request.media"]` hash. `client` may be `nil` for URL-based
63
+ platforms (HTTP).
64
+ - **Readers:** `type`, `mime_type`, `caption`, `filename`, `id` (WhatsApp),
65
+ `file_id` (Telegram), and `[]` for raw-hash access. Missing keys return `nil` —
66
+ media hashes differ by platform and this is expected, not defensive.
67
+ - **`#url`** — resolves a fetchable URL:
68
+ - WhatsApp: `client.get_media_url(id)`
69
+ - Telegram: `client.file_url(file_id)` (new client method)
70
+ - Intercom / HTTP: the direct `data[:url]`
71
+ - **`#download`** — returns the raw bytes:
72
+ - WhatsApp: `client.download_media(id)`
73
+ - Telegram: `client.download_file(file_id)` (new client method)
74
+ - URL-based (Intercom/HTTP): plain HTTP GET of `#url`
75
+
76
+ Platform is selected by a simple `case` on `platform`. Note the type vocabulary
77
+ differs by platform and is preserved as-parsed (WhatsApp: `:image, :document,
78
+ :audio, :video, :sticker`; Telegram: `:photo, :video, :audio, :document, :voice,
79
+ :sticker`). No forced normalization of `type` — documented, not hidden.
80
+
81
+ ### 2. `FlowChat::App` wiring (`lib/flow_chat/app.rb`)
82
+
83
+ Replace the `nil` stubs. Inbound media is normalized to a list because a single
84
+ message can carry multiple media items on some platforms (Intercom attachments),
85
+ while WhatsApp/Telegram carry exactly one:
86
+
87
+ - `media_items` → `Array<FlowChat::Media>`. Reads `context["request.media"]`, which
88
+ may be a single hash (WhatsApp/Telegram) or an array of hashes (Intercom, HTTP).
89
+ Normalizes shape (`raw.is_a?(Array) ? raw : [raw]`), maps each to
90
+ `FlowChat::Media`, returns `[]` when none. This is the one place shape is
91
+ normalized — the WhatsApp/Telegram gateways are **not** changed.
92
+ - `media` → `media_items.first` (the ergonomic single-media accessor; `nil` when
93
+ none). Common case for WhatsApp/Telegram flows.
94
+ - `location` → returns `context["request.location"]` (hash or `nil`).
95
+ - `contact_name` → returns `context["request.user_name"]` (the sender's display
96
+ name, as set by the gateways).
97
+ - `contact` → returns `context["request.contact"]` (a contact card the user
98
+ *shared*, distinct from the sender; `nil` when none).
99
+
100
+ Each `FlowChat::Media` is built with the platform client chosen from context
101
+ (`whatsapp.client` / `telegram.client` / `intercom.client`; `nil` for HTTP). A small
102
+ private `media_client` helper maps platform → context client key.
103
+
104
+ ### 3. Telegram client (`lib/flow_chat/telegram/client.rb`)
105
+
106
+ Telegram requires a `getFile` round-trip before download. Add:
107
+
108
+ - `get_file(file_id)` — calls the `getFile` API method, returns the file metadata
109
+ (including `file_path`).
110
+ - `file_url(file_id)` — resolves to
111
+ `https://api.telegram.org/file/bot<token>/<file_path>`.
112
+ - `download_file(file_id)` — GETs `file_url` and returns the bytes.
113
+
114
+ ### 4. Intercom gateway inbound parsing (`lib/flow_chat/intercom/gateway/intercom_api.rb`)
115
+
116
+ In the latest-user-message extraction path, parse `attachments` (an **array** —
117
+ Intercom conversation parts and the conversation `source` each carry
118
+ `attachments` with `name`, `url`, `content_type`, `type`). Map **all** attachments:
119
+
120
+ ```ruby
121
+ media = attachments.map do |a|
122
+ {
123
+ type: intercom_media_type(a["content_type"]), # image/* → :image, else :document
124
+ url: a["url"],
125
+ mime_type: a["content_type"],
126
+ filename: a["name"]
127
+ }
128
+ end
129
+ context["request.media"] = media # array, one entry per attachment
130
+ context.input = FlowChat::Input::MEDIA
131
+ ```
132
+
133
+ A single Intercom message legitimately carries multiple attachments, so the array
134
+ form is required here. `intercom_media_type` maps `content_type`: `image/*` →
135
+ `:image`, `video/*` → `:video`, `audio/*` → `:audio`, otherwise `:document`.
136
+
137
+ ### 5. HTTP gateway inbound parsing (`lib/flow_chat/http/gateway/simple.rb`)
138
+
139
+ Accept media from request params. Contract: a `media_url` param (optionally with
140
+ `media_type` and `mime_type`), or a nested `media` hash. When present:
141
+
142
+ ```ruby
143
+ context["request.media"] = {
144
+ type: (params["media_type"] || :document).to_sym,
145
+ url: params["media_url"],
146
+ mime_type: params["mime_type"]
147
+ }
148
+ context.input = FlowChat::Input::MEDIA # unless a text input is also present
149
+ ```
150
+
151
+ Text input still takes precedence when both are supplied, preserving current
152
+ behavior for existing HTTP callers.
153
+
154
+ ## Data Flow (inbound)
155
+
156
+ ```
157
+ Webhook → Gateway parses payload → context["request.media"] = {...}
158
+ → context.input = "$media$"
159
+ → Session::Middleware → Executor → Flow
160
+ Flow: app.media_items → [FlowChat::Media(data, platform, client), ...] (normalized)
161
+ app.media → media_items.first (single-media convenience)
162
+ app.media.download → client.download_media / download_file / GET url
163
+ ```
164
+
165
+ ## Error Handling
166
+
167
+ - `app.media` returns `nil` when no inbound media is present.
168
+ - `Media#url` / `#download` surface client/network errors the same way existing
169
+ client methods do (WhatsApp client logs and returns `nil` on API error; Telegram
170
+ download raises on non-success, consistent with that client's style).
171
+ - Missing metadata keys return `nil` (platforms legitimately omit fields).
172
+
173
+ ## Testing
174
+
175
+ - **Unit — `FlowChat::Media`** (`test/unit/media_test.rb`): readers; `url`/`download`
176
+ dispatch per platform (WhatsApp id, Telegram file_id, URL-based) with mocked clients.
177
+ - **Unit — Telegram client** (`test/unit/telegram/client_test.rb`): `get_file`,
178
+ `file_url`, `download_file` against a stubbed API.
179
+ - **Unit — `App` normalization** (in `media_test.rb` or app test): `media_items`
180
+ returns `[]` with no media, one item for a single hash, and N items for an array;
181
+ `media` returns the first item or `nil`.
182
+ - **Integration** (`test/integration/media_support_test.rb`): extend with inbound
183
+ cases — `app.media` returns a populated `FlowChat::Media` for WhatsApp, Telegram,
184
+ Intercom, and HTTP; Intercom with multiple attachments yields
185
+ `app.media_items.size == 2`; `app.location` and `app.contact` populated where
186
+ applicable; `app.media` is `nil` and `app.media_items` is `[]` for plain text.
187
+
188
+ ## Files
189
+
190
+ - Create: `lib/flow_chat/media.rb`, `test/unit/media_test.rb`
191
+ - Modify: `lib/flow_chat.rb` (require new file), `lib/flow_chat/app.rb`,
192
+ `lib/flow_chat/telegram/client.rb`,
193
+ `lib/flow_chat/intercom/gateway/intercom_api.rb`,
194
+ `lib/flow_chat/http/gateway/simple.rb`,
195
+ `test/integration/media_support_test.rb`, `test/unit/telegram/client_test.rb`