messengerr 0.1.0 → 0.2.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/README.md +9 -0
- data/lib/messengerr/cli/send_command.rb +74 -10
- data/lib/messengerr/prompter.rb +6 -1
- data/lib/messengerr/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f907dbc0370e1a30035ba22269b2ff1adb90facd0ea9836b360cc539827ac61
|
|
4
|
+
data.tar.gz: 9d08c5eff77009d71e3137f4bb4ae221e4c2292d029d6057166c2c7ce1635ecf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4fca38b64037c0014f5ae69e1f00acdaa0191e00d0fe7934dfa0987f7579748f5484595da025cb0ff149dedc35dd7d5924eba4bb6ea9123ba0172538273a3fc2
|
|
7
|
+
data.tar.gz: 7b09fdf9a874fefdeef28d5415ee50a314fc949d5d964cc4d5bbf73e5d6c2154e6c384bac39218286cc5b6438409c44821e28c5f506a45f84e8651a981579f71
|
data/README.md
CHANGED
|
@@ -27,6 +27,15 @@ messengerr send --dry-run # walk the wizard and preview, sending n
|
|
|
27
27
|
messengerr send # preview, confirm, send
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Every wizard question also has a flag, so a send can be one command. Preview it anywhere with `--dry-run`, then run the same command without it in a terminal:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
messengerr send --method email --csv contacts.csv --contact email --map name=name \
|
|
34
|
+
--message "Hi {{name}}, your invoice is ready." --subject "Invoice for {{name}}" --dry-run
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A real send is refused unless a person is at a terminal (so an automated shell can prepare and preview, but not send).
|
|
38
|
+
|
|
30
39
|
Given a CSV like
|
|
31
40
|
|
|
32
41
|
```text
|
|
@@ -28,17 +28,19 @@ module Messengerr
|
|
|
28
28
|
@prompter.say(@parser.help)
|
|
29
29
|
return 0
|
|
30
30
|
end
|
|
31
|
+
guard_interactive!
|
|
31
32
|
check_resume_ledger!
|
|
32
33
|
@file_message = load_message_file
|
|
33
34
|
|
|
34
35
|
method = pick_delivery_method
|
|
35
36
|
check_html_method!(method)
|
|
37
|
+
check_subject_method!(method)
|
|
36
38
|
integration_class = pick_integration(method) or return 1
|
|
37
39
|
check_html_supported!(integration_class)
|
|
38
40
|
message, subject = collect_message(method)
|
|
39
41
|
source = load_csv or return 1
|
|
40
|
-
mapping = map_placeholders(message, subject, source)
|
|
41
|
-
contact_column = pick_contact_column(method, source)
|
|
42
|
+
mapping = map_placeholders(message, subject, source) or return 1
|
|
43
|
+
contact_column = pick_contact_column(method, source) or return 1
|
|
42
44
|
outcome = validate_recipients(source, contact_column, method) or return 1
|
|
43
45
|
deliveries = preview_and_confirm(outcome[:valid], message, subject, mapping, method) or return 1
|
|
44
46
|
|
|
@@ -54,6 +56,7 @@ module Messengerr
|
|
|
54
56
|
extra = @parser.parse(args)
|
|
55
57
|
raise Error, "unexpected argument: #{extra.join(' ')} (see `messengerr send --help`)" unless extra.empty?
|
|
56
58
|
raise Error, "use either --html or --message-file, not both (see `messengerr send --help`)" if @flags[:html] && @flags[:message_file]
|
|
59
|
+
raise Error, "use either --message or --message-file/--html, not both" if @flags[:message] && (@flags[:message_file] || @flags[:html])
|
|
57
60
|
rescue OptionParser::ParseError => e
|
|
58
61
|
raise Error, "#{e.message} (see `messengerr send --help`)"
|
|
59
62
|
end
|
|
@@ -61,7 +64,19 @@ module Messengerr
|
|
|
61
64
|
def build_parser
|
|
62
65
|
OptionParser.new do |opts|
|
|
63
66
|
opts.banner = "Usage: messengerr send [flags]"
|
|
67
|
+
opts.on("--method METHOD", %w[email sms], "Delivery method (skips the chooser)") { |v| @flags[:method] = v.to_sym }
|
|
68
|
+
opts.on("--integration NAME", "Integration to use (e.g. postmark, clicksend)") { |v| @flags[:integration] = present("--integration", v) }
|
|
64
69
|
opts.on("--dry-run", "Preview the rendered messages; send nothing") { @flags[:dry_run] = true }
|
|
70
|
+
opts.on("--csv PATH", "Contact CSV (skips the path prompt)") { |v| @flags[:csv] = present("--csv", v) }
|
|
71
|
+
opts.on("--contact COLUMN", "Column holding the email/phone contact value") { |v| @flags[:contact] = present("--contact", v) }
|
|
72
|
+
opts.on("--map PLACEHOLDER=COLUMN", "Map a {{placeholder}} to a CSV column (repeatable)") do |v|
|
|
73
|
+
key, column = v.split("=", 2)
|
|
74
|
+
raise OptionParser::InvalidArgument, "#{v} (expected PLACEHOLDER=COLUMN)" if key.nil? || column.nil? || key.empty? || column.empty?
|
|
75
|
+
(@flags[:map] ||= {})[key] = column
|
|
76
|
+
end
|
|
77
|
+
opts.on("--message TEXT", "Message body as a literal argument") { |v| @flags[:message] = present("--message", v) }
|
|
78
|
+
opts.on("--subject TEXT", "Email subject line ({{placeholders}} allowed)") { |v| @flags[:subject] = present("--subject", v) }
|
|
79
|
+
opts.on("--allow-non-interactive", "Send without an interactive terminal (human scripts only)") { @flags[:allow_non_interactive] = true }
|
|
65
80
|
opts.on("--resume LEDGER", "Skip recipients already sent in this ledger (row + contact)") do |v|
|
|
66
81
|
@flags[:resume] = v
|
|
67
82
|
@flags[:ledger] = v
|
|
@@ -76,12 +91,30 @@ module Messengerr
|
|
|
76
91
|
end
|
|
77
92
|
end
|
|
78
93
|
|
|
94
|
+
# A blank value is almost always a mistake (an empty shell variable), and
|
|
95
|
+
# for --message it would mean sending an empty message.
|
|
96
|
+
def present(flag, value)
|
|
97
|
+
raise Error, "#{flag} can't be blank (see `messengerr send --help`)" if value.strip.empty?
|
|
98
|
+
|
|
99
|
+
value
|
|
100
|
+
end
|
|
101
|
+
|
|
79
102
|
def positive(value)
|
|
80
103
|
raise OptionParser::InvalidArgument, "#{value} (must be a positive integer)" unless value.positive?
|
|
81
104
|
|
|
82
105
|
value
|
|
83
106
|
end
|
|
84
107
|
|
|
108
|
+
# Real sends are for humans at terminals. An agent shell or CI hitting
|
|
109
|
+
# this path gets a clear refusal; the documented escape hatch exists for
|
|
110
|
+
# the user's own scripts only.
|
|
111
|
+
def guard_interactive!
|
|
112
|
+
return if @flags[:dry_run] || @flags[:allow_non_interactive] || @prompter.interactive?
|
|
113
|
+
|
|
114
|
+
raise Error, "refusing to send without an interactive terminal: run `messengerr send` yourself in a terminal " \
|
|
115
|
+
"(or pass --allow-non-interactive in your own scripts). --dry-run works anywhere."
|
|
116
|
+
end
|
|
117
|
+
|
|
85
118
|
# A mistyped --resume path must not silently mean "nothing sent yet".
|
|
86
119
|
def check_resume_ledger!
|
|
87
120
|
return unless @flags[:resume]
|
|
@@ -114,6 +147,10 @@ module Messengerr
|
|
|
114
147
|
raise Error, "--html is only for email; SMS messages are plain text (use --message-file)" if @flags[:html] && method != :email
|
|
115
148
|
end
|
|
116
149
|
|
|
150
|
+
def check_subject_method!(method)
|
|
151
|
+
raise Error, "--subject is only for email; SMS messages have no subject" if @flags[:subject] && method != :email
|
|
152
|
+
end
|
|
153
|
+
|
|
117
154
|
def check_html_supported!(integration_class)
|
|
118
155
|
return unless @flags[:html]
|
|
119
156
|
return if integration_class.formats.include?(:html)
|
|
@@ -124,7 +161,7 @@ module Messengerr
|
|
|
124
161
|
def message_format = @flags[:html] ? :html : :text
|
|
125
162
|
|
|
126
163
|
def pick_delivery_method
|
|
127
|
-
@prompter.choose("Delivery method:", %w[email sms]).to_sym
|
|
164
|
+
@flags[:method] || @prompter.choose("Delivery method:", %w[email sms]).to_sym
|
|
128
165
|
end
|
|
129
166
|
|
|
130
167
|
def pick_integration(method)
|
|
@@ -135,6 +172,17 @@ module Messengerr
|
|
|
135
172
|
return nil
|
|
136
173
|
end
|
|
137
174
|
|
|
175
|
+
if @flags[:integration]
|
|
176
|
+
wanted = @flags[:integration].to_sym
|
|
177
|
+
match = integrations.find { |i| i.integration_key == wanted }
|
|
178
|
+
unless match
|
|
179
|
+
@prompter.say("Integration '#{wanted}' is not configured for #{method}. " \
|
|
180
|
+
"Configured: #{integrations.map(&:integration_key).join(', ')}")
|
|
181
|
+
return nil
|
|
182
|
+
end
|
|
183
|
+
return match
|
|
184
|
+
end
|
|
185
|
+
|
|
138
186
|
return integrations.first if integrations.length == 1
|
|
139
187
|
|
|
140
188
|
keys = integrations.map(&:integration_key)
|
|
@@ -143,13 +191,13 @@ module Messengerr
|
|
|
143
191
|
end
|
|
144
192
|
|
|
145
193
|
def collect_message(method)
|
|
146
|
-
message = @file_message
|
|
147
|
-
if
|
|
194
|
+
message = @file_message || @flags[:message]
|
|
195
|
+
if @file_message
|
|
148
196
|
@prompter.say("Message: #{message.lines.length} line(s) from #{@flags[:html] || @flags[:message_file]}")
|
|
149
|
-
|
|
197
|
+
elsif message.nil?
|
|
150
198
|
message = @prompter.ask("Message (use {{column}} placeholders):")
|
|
151
199
|
end
|
|
152
|
-
subject = ask_subject if method == :email
|
|
200
|
+
subject = @flags[:subject] || (ask_subject if method == :email)
|
|
153
201
|
[ message, subject ]
|
|
154
202
|
end
|
|
155
203
|
|
|
@@ -164,7 +212,7 @@ module Messengerr
|
|
|
164
212
|
end
|
|
165
213
|
|
|
166
214
|
def load_csv
|
|
167
|
-
path = @prompter.ask("Path to CSV file:")
|
|
215
|
+
path = @flags[:csv] || @prompter.ask("Path to CSV file:")
|
|
168
216
|
source = CsvSource.from(path)
|
|
169
217
|
@prompter.say("Columns: #{source.headers.join(', ')}")
|
|
170
218
|
source
|
|
@@ -176,15 +224,31 @@ module Messengerr
|
|
|
176
224
|
# Placeholders in the subject are mapped just like body placeholders.
|
|
177
225
|
def map_placeholders(message, subject, source)
|
|
178
226
|
placeholders = Placeholder.extract(message) + Placeholder.extract(subject)
|
|
227
|
+
flag_map = @flags[:map] || {}
|
|
228
|
+
flag_map.each_key do |placeholder|
|
|
229
|
+
unless source.headers.include?(flag_map[placeholder])
|
|
230
|
+
@prompter.say("--map #{placeholder}=#{flag_map[placeholder]}: no such column in the CSV.")
|
|
231
|
+
return nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
unused = flag_map.keys - placeholders
|
|
235
|
+
unless unused.empty?
|
|
236
|
+
@prompter.say("Note: --map #{unused.join(', ')} not used: no such {{placeholder}} in the message or subject.")
|
|
237
|
+
end
|
|
179
238
|
placeholders.uniq.to_h do |placeholder|
|
|
180
|
-
column = @prompter.choose("Map {{#{placeholder}}} to column:", source.headers)
|
|
239
|
+
column = flag_map[placeholder] || @prompter.choose("Map {{#{placeholder}}} to column:", source.headers)
|
|
181
240
|
[ placeholder, column ]
|
|
182
241
|
end
|
|
183
242
|
end
|
|
184
243
|
|
|
185
244
|
def pick_contact_column(method, source)
|
|
186
245
|
label = { email: "email address", sms: "phone number" }[method]
|
|
187
|
-
@prompter.choose("Which column holds the #{label}?", source.headers)
|
|
246
|
+
column = @flags[:contact] || @prompter.choose("Which column holds the #{label}?", source.headers)
|
|
247
|
+
unless source.headers.include?(column)
|
|
248
|
+
@prompter.say("--contact #{column}: no such column in the CSV.")
|
|
249
|
+
return nil
|
|
250
|
+
end
|
|
251
|
+
column
|
|
188
252
|
end
|
|
189
253
|
|
|
190
254
|
def validate_recipients(source, contact_column, method)
|
data/lib/messengerr/prompter.rb
CHANGED
|
@@ -3,11 +3,16 @@ require "io/console"
|
|
|
3
3
|
module Messengerr
|
|
4
4
|
# Injectable stdin/stdout interaction so CLI flows are scriptable and testable.
|
|
5
5
|
class Prompter
|
|
6
|
-
def initialize(input: $stdin, output: $stdout)
|
|
6
|
+
def initialize(input: $stdin, output: $stdout, interactive: nil)
|
|
7
7
|
@input = input
|
|
8
8
|
@output = output
|
|
9
|
+
@interactive = interactive.nil? ? (@input.respond_to?(:tty?) && @input.tty?) : interactive
|
|
9
10
|
end
|
|
10
11
|
|
|
12
|
+
# Real sends require a human at a terminal; anything else (pipes, agent
|
|
13
|
+
# shells) is non-interactive. Specs inject the value.
|
|
14
|
+
def interactive? = @interactive
|
|
15
|
+
|
|
11
16
|
def say(message)
|
|
12
17
|
@output.puts(message)
|
|
13
18
|
end
|
data/lib/messengerr/version.rb
CHANGED