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,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`
|