feat!: inbound media support with FlowChat::Input turn value object - #2
feat!: inbound media support with FlowChat::Input turn value object#2thedumbtechguy wants to merge 21 commits into
Conversation
… object
Every turn is now a FlowChat::Input value object (app.input) with two
independent axes: text and an optional attachment (media/location/contact).
Gateways set context.input to plain text only; a structured turn with no text
sets it to "". The prompt gates on Input#submitted? (text OR attachment), and
Input delegates public String methods to its text so existing validators and
transforms keep working unchanged. app.media is always an Array so multi-
attachment messages never silently drop items.
BREAKING CHANGE: The "$media$", "$location$", and "$contact$" input sentinels
are removed. context.input is always plain text ("" when a turn carries no
text). app.input is now a FlowChat::Input rather than a raw string, and
app.media always returns an Array<FlowChat::Media> (empty when none) instead of
a single item. Branch on app.attachment_type instead of comparing input to a
sentinel string.
Claude-Session: https://claude.ai/code/session_01MgpYCqkxqafrxTp2CUtGii
Left over from removing the input sentinel block; fixes standardrb Layout/EmptyLinesAroundModuleBody. Claude-Session: https://claude.ai/code/session_01MgpYCqkxqafrxTp2CUtGii
Addresses review findings on the FlowChat::Input turn model: - App#screen now treats a screen as answered when its key is present in the session (non-nil), not merely truthy. A caption-less attachment answers with "", which previously failed the `.present?` cache-hit guard and caused the screen to re-ask every turn. Also fixes false/blank transform results looping. - App#prepare_user_input lets an opening message that carries an attachment through to the first screen instead of swallowing it as a bare session-opener; text-only openers keep the existing "wake the flow" behavior. - Input#== coerces a FlowChat::Input operand to its text so two Inputs with equal text compare equal; non-string operands still compare false. - Media#url is memoized so repeated reads don't re-issue the platform lookup. - HTTP gateway validates caller-supplied media_type against Media::CANONICAL_TYPES, falling back to :document. - Mark the historical inbound-media spec/plan as superseded by the Input model. Claude-Session: https://claude.ai/code/session_01MgpYCqkxqafrxTp2CUtGii
go_back raises RestartFlow, which the executor handles by rebuilding a fresh App from the same context. The previous @input_consumed flag lived on the discarded instance, so on restart the newly-uncached screen consumed the back-trigger input as its answer instead of re-prompting. Clear the turn on the shared context (input + media/location/contact) so the rebuilt App sees an empty inbound message.
Inbound media/location/contact now set a blank input string instead of a '$media$'-style sentinel, so the 'if context.input.present?' gate silently dropped them from instrumentation. Add a shared Instrumentation#inbound_message? predicate (text OR media/location/contact) and gate MESSAGE_RECEIVED on it across the Telegram, WhatsApp, and HTTP gateways.
- #url and #download rescue to nil uniformly, so a network/API failure no longer raises on WhatsApp/Telegram while returning nil elsewhere. - #download fetches the memoized #url for every non-WhatsApp platform, removing the Telegram special case and its redundant second getFile. - marshal_dump/marshal_load drop the live platform client, so a Media (or an Input holding one) is safe to persist in a Marshal-backed session store; a client-less restored Media degrades to nil. - Document what a screen should return and the persistence caveat.
|
Closing: this work is already on It landed via cd3b450 — the commit titled "docs: revamp README and all docs, add GitHub Pages landing page (#3)", which despite its subject touched 11 files under
Verified rather than assumed. Every file that is purely inbound-media is byte-identical between this branch's tip and
On A rebase was attempted and abandoned deliberately. The remaining differences are all places
The design document on |
Summary
Adds inbound media support so flows can read the media, location, and contact data users send — previously
app.media/app.location/app.contact_namewere hardcoded tonil, stranding data the WhatsApp/Telegram gateways already parsed. Fills in inbound parsing for Intercom and HTTP, and — as the branch evolved — replaces the old input-sentinel model with a richFlowChat::Inputvalue object so text and attachments are first-class on every turn.The turn is now a value object
Every turn is a
FlowChat::Input(app.input) with two independent axes: text and an optional attachment.app.text→ the turn's text (message body or a media caption). Always a String;""when the turn carried no text (e.g. a caption-less photo).app.attachment_type→:media/:location/:contact/nil— the discriminator to branch on.app.media→ always anArray<FlowChat::Media>(empty when none), so multi-attachment messages never silently drop items.app.location,app.contact,app.contact_name.FlowChat::Media:type(normalized::photo→:image,:voice→:audio),raw_type(platform-native),mime_type,caption,filename,id/file_id, plusurl/downloadto fetch bytes per platform.FlowChat::Inputdelegates public String methods to its text, so existingvalidate/transformlambdas (input.strip,input.to_i,input.blank?) keep working unchanged — and they can now also inspect attachments (input.media.any?). A screen is satisfied wheninput.submitted?— text or an attachment — so a caption-less photo still answers.Platform coverage
get_media_url/download_mediaget_file/file_url/download_file; message-level captions capturedmedia_url/media_type/mime_typeparams"$media$","$location$", and"$contact$"input sentinels are removed.context.inputis always plain text now (""when a turn carries no text); the structured payload lives onrequest.media/request.location/request.contact. Branch onapp.attachment_typeinstead of comparinginputto a sentinel string.app.inputis now aFlowChat::Input, not a raw String (it still behaves like its text for string ops and==).app.mediaalways returns anArray<FlowChat::Media>(empty when none) rather than a single object — iterate uniformly. The interimapp.media_itemsaccessor is gone;app.mediais the list.Conveyed to git-cliff via a
refactor!:commit with aBREAKING CHANGE:footer.Test Plan
standardrbcleanFlowChat::Inputunit tests (text coercion,submitted?,attachment_type, media-always-a-list, string delegation, public-onlyrespond_to?, equality,START)FlowChat::Media(readers, per-platformurl/downloaddispatch, direct-HTTP download, type normalization)input, including multi-attachment and caption casesAppaccessor +media_clientwiring testsDesign spec and implementation plan under
docs/superpowers/.🤖 Generated with Claude Code