belt-connect 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 +7 -0
- data/CHANGELOG.md +121 -0
- data/LICENSE +21 -0
- data/README.md +169 -0
- data/lib/belt/connect/configuration.rb +35 -0
- data/lib/belt/connect/snippet_parser.rb +129 -0
- data/lib/belt/connect/templates/config/widgets-README.md.erb +63 -0
- data/lib/belt/connect/templates/frontend/ConnectWidget.jsx.erb +116 -0
- data/lib/belt/connect/templates/frontend/widgets.js.erb +59 -0
- data/lib/belt/connect/templates/terraform/main.tf.erb +323 -0
- data/lib/belt/connect/templates/terraform/outputs.tf.erb +76 -0
- data/lib/belt/connect/templates/terraform/variables.tf.erb +100 -0
- data/lib/belt/connect/version.rb +7 -0
- data/lib/belt/connect.rb +35 -0
- data/lib/belt/generators/connect/add_widget.rb +319 -0
- data/lib/belt/generators/connect_generator.rb +322 -0
- data/lib/belt-connect.rb +3 -0
- metadata +110 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'belt/connect'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module Generators
|
|
8
|
+
# `belt generate connect add-widget <name> [snippet-file] [--env <env>]`
|
|
9
|
+
#
|
|
10
|
+
# Parses an Amazon Connect "Get widget code" snippet and upserts a named
|
|
11
|
+
# entry into the frontend widget registry (frontend/src/connect/widgets.js),
|
|
12
|
+
# then prints the VITE_CONNECT_<NAME>_* env lines to add. The base64
|
|
13
|
+
# snippetId is a secret, so it is wired through env — never written into the
|
|
14
|
+
# committed registry — while the widget id and URL (not secret) land in the
|
|
15
|
+
# registry as readable config.
|
|
16
|
+
#
|
|
17
|
+
# Convention: the raw snippet lives at config/connect/widgets/<name>.js in
|
|
18
|
+
# the app (gitignored — it holds the snippetId). Omit the file argument and
|
|
19
|
+
# that's where we look; pass a file from anywhere else and it's copied there
|
|
20
|
+
# so the app always has one obvious bay for widget sources instead of a
|
|
21
|
+
# paste in a ticket.
|
|
22
|
+
#
|
|
23
|
+
# With --env <env>, the three SSM parameters the Terraform module seeds
|
|
24
|
+
# (chat-snippet-id, chat-widget-url, chat-widget-element-id) are written as
|
|
25
|
+
# a matched set from the same parsed snippet, so a human never runs three
|
|
26
|
+
# `aws ssm put-parameter` commands by hand and mixes widgets (the 403).
|
|
27
|
+
#
|
|
28
|
+
# This is the mechanical snippet->config translation that stops a human from
|
|
29
|
+
# ever retyping the base64 snippetId (the bug behind the wrong-widget 403).
|
|
30
|
+
class ConnectGenerator
|
|
31
|
+
class AddWidget
|
|
32
|
+
WIDGETS_DIR = 'config/connect/widgets'
|
|
33
|
+
|
|
34
|
+
# SSM parameter names the Terraform module provisions, keyed by the
|
|
35
|
+
# parsed-snippet field that feeds each one. Matched set — same widget.
|
|
36
|
+
SSM_PARAMS = {
|
|
37
|
+
snippet_id: 'chat-snippet-id',
|
|
38
|
+
widget_url: 'chat-widget-url',
|
|
39
|
+
id: 'chat-widget-element-id'
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# runner: how shell commands are executed (swapped out in specs).
|
|
43
|
+
def initialize(args, runner: method(:system))
|
|
44
|
+
@runner = runner
|
|
45
|
+
@opts = parse_options(args)
|
|
46
|
+
@name = @opts[:positional][0]
|
|
47
|
+
@source = @opts[:positional][1]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
return usage if @name.nil? || @opts[:help]
|
|
52
|
+
|
|
53
|
+
snippet = read_snippet
|
|
54
|
+
return if snippet.nil?
|
|
55
|
+
|
|
56
|
+
result = Belt::Connect::SnippetParser.parse(snippet)
|
|
57
|
+
|
|
58
|
+
registry = registry_path
|
|
59
|
+
unless File.exist?(registry)
|
|
60
|
+
warn "Widget registry not found at #{registry}."
|
|
61
|
+
warn 'Run `belt generate connect` first to scaffold it.'
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
stash_snippet(snippet)
|
|
66
|
+
upsert_entry(registry, @name, result)
|
|
67
|
+
warn_jwt if result.jwt?
|
|
68
|
+
push_ssm(result) if @opts[:env] || @opts[:ssm_prefix]
|
|
69
|
+
print_result(@name, result)
|
|
70
|
+
rescue Belt::Connect::SnippetParser::ParseError => e
|
|
71
|
+
warn "Could not parse the Connect snippet: #{e.message}"
|
|
72
|
+
warn 'Make sure you copied the full "Get widget code" <script> block.'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def parse_options(args)
|
|
78
|
+
opts = { positional: [] }
|
|
79
|
+
rest = args.dup
|
|
80
|
+
until rest.empty?
|
|
81
|
+
arg = rest.shift
|
|
82
|
+
case arg
|
|
83
|
+
when '--env' then opts[:env] = rest.shift
|
|
84
|
+
when '--app-name' then opts[:app_name] = rest.shift
|
|
85
|
+
when '--ssm-prefix' then opts[:ssm_prefix] = rest.shift
|
|
86
|
+
when '--help', '-h' then opts[:help] = true
|
|
87
|
+
when /\A--/
|
|
88
|
+
warn "Unknown option #{arg} (ignored)"
|
|
89
|
+
else
|
|
90
|
+
opts[:positional] << arg
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
opts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def usage
|
|
97
|
+
puts <<~USAGE
|
|
98
|
+
Usage: belt generate connect add-widget <name> [snippet-file] [options]
|
|
99
|
+
|
|
100
|
+
Parses an Amazon Connect "Get widget code" snippet into the widget
|
|
101
|
+
registry and prints the env lines to add.
|
|
102
|
+
|
|
103
|
+
The snippet is read from #{WIDGETS_DIR}/<name>.js when no file is
|
|
104
|
+
given. A file passed from anywhere else is copied there, so the app
|
|
105
|
+
always has one place for widget sources (the directory is gitignored —
|
|
106
|
+
the snippet holds the secret snippetId).
|
|
107
|
+
|
|
108
|
+
Options:
|
|
109
|
+
--env <env> Also write the matched set (snippet id, widget
|
|
110
|
+
url, element id) to the module's SSM params:
|
|
111
|
+
/<app-name>/<env>/connect/chat-*
|
|
112
|
+
--app-name <name> App name for the SSM prefix (default: detected
|
|
113
|
+
from config/routes.rb, else the directory name)
|
|
114
|
+
--ssm-prefix <path> Full SSM prefix, overrides --env/--app-name
|
|
115
|
+
-h, --help Show this help
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
belt generate connect add-widget support
|
|
119
|
+
belt generate connect add-widget support ~/Downloads/code.js --env sandbox
|
|
120
|
+
USAGE
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- snippet source -------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def conventional_path
|
|
126
|
+
File.join(WIDGETS_DIR, "#{@name}.js")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def read_snippet
|
|
130
|
+
if @source.nil?
|
|
131
|
+
return File.read(conventional_path) if File.exist?(conventional_path)
|
|
132
|
+
|
|
133
|
+
warn "No snippet file given and #{conventional_path} does not exist."
|
|
134
|
+
warn ''
|
|
135
|
+
warn 'Create the widget in the Connect admin console (Channels → Communications'
|
|
136
|
+
warn "widgets → Copy script), save the <script> block to #{conventional_path},"
|
|
137
|
+
warn 'then re-run. Or pass the file path explicitly.'
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return File.read(@source) if File.exist?(@source)
|
|
142
|
+
|
|
143
|
+
# Allow passing the snippet inline as well (handy for scripts/pipes).
|
|
144
|
+
@source
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Keep the raw snippet at the conventional path so the next person (or
|
|
148
|
+
# machine) can re-run add-widget without hunting for a paste.
|
|
149
|
+
def stash_snippet(snippet)
|
|
150
|
+
return if @source.nil? # already read from the conventional path
|
|
151
|
+
return if File.exist?(@source) && File.expand_path(@source) == File.expand_path(conventional_path)
|
|
152
|
+
|
|
153
|
+
FileUtils.mkdir_p(WIDGETS_DIR)
|
|
154
|
+
File.write(conventional_path, snippet)
|
|
155
|
+
puts " stash #{conventional_path} (gitignored — holds the snippetId)"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- registry -------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def registry_path
|
|
161
|
+
base = if Dir.exist?('app/src') && !Dir.exist?('frontend/src')
|
|
162
|
+
'app/src/connect'
|
|
163
|
+
else
|
|
164
|
+
'frontend/src/connect'
|
|
165
|
+
end
|
|
166
|
+
File.join(base, 'widgets.js')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build the JS entry for a widget. id + widgetUrl are readable config and
|
|
170
|
+
# go straight into the registry; snippetId stays an env reference.
|
|
171
|
+
def entry_js(name, result)
|
|
172
|
+
<<~JS.chomp
|
|
173
|
+
#{name}: {
|
|
174
|
+
// Widget instance id (4th arg of the Connect bootstrap snippet).
|
|
175
|
+
id: "#{result.id}",
|
|
176
|
+
// Secret — stays in env/SSM, never committed.
|
|
177
|
+
snippetId: import.meta.env.#{env_var(name, 'SNIPPET_ID')},
|
|
178
|
+
// Hosted widget loader URL (same per Connect instance).
|
|
179
|
+
widgetUrl: "#{result.widget_url}",
|
|
180
|
+
styles: {
|
|
181
|
+
iconType: "#{icon_type(result)}",
|
|
182
|
+
brandColor: BRAND_COLOR,
|
|
183
|
+
typeface: TYPEFACE,
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
JS
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def icon_type(result)
|
|
190
|
+
(result.styles && result.styles['iconType']) || 'CHAT'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def env_var(name, suffix)
|
|
194
|
+
"VITE_CONNECT_#{name.upcase}_#{suffix}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Upsert the named entry inside the `export const widgets = { ... }`
|
|
198
|
+
# object. If the name already exists, replace its block; otherwise insert
|
|
199
|
+
# a new one right after the opening brace.
|
|
200
|
+
def upsert_entry(path, name, result)
|
|
201
|
+
source = File.read(path)
|
|
202
|
+
entry = " #{entry_js(name, result)},"
|
|
203
|
+
|
|
204
|
+
source =
|
|
205
|
+
if source =~ /export const widgets = \{/ && source.match?(existing_within_widgets(name))
|
|
206
|
+
replace_entry(source, name, entry)
|
|
207
|
+
else
|
|
208
|
+
source.sub(/export const widgets = \{\n/, "export const widgets = {\n#{entry}\n")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
File.write(path, source)
|
|
212
|
+
puts " update #{path} (widget \"#{name}\")"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A loose matcher used only to decide replace-vs-insert.
|
|
216
|
+
def existing_within_widgets(name)
|
|
217
|
+
/^\s{2}#{Regexp.escape(name)}:\s*\{/m
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def replace_entry(source, name, entry)
|
|
221
|
+
# Replace a top-level (2-space indented) named block, brace-balanced.
|
|
222
|
+
source.sub(/^\s{2}#{Regexp.escape(name)}:\s*\{.*?\n\s{2}\},?\n/m, "#{entry}\n")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# --- JWT --------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def warn_jwt
|
|
228
|
+
warn ''
|
|
229
|
+
warn '⚠ This snippet has JWT security enabled (it contains an'
|
|
230
|
+
warn " amazon_connect('authenticate', ...) hook). belt-connect's loader does"
|
|
231
|
+
warn ' not implement that callback yet, so the widget will load but refuse to'
|
|
232
|
+
warn ' start a chat. Either recreate the widget with security skipped'
|
|
233
|
+
warn ' ("No - I do not want to enable JWT security measures"), or add a'
|
|
234
|
+
warn ' backend route that signs a JWT with the widget security key and wire'
|
|
235
|
+
warn " it into ConnectWidget.jsx via amazon_connect('authenticate', cb)."
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# --- SSM --------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def ssm_prefix
|
|
241
|
+
return @opts[:ssm_prefix].sub(%r{/\z}, '') if @opts[:ssm_prefix]
|
|
242
|
+
|
|
243
|
+
"/#{@opts[:app_name] || detect_app_name}/#{@opts[:env]}/connect"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Same detection the main generator uses for the module's app_name
|
|
247
|
+
# default, so the prefix lines up with what Terraform created.
|
|
248
|
+
def detect_app_name
|
|
249
|
+
['config/routes.tf.rb', 'infrastructure/routes.tf.rb', 'config/routes.rb'].each do |path|
|
|
250
|
+
next unless File.exist?(path)
|
|
251
|
+
|
|
252
|
+
match = File.read(path).match(/namespace :(\w+)/)
|
|
253
|
+
return match[1] if match
|
|
254
|
+
end
|
|
255
|
+
File.basename(Dir.pwd)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def push_ssm(result)
|
|
259
|
+
prefix = ssm_prefix
|
|
260
|
+
puts ''
|
|
261
|
+
puts "Writing the matched set to SSM under #{prefix}/ (type String, --overwrite):"
|
|
262
|
+
|
|
263
|
+
failed = SSM_PARAMS.reject do |field, param|
|
|
264
|
+
name = "#{prefix}/#{param}"
|
|
265
|
+
ok = @runner.call('aws', 'ssm', 'put-parameter', '--overwrite', '--type', 'String',
|
|
266
|
+
'--name', name, '--value', result.public_send(field).to_s,
|
|
267
|
+
out: File::NULL)
|
|
268
|
+
puts " #{ok ? 'put' : 'FAIL'} #{name}"
|
|
269
|
+
ok
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if failed.empty?
|
|
273
|
+
env = @opts[:env] || '<env>'
|
|
274
|
+
puts ' ✓ SSM matched set written.'
|
|
275
|
+
puts ''
|
|
276
|
+
puts 'The module reads these back through SSM data sources, which only refresh'
|
|
277
|
+
puts 'on apply — so the Terraform outputs (and therefore the frontend build)'
|
|
278
|
+
puts 'still see the old value until you refresh state:'
|
|
279
|
+
puts ''
|
|
280
|
+
puts " terraform -chdir=infrastructure/#{env} apply -refresh-only -auto-approve"
|
|
281
|
+
puts " belt deploy frontend #{env}"
|
|
282
|
+
else
|
|
283
|
+
warn ''
|
|
284
|
+
warn 'Some SSM writes failed (is AWS_PROFILE set and `aws` on PATH?). Run by hand:'
|
|
285
|
+
failed.each do |field, param|
|
|
286
|
+
warn " aws ssm put-parameter --overwrite --type String --name #{prefix}/#{param} --value '#{result.public_send(field)}'"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# --- output -----------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def print_result(name, result)
|
|
294
|
+
puts ''
|
|
295
|
+
puts "✓ Widget \"#{name}\" written to the registry."
|
|
296
|
+
puts ''
|
|
297
|
+
if @opts[:env] || @opts[:ssm_prefix]
|
|
298
|
+
puts "The snippetId is in SSM; frontend/env.yml maps #{env_var(name, 'SNIPPET_ID')} to the"
|
|
299
|
+
puts 'Terraform output so `belt deploy frontend <env>` injects it at build time.'
|
|
300
|
+
puts 'For local dev, `belt frontend env <env>` merges it into frontend/.env.'
|
|
301
|
+
else
|
|
302
|
+
puts 'Add this to your frontend .env (the snippetId is a secret — keep it out of git):'
|
|
303
|
+
puts ''
|
|
304
|
+
puts " #{env_var(name, 'SNIPPET_ID')}=#{result.snippet_id}"
|
|
305
|
+
puts ''
|
|
306
|
+
puts 'Or pass --env <env> to write it (and the widget url + element id) to SSM'
|
|
307
|
+
puts 'as a matched set so the build injects it from the Terraform output.'
|
|
308
|
+
end
|
|
309
|
+
puts ''
|
|
310
|
+
puts 'Then mount it:'
|
|
311
|
+
puts ''
|
|
312
|
+
puts " import ConnectWidget, { openConnectChat } from './connect/ConnectWidget'"
|
|
313
|
+
puts " <ConnectWidget name=\"#{name}\" />"
|
|
314
|
+
puts " <button onClick={() => openConnectChat('#{name}')}>Chat with us</button>"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require 'belt/connect'
|
|
6
|
+
require_relative 'connect/add_widget'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
module Generators
|
|
10
|
+
# Generator discovered automatically by Belt::CLI::GeneratorRegistry.
|
|
11
|
+
# File MUST live at: lib/belt/generators/connect_generator.rb
|
|
12
|
+
# Class MUST be: Belt::Generators::ConnectGenerator
|
|
13
|
+
#
|
|
14
|
+
# Available as:
|
|
15
|
+
# belt generate connect
|
|
16
|
+
# belt destroy connect
|
|
17
|
+
#
|
|
18
|
+
# Scaffolds Amazon Connect (Connect Customer) into the host Belt app:
|
|
19
|
+
# - aws_connect_instance (SAML identity management, chat enabled)
|
|
20
|
+
# - Approved origins (null_resource + local-exec; no native TF resource)
|
|
21
|
+
# - 24/7 hours of operation
|
|
22
|
+
# - Chat contact flow with off-hours queuing, a queue, and a CHAT routing profile
|
|
23
|
+
# - SSM params for the hosted chat widget snippet/URL (ignore_changes)
|
|
24
|
+
# - SAML federation: IAM SAML provider + role for Identity Center SSO
|
|
25
|
+
# - A frontend widget loader (React) wired to the SSM/env values
|
|
26
|
+
#
|
|
27
|
+
# Extracted and generalized from Stowzilla's standalone connect-infrastructure:
|
|
28
|
+
# brand color, approved origins, and the SSM prefix are now app config, not
|
|
29
|
+
# hard-coded to Stowzilla.
|
|
30
|
+
class ConnectGenerator
|
|
31
|
+
TEMPLATE_DIR = File.expand_path('../connect/templates', __dir__)
|
|
32
|
+
MODULE_DIR = 'infrastructure/modules/connect'
|
|
33
|
+
|
|
34
|
+
# Terraform templates emitted into the host app's module directory.
|
|
35
|
+
TERRAFORM_FILES = {
|
|
36
|
+
'terraform/main.tf.erb' => 'main.tf',
|
|
37
|
+
'terraform/variables.tf.erb' => 'variables.tf',
|
|
38
|
+
'terraform/outputs.tf.erb' => 'outputs.tf'
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# Frontend assets emitted into the host app. The widget registry
|
|
42
|
+
# (widgets.js) is the source of truth for every widget; ConnectWidget.jsx
|
|
43
|
+
# is the single generic loader driven by a widget name. Both live under
|
|
44
|
+
# frontend/src/connect/ so the app has one obvious place for widget wiring.
|
|
45
|
+
FRONTEND_FILES = {
|
|
46
|
+
'frontend/widgets.js.erb' => 'widgets.js',
|
|
47
|
+
'frontend/ConnectWidget.jsx.erb' => 'ConnectWidget.jsx'
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Where raw console snippets live in the host app (see AddWidget). The
|
|
51
|
+
# README is committed; the *.js snippets hold the secret snippetId and are
|
|
52
|
+
# gitignored by the lines below.
|
|
53
|
+
WIDGETS_DIR = AddWidget::WIDGETS_DIR
|
|
54
|
+
WIDGETS_README = 'config/widgets-README.md.erb'
|
|
55
|
+
GITIGNORE_LINES = [
|
|
56
|
+
'# Amazon Connect widget snippets (belt-connect) — hold the secret snippetId',
|
|
57
|
+
"/#{WIDGETS_DIR}/*.js"
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
def self.description
|
|
61
|
+
'Install Amazon Connect: chat widget + call-center contact flow, queue, routing, and SAML SSO'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.run(args)
|
|
65
|
+
if args.include?('--help') || args.include?('-h')
|
|
66
|
+
print_help
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Subcommand: `belt generate connect add-widget <name> <snippet-file>`
|
|
71
|
+
# parses a pasted Connect console snippet into the widget registry so the
|
|
72
|
+
# base64 snippetId is never retyped by hand.
|
|
73
|
+
return AddWidget.new(args[1..] || []).run if args.first == 'add-widget'
|
|
74
|
+
|
|
75
|
+
new(args).generate
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.destroy(args)
|
|
79
|
+
new(args).destroy
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.print_help
|
|
83
|
+
puts <<~HELP
|
|
84
|
+
Install belt-connect into a Belt application.
|
|
85
|
+
|
|
86
|
+
Usage: belt generate connect [options]
|
|
87
|
+
belt generate connect add-widget <name> [snippet-file] [--env <env>]
|
|
88
|
+
|
|
89
|
+
Options:
|
|
90
|
+
--skip-frontend Only emit the Terraform module (no React widget)
|
|
91
|
+
--force Overwrite existing files
|
|
92
|
+
-h, --help Show this help
|
|
93
|
+
|
|
94
|
+
What gets created:
|
|
95
|
+
infrastructure/modules/connect/ Terraform module
|
|
96
|
+
- main.tf Connect instance, approved origins, hours of
|
|
97
|
+
operation, chat contact flow (off-hours queuing),
|
|
98
|
+
queue, CHAT routing profile, SSM widget params,
|
|
99
|
+
SAML federation (IAM provider + role)
|
|
100
|
+
- variables.tf Inputs (app_name, environment, instance_alias,
|
|
101
|
+
approved_origins, brand/SSM knobs, saml_metadata_file)
|
|
102
|
+
- outputs.tf instance_id/arn, admin_console_url, contact_flow_id,
|
|
103
|
+
queue_id, routing_profile_id, saml_* arns
|
|
104
|
+
|
|
105
|
+
frontend/src/connect/widgets.js Widget registry — the single
|
|
106
|
+
source of truth for every hosted widget (id, url,
|
|
107
|
+
styles). snippetId is read from env (it's a secret).
|
|
108
|
+
frontend/src/connect/ConnectWidget.jsx Generic hosted-chat loader
|
|
109
|
+
driven by a widget name; exports openConnectChat(name)
|
|
110
|
+
config/connect/widgets/README.md Drop zone for raw console
|
|
111
|
+
snippets (<name>.js, gitignored — they hold the
|
|
112
|
+
secret snippetId); .gitignore gets the entry
|
|
113
|
+
|
|
114
|
+
Adding a widget:
|
|
115
|
+
Create a hosted chat widget in the Connect admin console (answer "No"
|
|
116
|
+
to JWT security unless you implement the authenticate callback), save
|
|
117
|
+
its "Get widget code" snippet to config/connect/widgets/<name>.js, then:
|
|
118
|
+
|
|
119
|
+
belt generate connect add-widget support --env sandbox
|
|
120
|
+
|
|
121
|
+
This parses the snippet into widgets.js (so the base64 snippetId is
|
|
122
|
+
never retyped) and, with --env, writes the matched set (snippet id,
|
|
123
|
+
widget url, element id) to the module's SSM params so the build
|
|
124
|
+
injects it via frontend/env.yml. Without --env it prints the
|
|
125
|
+
VITE_CONNECT_<NAME>_SNIPPET_ID line for a local .env instead.
|
|
126
|
+
|
|
127
|
+
After generation:
|
|
128
|
+
1. Add the module to your environment main.tf:
|
|
129
|
+
module "connect" {
|
|
130
|
+
source = "../modules/connect"
|
|
131
|
+
app_name = var.app_name
|
|
132
|
+
environment = var.environment
|
|
133
|
+
aws_region = var.aws_region
|
|
134
|
+
instance_alias = "${var.app_name}-${var.environment}"
|
|
135
|
+
approved_origins = ["https://${var.environment}.example.com"]
|
|
136
|
+
# Demo/sandbox: password logins instead of SAML, so someone can
|
|
137
|
+
# actually sign in to the CCP and answer the chat.
|
|
138
|
+
# identity_management_type = "CONNECT_MANAGED"
|
|
139
|
+
# users = { "agent" = { security_profiles = ["Agent"] } }
|
|
140
|
+
}
|
|
141
|
+
2. Deploy: belt deploy <env>
|
|
142
|
+
3. Create a hosted chat widget, then run `belt generate connect add-widget`.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
belt g connect
|
|
146
|
+
belt g connect --skip-frontend
|
|
147
|
+
belt g connect add-widget support --env sandbox
|
|
148
|
+
belt g connect add-widget support ~/Downloads/code.js --env sandbox
|
|
149
|
+
belt d connect
|
|
150
|
+
HELP
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def initialize(args)
|
|
154
|
+
@force = args.include?('--force')
|
|
155
|
+
@skip_frontend = args.include?('--skip-frontend')
|
|
156
|
+
# @app_name feeds the ERB-templated headers / defaults through the
|
|
157
|
+
# binding passed to #write_template. Detected the same way as
|
|
158
|
+
# belt-cost-monitoring / belt-messaging / belt-pay.
|
|
159
|
+
@app_name = detect_namespace
|
|
160
|
+
@brand_color = Belt::Connect.configuration.brand_color
|
|
161
|
+
@typeface = Belt::Connect.configuration.typeface
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def generate
|
|
165
|
+
puts "Installing Amazon Connect into #{MODULE_DIR}/"
|
|
166
|
+
puts ''
|
|
167
|
+
|
|
168
|
+
generate_terraform_module
|
|
169
|
+
generate_frontend unless @skip_frontend
|
|
170
|
+
print_success
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def destroy
|
|
174
|
+
if Dir.exist?(MODULE_DIR)
|
|
175
|
+
FileUtils.rm_rf(MODULE_DIR)
|
|
176
|
+
puts " remove #{MODULE_DIR}/"
|
|
177
|
+
else
|
|
178
|
+
puts " skip #{MODULE_DIR} (not found)"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
%w[widgets.js ConnectWidget.jsx].each do |name|
|
|
182
|
+
path = File.join(frontend_dir, name)
|
|
183
|
+
next unless File.exist?(path)
|
|
184
|
+
|
|
185
|
+
File.delete(path)
|
|
186
|
+
puts " remove #{path}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
readme = File.join(WIDGETS_DIR, 'README.md')
|
|
190
|
+
if File.exist?(readme)
|
|
191
|
+
File.delete(readme)
|
|
192
|
+
puts " remove #{readme} (your <name>.js snippets are left in place)"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
puts ''
|
|
196
|
+
puts '✓ Amazon Connect removed!'
|
|
197
|
+
puts " Don't forget to remove the module reference from environment main.tf files,"
|
|
198
|
+
puts ' and tear down the Connect instance with `belt deploy <env>` (or terraform destroy).'
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# Detect the host app's namespace from its routes file, falling back to the
|
|
204
|
+
# current directory name. Matches the belt-cost-monitoring convention.
|
|
205
|
+
def detect_namespace
|
|
206
|
+
routes_path = find_routes_file_path
|
|
207
|
+
if routes_path && (match = File.read(routes_path).match(/namespace :(\w+)/))
|
|
208
|
+
return match[1]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
File.basename(Dir.pwd)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def find_routes_file_path
|
|
215
|
+
['config/routes.tf.rb', 'infrastructure/routes.tf.rb', 'config/routes.rb'].find do |path|
|
|
216
|
+
File.exist?(path)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def generate_terraform_module
|
|
221
|
+
if Dir.exist?(MODULE_DIR) && !@force
|
|
222
|
+
puts " skip #{MODULE_DIR}/ (already exists, use --force to overwrite)"
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
FileUtils.mkdir_p(MODULE_DIR)
|
|
227
|
+
TERRAFORM_FILES.each do |template, filename|
|
|
228
|
+
write_template(template, File.join(MODULE_DIR, filename))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def generate_frontend
|
|
233
|
+
FRONTEND_FILES.each do |template, filename|
|
|
234
|
+
write_template(template, File.join(frontend_dir, filename))
|
|
235
|
+
end
|
|
236
|
+
generate_widgets_dir
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The bay for raw console snippets: a committed README explaining the
|
|
240
|
+
# convention, plus gitignore entries so the secret-bearing *.js never lands
|
|
241
|
+
# in git. Idempotent — re-running never duplicates the ignore lines.
|
|
242
|
+
def generate_widgets_dir
|
|
243
|
+
write_template(WIDGETS_README, File.join(WIDGETS_DIR, 'README.md'))
|
|
244
|
+
|
|
245
|
+
existing = File.exist?('.gitignore') ? File.read('.gitignore') : ''
|
|
246
|
+
return if existing.include?(GITIGNORE_LINES.last)
|
|
247
|
+
|
|
248
|
+
block = "#{GITIGNORE_LINES.join("\n")}\n"
|
|
249
|
+
File.write('.gitignore', existing.empty? ? block : "#{existing.chomp}\n\n#{block}")
|
|
250
|
+
puts " update .gitignore (ignore #{WIDGETS_DIR}/*.js)"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Belt apps keep the React app under frontend/src; the older Stowzilla
|
|
254
|
+
# layout used app/src. The widget registry + generic loader live in a
|
|
255
|
+
# dedicated connect/ directory so the app has one obvious bay for widget
|
|
256
|
+
# wiring. Resolve at generation time against the app's CWD.
|
|
257
|
+
def frontend_dir
|
|
258
|
+
return 'app/src/connect' if Dir.exist?('app/src') && !Dir.exist?('frontend/src')
|
|
259
|
+
|
|
260
|
+
'frontend/src/connect'
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def print_success
|
|
264
|
+
puts ''
|
|
265
|
+
puts '✓ Amazon Connect installed!'
|
|
266
|
+
puts ''
|
|
267
|
+
puts 'Next steps:'
|
|
268
|
+
puts ' 1. Add the module to your environment main.tf:'
|
|
269
|
+
puts ''
|
|
270
|
+
puts ' module "connect" {'
|
|
271
|
+
puts ' source = "../modules/connect"'
|
|
272
|
+
puts ' app_name = var.app_name'
|
|
273
|
+
puts ' environment = var.environment'
|
|
274
|
+
puts ' aws_region = var.aws_region'
|
|
275
|
+
puts ' instance_alias = "${var.app_name}-${var.environment}"'
|
|
276
|
+
puts ' approved_origins = ["https://${var.environment}.example.com"]'
|
|
277
|
+
puts ' # saml_metadata_file = "${path.module}/saml-metadata.xml" # optional SSO'
|
|
278
|
+
puts ' # identity_management_type = "CONNECT_MANAGED" # demo/sandbox: password logins'
|
|
279
|
+
puts ' # users = { "agent" = { security_profiles = ["Agent"] } }'
|
|
280
|
+
puts ' }'
|
|
281
|
+
puts ''
|
|
282
|
+
puts ' 2. Deploy: belt deploy <env>'
|
|
283
|
+
puts ' 3. Create a hosted chat widget in the Connect admin console (skip JWT'
|
|
284
|
+
puts ' security), save its "Get widget code" snippet to'
|
|
285
|
+
puts " #{WIDGETS_DIR}/support.js (gitignored), then run:"
|
|
286
|
+
puts ''
|
|
287
|
+
puts ' belt generate connect add-widget support --env <env>'
|
|
288
|
+
puts ''
|
|
289
|
+
puts ' That parses the snippet into frontend/src/connect/widgets.js and writes'
|
|
290
|
+
puts ' the matched set (snippet id, widget url, element id) to SSM so the'
|
|
291
|
+
puts ' build injects it (the snippetId never lands in git).'
|
|
292
|
+
puts ''
|
|
293
|
+
puts ' 4. Mount the widget anywhere:'
|
|
294
|
+
puts ''
|
|
295
|
+
puts " import ConnectWidget, { openConnectChat } from './connect/ConnectWidget'"
|
|
296
|
+
puts ' <ConnectWidget name="support" />'
|
|
297
|
+
puts " <button onClick={() => openConnectChat('support')}>Chat with us</button>"
|
|
298
|
+
puts ''
|
|
299
|
+
puts ' The chat widget is hosted by AWS and the browser WebSocket does the talking,'
|
|
300
|
+
puts ' so there is no runtime Lambda to deploy.'
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def write_template(relative_template, destination, binding_context = binding)
|
|
304
|
+
source = File.join(TEMPLATE_DIR, relative_template)
|
|
305
|
+
unless File.exist?(source)
|
|
306
|
+
puts " skip missing template #{relative_template}"
|
|
307
|
+
return
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
if File.exist?(destination) && !@force
|
|
311
|
+
puts " skip #{destination} (already exists, use --force to overwrite)"
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
316
|
+
content = ERB.new(File.read(source), trim_mode: '-').result(binding_context)
|
|
317
|
+
File.write(destination, content)
|
|
318
|
+
puts " create #{destination}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
data/lib/belt-connect.rb
ADDED