From b6058eb4db92aa1e3bd72777fee01da204920f24 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 13 Mar 2026 16:18:55 -0600 Subject: [PATCH] Add multiple numbers, file input, --all to resend-inbounds 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. --- bin/resend-inbounds | 93 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/bin/resend-inbounds b/bin/resend-inbounds index fccc0aaba932d4d0f41f8907f50d373768622b32..936ed3cf60d75209aed529a13d38631989ff1ca9 100755 --- a/bin/resend-inbounds +++ b/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