Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,37 @@ Intercepted messages are persisted as files under `tmp/sms_trap` in the host app
in memory, so they're visible across every worker process — including a clustered Puma dev
server — and survive server restarts until explicitly cleared with `SmsTrap.store.clear`.

## Simulating inbound replies

Typing a reply in the SmsTrap UI can trigger your app's real inbound-webhook path — not a
fake success, the same code that runs when your provider posts a genuine webhook. Configure
it by setting `SmsTrap.reply_handler` in a development-only initializer to any callable
responding to `#call(from:, to:, text:)`:

```ruby
# config/initializers/sms_trap.rb
SmsTrap.reply_handler = lambda do |from:, to:, text:|
# Build whatever wire format your provider's webhook expects, and POST it to your app's
# own webhook endpoint (e.g. via ActionDispatch::Integration::Session), the same way your
# provider's real webhook call would land — SmsTrap never touches this format itself.
YourApp::Sms::Adapter.new.simulate_reply(from: from, to: to, text: text)
end
```

SmsTrap doesn't know or care what's inside the callable — it stays exactly as
provider-agnostic on the inbound side as `Connector` is on the outbound side. All it
guarantees is that your handler gets *called* with the reply's `from`/`to`/`text`; whether
your app's downstream logic does anything observable (e.g. matching an active conversation
thread) is entirely up to your app's real code, same as it would be for a genuine provider
webhook.

If no `reply_handler` is configured, the reply form doesn't render at all — apps that don't
need inbound simulation don't need to set anything up.

## Roadmap

Inbound reply simulation — typing a reply in the SmsTrap UI and having it flow back into
your app exactly as a real inbound webhook would — is planned for a later release. Its
interface will be designed once outbound interception has seen real usage.
None currently — outbound interception and inbound reply simulation are both shipped.
Feature requests are welcome via GitHub issues.

## Contributing

Expand Down
30 changes: 30 additions & 0 deletions app/controllers/sms_trap/replies_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module SmsTrap
# Handles a dev submitting a reply in the UI: triggers the host app's real inbound path via
# {SmsTrap.reply_handler}, then records the reply so it shows up as an inbound bubble.
class RepliesController < ApplicationController
def create
conversation = find_conversation
return head(:not_found) unless conversation

simulate_reply(conversation)
redirect_to conversation_path(conversation)
end

private

def find_conversation
SmsTrap.store.conversations.find { |c| c.to_param == params[:conversation_id] }
end

def simulate_reply(conversation)
from = conversation.their_number
to = conversation.our_number
text = params[:text]

SmsTrap.reply_handler.call(from: from, to: to, text: text)
SmsTrap.store.record(from: from, to: to, text: text, direction: 'inbound')
end
end
end
26 changes: 26 additions & 0 deletions app/views/layouts/sms_trap/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,32 @@
background: #e5e5ea;
color: #1c1c1e;
}

.sms-trap-reply-form {
display: flex;
gap: 8px;
margin-top: 16px;
}

.sms-trap-reply-input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d1d1d6;
border-radius: 18px;
font-size: 14px;
font-family: inherit;
}

.sms-trap-reply-submit {
padding: 10px 18px;
border: none;
border-radius: 18px;
background: #007aff;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
</style>
</head>
<body>
Expand Down
7 changes: 7 additions & 0 deletions app/views/sms_trap/conversations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,12 @@
</div>
<% end %>
</div>

<% if SmsTrap.reply_handler? %>
<%= form_with url: conversation_reply_path(@conversation), class: "sms-trap-reply-form" do |f| %>
<%= f.text_field :text, placeholder: "Type a reply…", class: "sms-trap-reply-input", autocomplete: "off" %>
<%= f.submit "Send", class: "sms-trap-reply-submit" %>
<% end %>
<% end %>
</div>
</div>
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@

SmsTrap::Engine.routes.draw do
root to: 'conversations#index'
resources :conversations, only: %i[index show]
resources :conversations, only: %i[index show] do
resource :reply, only: :create
end
end
20 changes: 20 additions & 0 deletions lib/sms_trap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,30 @@
module SmsTrap
class Error < StandardError; end

# Raised when {reply_handler} is read before the host app has configured one.
class ReplyHandlerNotConfigured < Error; end

class << self
# @return [Store] the process-wide store every {Connector} records into by default
def store
@store ||= Store.new
end

# @param handler [#call] called with from:/to:/text: when a dev submits a reply in the
# UI, to trigger the host app's real inbound path — see README for wiring examples
attr_writer :reply_handler

# @return [#call] the configured reply handler
# @raise [ReplyHandlerNotConfigured] if the host app hasn't set one
def reply_handler
@reply_handler || raise(ReplyHandlerNotConfigured,
'Set SmsTrap.reply_handler in an initializer, e.g. ' \
'SmsTrap.reply_handler = ->(from:, to:, text:) { ... }. See README.')
end

# @return [Boolean] whether a reply handler has been configured
def reply_handler?
@reply_handler.present?
end
end
end
2 changes: 1 addition & 1 deletion test/dummy/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
config.cache_store = :null_store

# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = false
config.action_dispatch.show_exceptions = :none

# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
Expand Down
7 changes: 7 additions & 0 deletions test/dummy/config/initializers/sms_trap.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

# Stand-in for wherever a real host app wires SmsTrap.reply_handler to its real inbound
# webhook path. The dummy app has no such path, so this just logs.
SmsTrap.reply_handler = lambda do |from:, to:, text:|
Rails.logger.info("[dummy] simulated inbound reply from #{from} to #{to}: #{text}")
end
50 changes: 50 additions & 0 deletions test/integration/reply_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

require 'test_helper'

class ReplyTest < ActionDispatch::IntegrationTest
setup { SmsTrap.store.clear }

test 'submitting a reply invokes the configured reply_handler and records an inbound message' do
SmsTrap::Connector.new.send_message(from: '+15550001111', to: '+15552223333', text: 'hello there')
conversation = SmsTrap.store.conversations.first
invocations = []

with_reply_handler(->(from:, to:, text:) { invocations << { from: from, to: to, text: text } }) do
post "/sms_trap/conversations/#{conversation.to_param}/reply", params: { text: 'got it, thanks' }
end

assert_redirected_to "/sms_trap/conversations/#{conversation.to_param}"
assert_equal [{ from: '+15552223333', to: '+15550001111', text: 'got it, thanks' }], invocations

get "/sms_trap/conversations/#{conversation.to_param}"
assert_match 'sms-trap-bubble--inbound', response.body
assert_match 'got it, thanks', response.body
end

test 'show renders the reply form only when a reply_handler is configured' do
SmsTrap::Connector.new.send_message(from: '+15550001111', to: '+15552223333', text: 'hello there')
conversation = SmsTrap.store.conversations.first

with_reply_handler(->(from:, to:, text:) {}) do
get "/sms_trap/conversations/#{conversation.to_param}"
assert_select 'form.sms-trap-reply-form', 1
end

with_reply_handler(nil) do
get "/sms_trap/conversations/#{conversation.to_param}"
assert_select 'form.sms-trap-reply-form', 0
end
end

test 'submitting a reply without a configured reply_handler raises' do
SmsTrap::Connector.new.send_message(from: '+15550001111', to: '+15552223333', text: 'hello there')
conversation = SmsTrap.store.conversations.first

with_reply_handler(nil) do
assert_raises(SmsTrap::ReplyHandlerNotConfigured) do
post "/sms_trap/conversations/#{conversation.to_param}/reply", params: { text: 'got it' }
end
end
end
end
24 changes: 24 additions & 0 deletions test/sms_trap_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,28 @@ class SmsTrapTest < ActiveSupport::TestCase
test 'it has a version number' do
assert SmsTrap::VERSION
end

test 'reply_handler returns the configured handler' do
handler = ->(from:, to:, text:) {}

with_reply_handler(handler) do
assert_equal handler, SmsTrap.reply_handler
end
end

test 'reply_handler raises a clear error when unset' do
with_reply_handler(nil) do
assert_raises(SmsTrap::ReplyHandlerNotConfigured) { SmsTrap.reply_handler }
end
end

test 'reply_handler? reflects whether a handler is configured' do
with_reply_handler(nil) do
assert_not SmsTrap.reply_handler?
end

with_reply_handler(->(from:, to:, text:) {}) do
assert SmsTrap.reply_handler?
end
end
end
10 changes: 10 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,15 @@
module ActiveSupport
class TestCase
teardown { SmsTrap.store.clear }

# Temporarily overrides SmsTrap.reply_handler for the duration of the block, restoring
# whatever was configured before (e.g. the dummy app's initializer) afterward.
def with_reply_handler(handler)
original = SmsTrap.instance_variable_get(:@reply_handler)
SmsTrap.reply_handler = handler
yield
ensure
SmsTrap.instance_variable_set(:@reply_handler, original)
end
end
end
Loading