Add multiple numbers, file input, --all to resend-inbounds

Amolith created

Accept repeatable --number flags and --file for bulk number lists. Add
--all to resend to every customer, gated behind interactive confirmation
(type y/yes) or --force. Abort in non-TTY without --force.

Widen the event filter to match both 'in' and 'thru' messages, since
both are inbound from the customer's perspective.

Validate that --all is mutually exclusive with --number/--file, and that
at least one targeting option is always provided.

Change summary

bin/resend-inbounds | 93 ++++++++++++++++++++++++++++++++++++++++------
1 file changed, 80 insertions(+), 13 deletions(-)

Detailed changes

bin/resend-inbounds 🔗

@@ -5,7 +5,12 @@
 # them through the webhook handler.
 #
 # Usage:
-#   resend-inbounds --start TIMESTAMP --end TIMESTAMP --number TEL [--dry-run]
+#   resend-inbounds --start TIMESTAMP [--end TIMESTAMP] [targeting] [options]
+#
+# Targeting (at least one required):
+#   --number TEL     Customer phone number (repeatable)
+#   --file PATH      File with newline-separated phone numbers
+#   --all            Resend to ALL customers (requires confirmation or --force)
 #
 # TIMESTAMP is anything GNU date -d accepts:
 #   - ISO 8601:  2025-06-01T00:00:00Z
@@ -16,7 +21,9 @@
 # TEL is the JMP customer's phone number, with or without +1 prefix. 5551234567
 # and +15551234567 are equivalent.
 #
-# --dry-run  prints matching messages without POSTing.
+# Options:
+#   --dry-run  Print matching messages without POSTing.
+#   --force    Skip interactive confirmation for --all.
 #
 # Environment:
 #   REDIS_URL    Redis connection URL   (default: redis://127.0.0.1:6379/0)
@@ -29,11 +36,15 @@
 # Examples:
 #   resend-inbounds --start "2025-06-01" --end "2025-06-02" --number +15551234567
 #   resend-inbounds --start "3 hours ago" --number 5551234567 --dry-run
+#   resend-inbounds --start "3 hours ago" --number 5551234567 --number 5559876543
+#   resend-inbounds --start "2025-06-01" --file numbers.txt
+#   resend-inbounds --start "2025-06-01" --all --force
 
 require "json"
 require "net/http"
 require "optparse"
 require "redis"
+require "set"
 require "shellwords"
 require "time"
 
@@ -55,11 +66,13 @@ def to_ms(str)
 	epoch * 1000
 end
 
-def normalise_tel(number)
+def normalise_tel(number, location: nil)
 	case number
 	when /\A\+1\d{10}\z/ then number
 	when /\A\d{10}\z/ then "+1#{number}"
-	else abort "error: invalid phone number: #{number}"
+	else
+		context = location ? " (#{location})" : ""
+		abort "error: invalid phone number: #{number}#{context}"
 	end
 end
 
@@ -83,21 +96,60 @@ def build_payload(stream_id, fields)
 	}])
 end
 
+def load_numbers_file(path)
+	numbers = Set.new
+	File.readlines(path).each_with_index do |line, idx|
+		line = line.strip
+		next if line.empty? || line.start_with?("#")
+		numbers << normalise_tel(line, location: "line #{idx + 1}")
+	end
+	abort "error: no phone numbers found in #{path}" if numbers.empty?
+	numbers
+rescue Errno::ENOENT
+	abort "error: file not found: #{path}"
+rescue Errno::EACCES
+	abort "error: permission denied: #{path}"
+end
+
 start_ms = nil
 end_ms = "+"
-owner = nil
+owners = Set.new
+all_customers = false
 dry_run = false
+force = false
 
 OptionParser.new do |opts|
-	opts.banner = "Usage: resend-inbounds --start TIMESTAMP --number TEL [options]"
+	opts.banner = "Usage: resend-inbounds --start TIMESTAMP [targeting] [options]"
 	opts.on("--start TIMESTAMP", "Start of time range (required)") { |v| start_ms = to_ms(v).to_s }
 	opts.on("--end TIMESTAMP", "End of time range (default: now)") { |v| end_ms = to_ms(v).to_s }
-	opts.on("--number TEL", "Customer phone number (required)") { |v| owner = normalise_tel(v) }
+	opts.on("--number TEL", "Customer phone number (repeatable)") { |v| owners << normalise_tel(v) }
+	opts.on("--file PATH", "File with newline-separated phone numbers") { |v| owners.merge(load_numbers_file(v)) }
+	opts.on("--all", "Resend to ALL customers") { all_customers = true }
 	opts.on("--dry-run", "Print matches without POSTing") { dry_run = true }
+	opts.on("--force", "Skip interactive confirmation for --all") { force = true }
 end.parse!
 
 abort "error: --start is required" unless start_ms
-abort "error: --number is required" unless owner
+if all_customers && !owners.empty?
+	abort "error: --all cannot be combined with --number or --file"
+end
+if !all_customers && owners.empty?
+	abort "error: provide --number, --file, or --all"
+end
+
+if all_customers && !force
+	action = dry_run ? "scan" : "resend"
+	$stderr.puts "WARNING: This will #{action} ALL inbound messages in the given range."
+	$stderr.puts "  stream: #{STREAM_KEY}"
+	$stderr.puts "  start:  #{start_ms}"
+	$stderr.puts "  end:    #{end_ms == '+' ? 'now' : end_ms}"
+	$stderr.puts "  dry_run: #{dry_run}"
+	$stderr.puts ""
+	abort "error: refusing --all in non-interactive mode (use --force)" unless $stdin.tty?
+	$stderr.print "Enter [y]es to continue, anything else aborts: "
+	confirmation = $stdin.gets&.strip
+	abort "aborted" unless confirmation == "yes" || confirmation == "y"
+end
 
 redis = Redis.new(url: REDIS_URL)
 uri = URI(WEBHOOK_URL) unless dry_run
@@ -107,6 +159,7 @@ resent = 0
 failed = 0
 consecutive_failures = 0
 max_consecutive_failures = 5
+matched_owners = Set.new # Track which explicit owners had matches
 
 http = unless dry_run
 	h = Net::HTTP.new(uri.host, uri.port)
@@ -120,9 +173,11 @@ begin
 		break if entries.empty?
 
 		entries.each do |id, fields|
-			next unless fields["event"] == "in" && fields["owner"] == owner
+			next unless %w[in thru].include?(fields["event"])
+			next unless all_customers || owners.include?(fields["owner"])
 
 			matched += 1
+			matched_owners << fields["owner"] unless all_customers
 			ts = fields["timestamp"] || "unknown"
 			body_preview = (fields["body"] || "")[0, 80]
 
@@ -167,10 +222,22 @@ ensure
 	http&.finish
 end
 
+target_desc = if all_customers
+	"all customers"
+elsif owners.size == 1
+	owners.first
+else
+	"#{owners.size} numbers"
+end
+
 if matched.zero?
-	puts "no inbound messages found for #{owner} in the given range"
-elsif dry_run
-	puts "\n#{matched} inbound message(s) matched (dry run, nothing sent)"
+	puts "no inbound messages found for #{target_desc} in the given range"
 else
-	puts "\n#{matched} matched, #{resent} resent, #{failed} failed"
+	if dry_run
+		puts "\n#{matched} inbound message(s) matched (dry run, nothing sent)"
+	else
+		puts "\n#{matched} matched, #{resent} resent, #{failed} failed"
+	end
+	no_matches = owners - matched_owners
+	puts "numbers with no matches: #{no_matches.sort.join(', ')}" if no_matches.any?
 end