number_station 0.2.0 → 0.3.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.
@@ -21,193 +21,1496 @@
21
21
 
22
22
  require 'thor'
23
23
  require 'fileutils'
24
+ require 'date'
24
25
 
25
26
  module NumberStation
26
- class CLI < Thor
27
+ class Pad < Thor
28
+ # Remove the built-in 'tree' command
29
+ def self.all_commands
30
+ super.reject { |k, v| k == 'tree' }
31
+ end
32
+
33
+ desc "create [--name NAME --path PATH --numpads NUM --length LENGTH]", "Generate a one time pad of LENGTH containing NUM entries"
34
+ long_desc <<-CREATE_PAD_LONG_DESC
35
+ Generate a one time pad of LENGTH containing NUM entries
36
+
37
+ Parameters:
38
+ --name NAME - Agent name to associate with this pad. Creates subdirectory ~/number_station/pads/NAME/ if --path is not provided
39
+ --path PATH - Directory where the pad file will be created (defaults to current directory, or ~/number_station/pads/NAME/ if --name is provided)
40
+ --numpads NUM - Number of pads to generate (defaults to 500)
41
+ --length LENGTH - Length of each pad in characters (defaults to 500)
42
+
43
+ If no parameters are passed it will generate 500 one time pads in the current
44
+ directory of size 500 characters.
45
+
46
+ If --name is provided without --path, the pad will be created in ~/number_station/pads/NAME/
47
+
48
+ Examples:
49
+ number_station pad create --name Shadow --numpads 1000 --length 1000
50
+ number_station pad create --name Shadow
51
+ number_station pad create --path ~/number_station/pads --numpads 10 --length 500
52
+ number_station pad create
53
+ CREATE_PAD_LONG_DESC
54
+ option :name, type: :string
55
+ option :length, type: :numeric
56
+ option :numpads, type: :numeric
57
+ option :path, type: :string
58
+ def create
59
+ ensure_config_loaded
60
+
61
+ # Determine the pad directory and agent name
62
+ pad_path = options[:path]
63
+ agent_name = options[:name]
64
+
65
+ if pad_path.nil? && agent_name
66
+ # If name is provided but no path, create agent-specific directory
67
+ pad_path = File.join(Dir.home, "number_station", "pads", agent_name)
68
+ FileUtils.mkdir_p(pad_path)
69
+ NumberStation.log.info "Created pad directory for agent: #{pad_path}"
70
+ end
71
+
72
+ # Pass options (make_otp will use defaults: 500 pads, 500 characters if nil)
73
+ # Also pass agent_name so it can be used in the filename
74
+ NumberStation.make_otp(pad_path, options[:length], options[:numpads], agent_name)
75
+ end
76
+
77
+ desc "stats [--path PATH]", "Show statistics about one-time pads"
78
+ long_desc <<-PAD_STATS_LONG_DESC
79
+ Lists all one-time pad files in the pads directory (default: ~/number_station/pads),
80
+ including pads in subdirectories. Pads are grouped by agent name (subdirectory name).
81
+
82
+ For each pad file, shows:
83
+ - Pad filename
84
+ - Maximum message length (in characters) that can be encrypted
85
+ - Number of unconsumed pads remaining
86
+
87
+ Optional parameters:
88
+ --path PATH Specify a custom directory path to examine pads from
89
+
90
+ Examples:
91
+ number_station pad stats
92
+ number_station pad stats --path ~/custom/pads
93
+ PAD_STATS_LONG_DESC
94
+ option :path, type: :string
95
+ def stats
96
+ ensure_config_loaded
97
+
98
+ pads_dir = options[:path] || File.join(Dir.home, "number_station", "pads")
99
+
100
+ unless Dir.exist?(pads_dir)
101
+ puts "Pads directory does not exist: #{pads_dir}"
102
+ return
103
+ end
104
+
105
+ # Find all pad files recursively in various formats:
106
+ # - Old format: one_time_pad_XXXXX.json (random number)
107
+ # - New format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json (date-based)
108
+ # - New format with counter: agentname-YYYY-MM-DD-001.json
109
+ pad_files = Dir.glob(File.join(pads_dir, "**", "*.json")).select do |file|
110
+ basename = File.basename(file)
111
+ # Match old format: one_time_pad_XXXXX.json
112
+ # Match new format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json
113
+ # Match new format with counter: agentname-YYYY-MM-DD-001.json
114
+ basename.match?(/^(one_time_pad|[\w-]+)[_-]\d{4}-\d{2}-\d{2}(-\d{3})?\.json$/) ||
115
+ basename.match?(/^one_time_pad_\d+\.json$/) ||
116
+ basename.match?(/^[\w-]+_\d+\.json$/)
117
+ end
118
+
119
+ if pad_files.empty?
120
+ puts "No pad files found in #{pads_dir}"
121
+ return
122
+ end
123
+
124
+ # Group pads by their parent directory (agent name)
125
+ pads_by_agent = {}
126
+
127
+ pad_files.each do |file_path|
128
+ # Get relative path from pads_dir
129
+ relative_path = file_path.sub(/^#{Regexp.escape(pads_dir)}\/?/, '')
130
+ dir_parts = File.dirname(relative_path).split(File::SEPARATOR)
131
+
132
+ # Determine agent name: if in subdirectory, use subdirectory name; otherwise use "root"
133
+ agent_name = dir_parts.empty? || dir_parts == ['.'] ? "root" : dir_parts.first
134
+
135
+ pads_by_agent[agent_name] ||= []
136
+ pad_info = NumberStation.examine_pad_file(file_path)
137
+ pad_info[:file_path] = file_path
138
+ pad_info[:agent_name] = agent_name
139
+ pads_by_agent[agent_name] << pad_info
140
+ end
141
+
142
+ puts "\nOne-Time Pad Statistics"
143
+ puts "=" * 80
144
+ puts "Directory: #{pads_dir}"
145
+ puts "=" * 80
146
+ puts
147
+
148
+ # Sort agents: root first, then alphabetically
149
+ sorted_agents = pads_by_agent.keys.sort_by { |k| k == "root" ? "" : k }
150
+
151
+ total_pad_files = 0
152
+ total_unconsumed = 0
153
+ total_pads = 0
154
+
155
+ sorted_agents.each do |agent_name|
156
+ agent_pads = pads_by_agent[agent_name]
157
+ agent_unconsumed = agent_pads.inject(0) { |sum, info| sum + (info[:unconsumed_pads] || 0) }
158
+ agent_total_pads = agent_pads.inject(0) { |sum, info| sum + (info[:total_pads] || 0) }
159
+
160
+ # Display agent header
161
+ if agent_name == "root"
162
+ puts "Root Directory:"
163
+ else
164
+ puts "Agent: #{agent_name}"
165
+ end
166
+ puts "-" * 80
167
+
168
+ # Display each pad for this agent
169
+ agent_pads.each do |info|
170
+ if info[:error]
171
+ puts " ✗ #{info[:filename]}: ERROR - #{info[:error]}"
172
+ else
173
+ puts " Pad: #{info[:filename]}"
174
+ puts " ID: #{info[:pad_id]}"
175
+ puts " Maximum message length: #{info[:max_message_length]} characters"
176
+ puts " Total pads: #{info[:total_pads]}"
177
+ puts " Unconsumed: #{info[:unconsumed_pads]}"
178
+ puts " Consumed: #{info[:consumed_pads]}"
179
+ end
180
+ end
181
+
182
+ puts " Summary: #{agent_pads.size} pad file(s), #{agent_unconsumed} unconsumed pad(s) out of #{agent_total_pads} total"
183
+ puts
184
+
185
+ total_pad_files += agent_pads.size
186
+ total_unconsumed += agent_unconsumed
187
+ total_pads += agent_total_pads
188
+ end
189
+
190
+ puts "=" * 80
191
+ puts "Overall Summary: #{total_pad_files} pad file(s), #{total_unconsumed} unconsumed pad(s) out of #{total_pads} total"
192
+ puts "=" * 80
193
+ end
194
+
195
+ private
196
+
197
+ def ensure_config_loaded
198
+ NumberStation::ConfigReader.read_config unless NumberStation.data
199
+ end
200
+ end
201
+
202
+ class Agents < Thor
203
+ # Remove the built-in 'tree' command
204
+ def self.all_commands
205
+ super.reject { |k, v| k == 'tree' }
206
+ end
207
+
208
+ # Map hyphenated command names
209
+ map "update-handler" => :update_handler
210
+ map "list-all" => :list_all
211
+
212
+
213
+ desc "list", "List all active agents in a condensed format"
214
+ long_desc <<-LIST_AGENTS_LONG_DESC
215
+ Shows a condensed list of all active agents with their key information:
216
+ - Name
217
+ - Location
218
+ - Handler Codeword
219
+ - Start Date
220
+ LIST_AGENTS_LONG_DESC
221
+ def list
222
+ ensure_config_loaded
223
+
224
+ active_agents = NumberStation.active_agents
225
+
226
+ if active_agents.empty?
227
+ puts "No active agents found."
228
+ return
229
+ end
230
+
231
+ puts "\nActive Agents (#{active_agents.size})"
232
+ puts "=" * 100
233
+
234
+ # Print header
235
+ printf "%-20s %-25s %-25s %-15s\n", "Name", "Location", "Handler Codeword", "Start Date"
236
+ puts "-" * 100
237
+
238
+ # Print each agent
239
+ active_agents.each do |agent|
240
+ name = agent['name'] || 'N/A'
241
+ location = agent['location'] || '-'
242
+ handler = agent['handler_codeword'] || '-'
243
+ start_date = agent['start_date'] || '-'
244
+
245
+ printf "%-20s %-25s %-25s %-15s\n", name, location, handler, start_date
246
+ end
247
+
248
+ puts "=" * 100
249
+ end
250
+
251
+ desc "list-all", "List all active agents and inactive agents with end dates"
252
+ long_desc <<-LIST_ALL_AGENTS_LONG_DESC
253
+ Shows all active agents and all inactive agents that have an end_date in a condensed format with:
254
+ - Name
255
+ - Status (Active/Inactive)
256
+ - Location
257
+ - Handler Codeword
258
+ - Start Date
259
+ - End Date
260
+
261
+ Includes:
262
+ - All currently active agents (regardless of end_date)
263
+ - All inactive agents that have an end_date (were active and then deactivated)
264
+ LIST_ALL_AGENTS_LONG_DESC
265
+ def list_all
266
+ ensure_config_loaded
267
+
268
+ all_agents = NumberStation.agent_list
269
+
270
+ # Filter to: active agents OR inactive agents with end_date
271
+ filtered_agents = all_agents.select do |a|
272
+ a['active'] == true || (a['end_date'] && !a['end_date'].to_s.empty?)
273
+ end
274
+
275
+ if filtered_agents.empty?
276
+ puts "No active agents or agents with end dates found."
277
+ return
278
+ end
279
+
280
+ active_count = filtered_agents.count { |a| a['active'] }
281
+ inactive_count = filtered_agents.size - active_count
282
+
283
+ puts "\nAll Active Agents and Agents with End Dates (#{filtered_agents.size} total: #{active_count} active, #{inactive_count} inactive)"
284
+ puts "=" * 120
285
+
286
+ # Print header
287
+ printf "%-20s %-10s %-20s %-25s %-15s %-15s\n",
288
+ "Name", "Status", "Location", "Handler Codeword", "Start Date", "End Date"
289
+ puts "-" * 120
290
+
291
+ # Print each agent
292
+ filtered_agents.each do |agent|
293
+ name = agent['name'] || 'N/A'
294
+ status = agent['active'] ? 'Active' : 'Inactive'
295
+ location = agent['location'] || '-'
296
+ handler = agent['handler_codeword'] || '-'
297
+ start_date = agent['start_date'] || '-'
298
+ end_date = agent['end_date'] || '-'
299
+
300
+ printf "%-20s %-10s %-20s %-25s %-15s %-15s\n",
301
+ name, status, location, handler, start_date, end_date
302
+ end
303
+
304
+ puts "=" * 120
305
+ end
306
+
307
+ desc "stats", "Display statistics about active agents"
308
+ long_desc <<-AGENT_STATS_LONG_DESC
309
+ Shows statistics and details about all active agents, including:
310
+ - Total number of active agents
311
+ - Agent names and details (location, handler codeword, dates, etc.)
312
+ - Summary statistics
313
+ AGENT_STATS_LONG_DESC
314
+ def stats
315
+ ensure_config_loaded
316
+
317
+ all_agents = NumberStation.agent_list
318
+ active_agents = NumberStation.active_agents
319
+ inactive_count = all_agents.size - active_agents.size
320
+
321
+ puts "\nAgent Statistics"
322
+ puts "=" * 80
323
+ puts
324
+
325
+ # Summary statistics
326
+ puts "Summary:"
327
+ puts " Total agents: #{all_agents.size}"
328
+ puts " Active agents: #{active_agents.size}"
329
+ puts " Inactive agents: #{inactive_count}"
330
+ puts
331
+
332
+ if active_agents.empty?
333
+ puts "No active agents found."
334
+ puts "=" * 80
335
+ return
336
+ end
337
+
338
+ puts "Active Agents:"
339
+ puts "-" * 80
340
+ puts
341
+
342
+ active_agents.each_with_index do |agent, index|
343
+ puts "#{index + 1}. #{agent['name']}"
344
+ puts " Location: #{agent['location'] || 'Not specified'}"
345
+ puts " Handler Codeword: #{agent['handler_codeword'] || 'Not specified'}"
346
+ puts " Start Date: #{agent['start_date'] || 'Not specified'}"
347
+ puts " End Date: #{agent['end_date'] || 'Not specified'}"
348
+ puts " Status: Active"
349
+ puts
350
+ end
351
+
352
+ # Additional statistics
353
+ agents_with_location = active_agents.count { |a| a['location'] && !a['location'].to_s.empty? }
354
+ agents_with_codeword = active_agents.count { |a| a['handler_codeword'] && !a['handler_codeword'].to_s.empty? }
355
+ agents_with_start_date = active_agents.count { |a| a['start_date'] && !a['start_date'].to_s.empty? }
356
+ agents_with_end_date = active_agents.count { |a| a['end_date'] && !a['end_date'].to_s.empty? }
357
+
358
+ puts "-" * 80
359
+ puts "Additional Statistics:"
360
+ puts " Agents with location: #{agents_with_location}/#{active_agents.size}"
361
+ puts " Agents with handler codeword: #{agents_with_codeword}/#{active_agents.size}"
362
+ puts " Agents with start date: #{agents_with_start_date}/#{active_agents.size}"
363
+ puts " Agents with end date: #{agents_with_end_date}/#{active_agents.size}"
364
+ puts "=" * 80
365
+ end
366
+
367
+ desc "create NAME", "Create a new agent"
368
+ long_desc <<-CREATE_AGENT_LONG_DESC
369
+ Create a new agent with the specified name. Optionally set location and handler codeword.
370
+
371
+ Parameters:
372
+ NAME - The agent name (required)
373
+
374
+ Options:
375
+ --location LOCATION - Set the agent's location
376
+ --handler HANDLER - Set the handler codeword
377
+
378
+ Examples:
379
+ number_station agents create Shadow --location "Berlin" --handler "NIGHTFALL"
380
+ number_station agents create Ghost --location "Moscow"
381
+ number_station agents create Phantom
382
+ CREATE_AGENT_LONG_DESC
383
+ option :location, type: :string
384
+ option :handler, type: :string
385
+ def create(name = nil)
386
+ if name.nil? || name.empty?
387
+ help("create")
388
+ return
389
+ end
390
+
391
+ ensure_config_loaded
392
+
393
+ # Check if agent already exists
394
+ if NumberStation.find_agent_by_name(name)
395
+ raise Thor::Error, "Agent '#{name}' already exists"
396
+ end
397
+
398
+ # Create new agent
399
+ new_agent = {
400
+ "name" => name,
401
+ "location" => options[:location],
402
+ "handler_codeword" => options[:handler],
403
+ "start_date" => nil,
404
+ "end_date" => nil,
405
+ "active" => false
406
+ }
407
+
408
+ # Add to agent list
409
+ config_data = NumberStation.data.dup
410
+ config_data["agent_list"] ||= []
411
+ config_data["agent_list"] << new_agent
412
+
413
+ # Save config
414
+ NumberStation::ConfigReader.save_config(config_data)
415
+ puts "Created agent: #{name}"
416
+ puts " Location: #{options[:location] || 'Not specified'}"
417
+ puts " Handler Codeword: #{options[:handler] || 'Not specified'}"
418
+ end
419
+
420
+ desc "activate NAME", "Activate an agent"
421
+ long_desc <<-ACTIVATE_AGENT_LONG_DESC
422
+ Activate an agent by name. Sets active status to true and optionally sets start_date.
423
+
424
+ Parameters:
425
+ NAME - The agent name (required)
426
+
427
+ Options:
428
+ --start-date DATE - Set the start date (defaults to today if not specified)
429
+
430
+ Examples:
431
+ number_station agents activate Shadow
432
+ number_station agents activate Shadow --start-date "2024-01-15"
433
+ ACTIVATE_AGENT_LONG_DESC
434
+ option :start_date, type: :string
435
+ def activate(name = nil)
436
+ if name.nil? || name.empty?
437
+ help("activate")
438
+ return
439
+ end
440
+
441
+ ensure_config_loaded
442
+
443
+ agent = NumberStation.find_agent_by_name(name)
444
+ unless agent
445
+ raise Thor::Error, "Agent '#{name}' not found"
446
+ end
447
+
448
+ if agent["active"]
449
+ puts "Agent '#{name}' is already active"
450
+ return
451
+ end
452
+
453
+ # Update agent
454
+ config_data = NumberStation.data.dup
455
+ agent_index = config_data["agent_list"].index { |a| a["name"] == name }
456
+
457
+ config_data["agent_list"][agent_index]["active"] = true
458
+ config_data["agent_list"][agent_index]["start_date"] = options[:start_date] || Date.today.to_s
459
+ config_data["agent_list"][agent_index]["end_date"] = nil # Reset end_date when reactivating
460
+
461
+ # Save config
462
+ NumberStation::ConfigReader.save_config(config_data)
463
+ puts "Activated agent: #{name}"
464
+ puts " Start Date: #{config_data["agent_list"][agent_index]["start_date"]}"
465
+ end
466
+
467
+ desc "deactivate NAME", "Deactivate an agent"
468
+ long_desc <<-DEACTIVATE_AGENT_LONG_DESC
469
+ Deactivate an agent by name. Sets active status to false and optionally sets end_date.
470
+
471
+ Parameters:
472
+ NAME - The agent name (required)
473
+
474
+ Options:
475
+ --end-date DATE - Set the end date (defaults to today if not specified)
476
+
477
+ Examples:
478
+ number_station agents deactivate Shadow
479
+ number_station agents deactivate Shadow --end-date "2024-12-31"
480
+ DEACTIVATE_AGENT_LONG_DESC
481
+ option :end_date, type: :string
482
+ def deactivate(name = nil)
483
+ if name.nil? || name.empty?
484
+ help("deactivate")
485
+ return
486
+ end
487
+
488
+ ensure_config_loaded
489
+
490
+ agent = NumberStation.find_agent_by_name(name)
491
+ unless agent
492
+ raise Thor::Error, "Agent '#{name}' not found"
493
+ end
494
+
495
+ unless agent["active"]
496
+ puts "Agent '#{name}' is already inactive"
497
+ return
498
+ end
499
+
500
+ # Update agent
501
+ config_data = NumberStation.data.dup
502
+ agent_index = config_data["agent_list"].index { |a| a["name"] == name }
503
+
504
+ config_data["agent_list"][agent_index]["active"] = false
505
+ config_data["agent_list"][agent_index]["end_date"] = options[:end_date] || Date.today.to_s
506
+
507
+ # Save config
508
+ NumberStation::ConfigReader.save_config(config_data)
509
+ puts "Deactivated agent: #{name}"
510
+ puts " End Date: #{config_data["agent_list"][agent_index]["end_date"]}"
511
+ end
512
+
513
+ desc "update_handler NAME HANDLER", "Update the handler codeword for an agent"
514
+ long_desc <<-UPDATE_HANDLER_LONG_DESC
515
+ Update the handler codeword for an agent.
516
+
517
+ Parameters:
518
+ NAME - The agent name (required)
519
+ HANDLER - The new handler codeword (required)
520
+
521
+ Examples:
522
+ number_station agents update-handler Shadow DAWNBREAK
523
+ number_station agents update-handler Ghost NEWCODE123
524
+ UPDATE_HANDLER_LONG_DESC
525
+ def update_handler(name = nil, handler = nil)
526
+ if name.nil? || name.empty? || handler.nil? || handler.empty?
527
+ help("update_handler")
528
+ return
529
+ end
530
+
531
+ ensure_config_loaded
532
+
533
+ agent = NumberStation.find_agent_by_name(name)
534
+ unless agent
535
+ raise Thor::Error, "Agent '#{name}' not found"
536
+ end
537
+
538
+ # Update agent
539
+ config_data = NumberStation.data.dup
540
+ agent_index = config_data["agent_list"].index { |a| a["name"] == name }
541
+
542
+ old_handler = config_data["agent_list"][agent_index]["handler_codeword"]
543
+ config_data["agent_list"][agent_index]["handler_codeword"] = handler
544
+
545
+ # Save config
546
+ NumberStation::ConfigReader.save_config(config_data)
547
+ puts "Updated handler codeword for agent: #{name}"
548
+ puts " Old handler: #{old_handler || 'Not specified'}"
549
+ puts " New handler: #{handler}"
550
+ end
551
+
552
+ private
27
553
 
554
+ def ensure_config_loaded
555
+ NumberStation::ConfigReader.read_config unless NumberStation.data
556
+ end
557
+ end
28
558
 
29
- # create_config
30
- desc "create_config [--path PATH]", "copy the sample config to current directory."
559
+ class CLI < Thor
560
+ desc "create_config [--path PATH]", "create the config file conf.yaml"
31
561
  long_desc <<-CREATE_CONFIG_LONG_DESC
32
- `create_config` will copy the sample config from config/config.json to the current directory. If the
33
- optional parameter `--path PATH` is passed then this file is copied to the location specified by the
34
- PATH parameter.
35
- Sample Intro and Outro messages are also copied to this directory. If you wish to automatically append
36
- these messages, change the conf.json boolean.
562
+ `create_config` will copy the sample config from resources/conf.yaml to ~/number_station/conf.yaml.
563
+ If the optional parameter `--path PATH` is passed then the config file is created at PATH/conf.yaml.
37
564
  CREATE_CONFIG_LONG_DESC
38
- option :path, :type => :string
39
- def create_config()
40
- config_file_path = File.join(File.dirname(__FILE__), "../../resources/conf.json")
41
- intro_file_path = File.join(File.dirname(__FILE__), "../../resources/intro_message.txt")
42
- outro_file_path = File.join(File.dirname(__FILE__), "../../resources/outro_message.txt")
43
-
44
- options[:path] ? path = options[:path] : path = File.join(Dir.home, "/number_station")
45
-
46
- unless Dir.exist?(path) then FileUtils.mkdir(path) end
47
- unless File.file?(File.join(path, "conf.json")) then FileUtils.cp(config_file_path, File.join(path, "conf.json")) end
48
- unless File.file?(File.join(path, "intro_message.txt")) then FileUtils.cp(intro_file_path, File.join(path, "intro_message.txt")) end
49
- unless File.file?(File.join(path, "outro_message.txt")) then FileUtils.cp(outro_file_path, File.join(path, "outro_message.txt")) end
50
- NumberStation::ConfigReader.read_config()
565
+ option :path, type: :string
566
+ def create_config
567
+ ensure_config_loaded
568
+
569
+ target_path = options[:path] || File.join(Dir.home, "number_station")
570
+ FileUtils.mkdir_p(target_path)
571
+
572
+ target_file = File.join(target_path, "conf.yaml")
573
+
574
+ # Find the source config file - try multiple locations
575
+ source_file = find_config_template
576
+
577
+ unless File.exist?(target_file)
578
+ if source_file && File.exist?(source_file)
579
+ FileUtils.cp(source_file, target_file)
580
+ NumberStation.log.info "Created config file: #{target_file}"
581
+ else
582
+ # If template not found, create a default config
583
+ create_default_config(target_file)
584
+ NumberStation.log.info "Created default config file: #{target_file}"
585
+ end
586
+ else
587
+ NumberStation.log.info "Config file already exists: #{target_file}"
588
+ end
589
+
590
+ # Copy message template files (intro, outro, repeat) to target directory
591
+ copy_message_template_files(target_path)
592
+
593
+ NumberStation::ConfigReader.read_config
51
594
  NumberStation.log.debug "create_config completed"
52
595
  end
53
596
 
54
597
 
55
- # convert_to_phonetic
56
598
  desc "convert_to_phonetic [MESSAGE_PATH]", "Convert a message to phonetic output."
57
599
  long_desc <<-CONVERT_MESSAGE_LONG_DESC
58
600
  convert_message takes a parameter which should point to a text file containing a message.
59
601
  MESSAGE_PATH\n
60
602
  Optional parameters:\n
61
- --intro [INTRO_PATH] should be a text file containing intro message. Overrides value in conf.json\n
62
- --outro [OUTRO_PATH] should be a text file containing the outro message. Overrides value in conf.json\n
603
+ --intro [INTRO_PATH] should be a text file containing intro message. Overrides value in conf.yaml\n
604
+ --outro [OUTRO_PATH] should be a text file containing the outro message. Overrides value in conf.yaml\n
605
+ --repeat [REPEAT_PATH] should be a text file containing repeat message. Included after first message.\n
63
606
  --mp3 [MP3] output message as an mp3 file.
64
607
 
65
- Final message will be created from intro + message + outro
608
+ Final message will be created from: intro (as-is) + message (phonetic) + repeat (as-is) + message (phonetic again) + outro (as-is)
66
609
  CONVERT_MESSAGE_LONG_DESC
67
- option :intro, :type => :string
68
- option :outro, :type => :string
69
- option :mp3, :type => :string
610
+ option :intro, type: :string
611
+ option :outro, type: :string
612
+ option :repeat, type: :string
613
+ option :mp3, type: :string
70
614
  def convert_to_phonetic(message_path)
71
- NumberStation::ConfigReader.read_config()
72
-
73
- if options[:intro]
74
- intro_path = options[:intro]
75
- intro = NumberStation.to_phonetic(intro_path)
76
- elsif NumberStation.data["resources"]["intro"]["enabled"]
77
- intro_path = NumberStation.data["resources"]["intro"]["template"]
78
- intro = NumberStation.to_phonetic(intro_path)
79
- else
80
- intro_path = ""
81
- end
82
-
83
- if options[:outro]
84
- outro_path = options[:outro]
85
- outro = NumberStation.to_phonetic(outro_path)
86
- elsif NumberStation.data["resources"]["outro"]["enabled"]
87
- outro_path = NumberStation.data["resources"]["outro"]["template"]
88
- outro = NumberStation.to_phonetic(outro_path)
89
- else
90
- outro_path = ""
91
- outro = ""
92
- end
93
- NumberStation.log.debug "intro enabled: #{NumberStation.data["resources"]["intro"]["enabled"]} path: #{intro_path}"
94
- NumberStation.log.debug "message_path: #{message_path}"
95
- NumberStation.log.debug "outro enabled: #{NumberStation.data["resources"]["outro"]["enabled"]} path: #{outro_path}"
615
+ ensure_config_loaded
96
616
 
617
+ # Load intro, outro, and repeat as-is (not converted to phonetic)
618
+ intro = load_message_component(:intro, options[:intro])
619
+ outro = load_message_component(:outro, options[:outro])
620
+ repeat = load_message_component(:repeat, options[:repeat])
621
+
622
+ # Convert message to phonetic
97
623
  message = NumberStation.to_phonetic(message_path)
98
- output = intro + message + outro
624
+
625
+ # Build output: intro (as-is) + message (phonetic) + repeat (as-is) + message (phonetic again) + outro (as-is)
626
+ output_parts = []
627
+ output_parts << intro.strip if intro && !intro.strip.empty?
628
+ output_parts << message.strip if message && !message.strip.empty?
629
+ output_parts << repeat.strip if repeat && !repeat.strip.empty?
630
+ output_parts << message.strip if message && !message.strip.empty? # Repeat the message
631
+ output_parts << outro.strip if outro && !outro.strip.empty?
632
+
633
+ # Join with newlines between components for readability
634
+ # Strip each part to remove trailing newlines that might cause extra blank lines
635
+ output = output_parts.join("\n")
99
636
  NumberStation.log.info "output: #{output}"
100
637
 
638
+ # Generate output filename based on input filename
639
+ input_basename = File.basename(message_path, File.extname(message_path))
640
+ output_filename = "#{input_basename}_phonetic.txt"
641
+ output_path = File.join(File.dirname(message_path), output_filename)
642
+
643
+ # Save output to file
644
+ File.write(output_path, output)
645
+ NumberStation.log.info "Saved phonetic output to: #{output_path}"
646
+
101
647
  if options[:mp3]
102
- mp3_path = options[:mp3]
103
- NumberStation.log.debug "mp3_path: #{mp3_path}" if options[:mp3]
104
- NumberStation.log.debug "Generating mp3 output: #{mp3_path}"
105
- NumberStation.write_mp3(output, mp3_path)
648
+ NumberStation.log.debug "Generating mp3 output: #{options[:mp3]}"
649
+ NumberStation.write_mp3(output, options[:mp3])
106
650
  end
107
- return output
651
+
652
+ output
108
653
  end
109
654
 
655
+ desc "convert_to_espeak FILE", "Convert a phonetic message file to GLaDOS-style espeak XML"
656
+ long_desc <<-CONVERT_TO_ESPEAK_LONG_DESC
657
+ Convert a phonetic message file (typically generated by convert_to_phonetic) to GLaDOS-style espeak XML format.
658
+
659
+ Parameters:
660
+ FILE - Path to phonetic message file (e.g., Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.txt)
661
+
662
+ The output XML file will be saved with the same name as the input file, but with .xml extension.
663
+
664
+ Example:
665
+ number_station convert_to_espeak Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.txt
666
+ # Creates: Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml
667
+ CONVERT_TO_ESPEAK_LONG_DESC
668
+ def convert_to_espeak(file)
669
+ ensure_config_loaded
670
+
671
+ unless File.exist?(file)
672
+ raise Thor::Error, "File not found: #{file}"
673
+ end
674
+
675
+ output_path = NumberStation.generate_glados_espeak(file)
676
+ puts "Generated GLaDOS espeak XML: #{output_path}"
677
+ end
110
678
 
111
- # make_one_time_pad
112
- desc "make_one_time_pad [--path PATH --numpads NUM --length LENGTH]", "Generate a one time pad of LENGTH containing NUM entries"
113
- long_desc <<-MAKE_ONE_TIME_PAD_LONG_DESC
114
- Generate a one time pad of LENGTH containing NUM entries
115
- Parameters:\n
116
- --path PATH\n
117
- --numpads NUM\n
118
- --length LENGTH
119
-
120
- If no parameters are passed it will generate 5 one time pads in the current
121
- directory of size 250 characters.
122
- MAKE_ONE_TIME_PAD_LONG_DESC
123
- option :length, :type => :numeric
124
- option :numpads, :type => :numeric
125
- option :path, :type => :string
126
- def make_one_time_pad()
127
- NumberStation::ConfigReader.read_config()
128
- NumberStation.log.debug "make_one_time_pad"
129
-
130
- length = options[:length]
131
- numpads = options[:numpads]
132
- path = options[:path]
133
- NumberStation.log.debug "length: #{length}" if options[:length]
134
- NumberStation.log.debug "numpads: #{numpads}" if options[:numpads]
135
- NumberStation.log.debug "path: #{path}" if options[:path]
136
-
137
- NumberStation.make_otp(path, length, numpads)
138
- end
139
-
140
-
141
- # encrypt message with a pad
142
- desc "encrypt_message [MESSAGE --numpad NUMPAD --padpath PADPATH]", "Encrypt a message using the key: NUMPAD in one time pad PADPATH"
143
- long_desc <<-ENCRYPT_MESSAGE_LONG_DESC
144
- Encrypt a message using key NUMPAD in one-time-pad PADPATH
145
- Parameters:\n
146
- MESSAGE
147
- --numpad NUMPAD\n
148
- --padpath PADPATH
679
+ desc "espeak XML_FILE", "Use espeak to read an XML file"
680
+ long_desc <<-ESPEAK_LONG_DESC
681
+ Use the espeak utility to read an XML file with GLaDOS-style voice settings.
682
+
683
+ Parameters:
684
+ XML_FILE - Path to XML file (typically generated by convert_to_espeak)
685
+
686
+ The command checks if espeak is available on the system before executing.
687
+ Uses GLaDOS voice settings: -ven+f3 -m -p 60 -s 100 -g 4
688
+
689
+ Example:
690
+ number_station espeak Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml
691
+ ESPEAK_LONG_DESC
692
+ def espeak(xml_file)
693
+ ensure_config_loaded
694
+
695
+ # Check if espeak is available
696
+ unless NumberStation.command?('espeak')
697
+ raise Thor::Error, "espeak utility is not available on this system. Please install espeak to use this command."
698
+ end
699
+
700
+ unless File.exist?(xml_file)
701
+ raise Thor::Error, "File not found: #{xml_file}"
702
+ end
703
+
704
+ # Call espeak with GLaDOS voice settings
705
+ cmd = "espeak -ven+f3 -m -p 60 -s 100 -g 4 -f #{xml_file}"
706
+ NumberStation.log.info "Running espeak: #{cmd}"
707
+ system(cmd)
708
+
709
+ unless $?.success?
710
+ raise Thor::Error, "espeak command failed with exit code #{$?.exitstatus}"
711
+ end
712
+ end
149
713
 
150
- ENCRYPT_MESSAGE_LONG_DESC
151
- option :numpad, :type => :string
152
- option :padpath, :type => :string
153
- def encrypt_message(message)
154
- NumberStation::ConfigReader.read_config()
155
- NumberStation.log.debug "encrypt_message"
714
+ desc "convert_to_mp3 XML_FILE", "Convert an XML file to MP3 using espeak and ffmpeg"
715
+ long_desc <<-CONVERT_TO_MP3_LONG_DESC
716
+ Convert an XML file (typically generated by convert_to_espeak) to MP3 audio format.
717
+
718
+ Parameters:
719
+ XML_FILE - Path to XML file (e.g., Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml)
720
+
721
+ The command checks if both espeak and ffmpeg utilities are available before executing.
722
+ The output MP3 file will be saved with the same name as the input file, but with .mp3 extension.
723
+
724
+ Example:
725
+ number_station convert_to_mp3 Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml
726
+ # Creates: Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.mp3
727
+ CONVERT_TO_MP3_LONG_DESC
728
+ def convert_to_mp3(xml_file)
729
+ ensure_config_loaded
730
+
731
+ # Check if espeak and ffmpeg are available
732
+ unless NumberStation.command?('espeak')
733
+ raise Thor::Error, "espeak utility is not available on this system. Please install espeak to use this command."
734
+ end
735
+
736
+ unless NumberStation.command?('ffmpeg')
737
+ raise Thor::Error, "ffmpeg utility is not available on this system. Please install ffmpeg to use this command."
738
+ end
739
+
740
+ unless File.exist?(xml_file)
741
+ raise Thor::Error, "File not found: #{xml_file}"
742
+ end
743
+
744
+ # Generate output filename: replace .xml extension with .mp3
745
+ input_basename = File.basename(xml_file, File.extname(xml_file))
746
+ output_filename = "#{input_basename}.mp3"
747
+ output_path = File.join(File.dirname(xml_file), output_filename)
748
+
749
+ # Call espeak piped to ffmpeg
750
+ cmd = "espeak -ven+f3 -m -p 60 -s 100 -g 4 -f #{xml_file} --stdout | ffmpeg -i - -ar 44100 -ac 2 -ab 192k -f mp3 #{output_path}"
751
+ NumberStation.log.info "Running: #{cmd}"
752
+ system(cmd)
753
+
754
+ unless $?.success?
755
+ raise Thor::Error, "convert_to_mp3 command failed with exit code #{$?.exitstatus}"
756
+ end
757
+
758
+ puts "Generated MP3 file: #{output_path}"
759
+ end
156
760
 
157
- message_data = File.read(message)
158
- numpad = options[:numpad]
159
- padpath = options[:padpath]
160
761
 
161
- NumberStation.log.debug "message: #{message}" if options[:message]
162
- NumberStation.log.debug "numpad: #{numpad}" if options[:numpad]
163
- NumberStation.log.debug "padpath: #{padpath}" if options[:padpath]
762
+ desc "encrypt [MESSAGE --file FILE --agent AGENT --numpad NUMPAD --padpath PADPATH]", "Encrypt a message using the key: NUMPAD in one time pad PADPATH"
763
+ long_desc <<-ENCRYPT_MESSAGE_LONG_DESC
764
+ Encrypt a message using key NUMPAD in one-time-pad PADPATH
765
+
766
+ Parameters:
767
+ MESSAGE - Message string to encrypt (if --file is not provided)
768
+ --file FILE - Path to message file (alternative to passing message as argument)
769
+ --agent AGENT - Agent name. If provided, searches for oldest pad in ~/number_station/pads/AGENT/
770
+ --numpad NUMPAD - Pad number (optional, will try to auto-detect if not provided)
771
+ --padpath PADPATH - Path to pad file (optional, will try to auto-detect if not provided)
164
772
 
165
- enc_m = NumberStation.encrypt_message(message_data, padpath, numpad)
166
- NumberStation.log.debug "encrypted_message: #{enc_m}"
773
+ If --agent is provided, the system will search for the oldest pad with unconsumed pads
774
+ in the agent-specific directory (~/number_station/pads/AGENT/).
775
+
776
+ If --agent is not provided and --padpath/--numpad are not specified, the system will
777
+ search for the oldest pad with unconsumed pads in ~/number_station/pads/.
778
+
779
+ Examples:
780
+ number_station encrypt "Hello World" --agent Shadow
781
+ number_station encrypt --file message.txt --agent Shadow
782
+ number_station encrypt "Hello World" --agent Shadow --numpad 5
783
+ number_station encrypt --file message.txt --padpath ~/number_station/pads/Shadow/Shadow-2024-01-15.json --numpad 10
784
+ ENCRYPT_MESSAGE_LONG_DESC
785
+ option :file, type: :string
786
+ option :agent, type: :string
787
+ option :numpad, type: :string
788
+ option :padpath, type: :string
789
+ def encrypt(message = nil)
790
+ # Handle help request
791
+ if message == "help" || (message.nil? && options[:file].nil? && options[:agent].nil?)
792
+ help("encrypt")
793
+ return
794
+ end
795
+
796
+ ensure_config_loaded
797
+
798
+ # Validate agent if provided
799
+ if options[:agent]
800
+ agent = NumberStation.find_agent_by_name(options[:agent])
801
+ unless agent
802
+ raise Thor::Error, "Agent '#{options[:agent]}' not found"
803
+ end
804
+ unless agent["active"] == true
805
+ raise Thor::Error, "Agent '#{options[:agent]}' is inactive. Messages can only be encrypted for active agents."
806
+ end
807
+ end
808
+
809
+ # Determine message content: from --file option or from argument
810
+ if options[:file]
811
+ unless File.exist?(options[:file])
812
+ raise Thor::Error, "File not found: #{options[:file]}"
813
+ end
814
+ message_data = File.read(options[:file])
815
+ message_file_path = options[:file] # Store for filename generation
816
+ elsif message && !message.empty?
817
+ message_data = message
818
+ # If agent is specified, we'll save to file (even though input is string)
819
+ # Pass a flag to indicate we should save to file
820
+ message_file_path = options[:agent] ? "string_input" : nil
821
+ else
822
+ raise Thor::Error, "Either provide a message string as argument or use --file option"
823
+ end
824
+
825
+ # Auto-detect pad if not provided
826
+ if options[:padpath].nil? || options[:numpad].nil?
827
+ agent_name = options[:agent]
828
+ pad_info = NumberStation.find_next_available_pad(nil, message_data.size, true, agent_name)
829
+ pad_path = options[:padpath] || pad_info[:pad_path]
830
+ pad_num = options[:numpad] || pad_info[:pad_num]
831
+
832
+ agent_info = agent_name ? " for agent '#{agent_name}'" : ""
833
+ NumberStation.log.info "Using pad#{agent_info}: #{File.basename(pad_path)}, pad number: #{pad_num}"
834
+ else
835
+ pad_path = options[:padpath]
836
+ pad_num = options[:numpad]
837
+ end
838
+
839
+ encrypted = NumberStation.encrypt_message(message_data, pad_path, pad_num, message_file_path)
840
+ NumberStation.log.debug "encrypted_message: #{encrypted}"
841
+
842
+ # Output encrypted message to stdout if encrypting from string without agent
843
+ # (If agent is specified, we save to file instead)
844
+ unless message_file_path
845
+ puts encrypted
846
+ end
167
847
  end
168
848
 
169
849
 
170
- # decrypt message with a pad
171
- desc "decrypt_message [MESSAGE --numpad NUMPAD --padpath PADPATH]", "Decrypt a message using the key: NUMPAD in one time pad PADPATH"
850
+ desc "decrypt [MESSAGE --file FILE --numpad NUMPAD --padpath PADPATH]", "Decrypt a message using the key: NUMPAD in one time pad PADPATH"
172
851
  long_desc <<-DECRYPT_MESSAGE_LONG_DESC
173
- Encrypt a message using key NUMPAD in one-time-pad PADPATH
174
- Parameters:\n
175
- MESSAGE
176
- --numpad NUMPAD\n
177
- --padpath PADPATH
852
+ Decrypt a message using key NUMPAD in one-time-pad PADPATH
853
+
854
+ Parameters:
855
+ MESSAGE - Encrypted message string to decrypt (if --file is not provided)
856
+ --file FILE - Path to encrypted message file (alternative to passing message as argument)
857
+ --numpad NUMPAD (optional, will try to auto-detect if not provided)
858
+ --padpath PADPATH (optional, will try to auto-detect if not provided)
178
859
 
860
+ If --numpad or --padpath are not specified, the system will attempt to find a matching pad.
861
+ Note: Auto-detection may be slower as it tries multiple pads.
862
+
863
+ Examples:
864
+ number_station decrypt "a1b2c3d4e5" --padpath ~/number_station/pads/Shadow/Shadow-2024-01-15.json --numpad 0
865
+ number_station decrypt --file encrypted.txt --padpath ~/number_station/pads/Shadow/Shadow-2024-01-15.json --numpad 0
866
+ number_station decrypt --file encrypted.txt
179
867
  DECRYPT_MESSAGE_LONG_DESC
180
- option :numpad, :type => :string
181
- option :padpath, :type => :string
182
- def decrypt_message(message)
183
- NumberStation::ConfigReader.read_config()
184
- NumberStation.log.debug "decrypt_message"
185
-
186
- message_data = File.read(message)
187
- numpad = options[:numpad]
188
- padpath = options[:padpath]
868
+ option :file, type: :string
869
+ option :numpad, type: :string
870
+ option :padpath, type: :string
871
+ def decrypt(message = nil)
872
+ # Handle help request
873
+ if message == "help" || (message.nil? && options[:file].nil?)
874
+ help("decrypt")
875
+ return
876
+ end
877
+
878
+ ensure_config_loaded
879
+
880
+ # Determine message content: from --file option or from argument
881
+ if options[:file]
882
+ unless File.exist?(options[:file])
883
+ raise Thor::Error, "File not found: #{options[:file]}"
884
+ end
885
+ message_data = File.read(options[:file])
886
+ message_file_path = options[:file] # Store for filename generation
887
+ elsif message && !message.empty?
888
+ message_data = message
889
+ message_file_path = nil
890
+ else
891
+ raise Thor::Error, "Either provide an encrypted message string as argument or use --file option"
892
+ end
893
+
894
+ # Auto-detect pad if not provided
895
+ if options[:padpath].nil? || options[:numpad].nil?
896
+ # For decryption, we don't require unconsumed pads (can decrypt with consumed pads)
897
+ # Get cleaned message length for size check
898
+ cleaned_message = message_data.gsub(/[\s\n\r]/, '')
899
+ min_length = cleaned_message.length
900
+
901
+ begin
902
+ pad_info = NumberStation.find_next_available_pad(nil, min_length, false)
903
+ pad_path = options[:padpath] || pad_info[:pad_path]
904
+ pad_num = options[:numpad] || pad_info[:pad_num]
905
+
906
+ NumberStation.log.info "Attempting decryption with pad: #{File.basename(pad_path)}, pad number: #{pad_num}"
907
+ decrypted = NumberStation.decrypt_message(message_data, pad_path, pad_num, message_file_path)
908
+ NumberStation.log.debug "decrypted_message: #{decrypted}"
909
+
910
+ # Output decrypted message to stdout if decrypting from string
911
+ unless message_file_path
912
+ puts decrypted
913
+ end
914
+ rescue ArgumentError, StandardError => e
915
+ NumberStation.log.error "Failed to decrypt with auto-detected pad: #{e.message}"
916
+ NumberStation.log.error "Please specify --padpath and --numpad explicitly for accurate decryption"
917
+ raise
918
+ end
919
+ else
920
+ pad_path = options[:padpath]
921
+ pad_num = options[:numpad]
922
+ decrypted = NumberStation.decrypt_message(message_data, pad_path, pad_num, message_file_path)
923
+ NumberStation.log.debug "decrypted_message: #{decrypted}"
924
+
925
+ # Output decrypted message to stdout if decrypting from string
926
+ unless message_file_path
927
+ puts decrypted
928
+ end
929
+ end
930
+ end
189
931
 
190
- NumberStation.log.debug "message: #{message}"
191
- NumberStation.log.debug "numpad: #{numpad}" if options[:numpad]
192
- NumberStation.log.debug "padpath: #{padpath}" if options[:padpath]
193
932
 
194
- decrypt_m = NumberStation.decrypt_message(message_data, padpath, numpad)
195
- NumberStation.log.debug "decrypted_message: #{decrypt_m}"
196
- end
933
+ desc "agents SUBCOMMAND", "Manage and view agent information"
934
+ long_desc <<-AGENTS_LONG_DESC
935
+ Manage agents in your number station configuration.
936
+
937
+ Available subcommands:
938
+ create NAME [--location LOCATION] [--handler HANDLER]
939
+ Create a new agent
940
+
941
+ activate NAME [--start-date DATE]
942
+ Activate an agent
943
+
944
+ deactivate NAME [--end-date DATE]
945
+ Deactivate an agent
946
+
947
+ update-handler NAME HANDLER
948
+ Update handler codeword for an agent
949
+
950
+ list
951
+ Show condensed list of active agents
952
+
953
+ list-all
954
+ Show all active agents and inactive agents with end dates
955
+
956
+ stats
957
+ Show detailed statistics about active agents
958
+
959
+ Examples:
960
+ number_station agents create Shadow --location "Berlin" --handler "NIGHTFALL"
961
+ number_station agents activate Shadow
962
+ number_station agents list
963
+ number_station agents stats
964
+
965
+ For help on a specific subcommand:
966
+ number_station agents help SUBCOMMAND
967
+ AGENTS_LONG_DESC
968
+ subcommand "agents", Agents
197
969
 
970
+ desc "pad SUBCOMMAND", "Manage one-time pads"
971
+ long_desc <<-PAD_LONG_DESC
972
+ Manage one-time pads for encryption/decryption.
973
+
974
+ Available subcommands:
975
+ create [--name NAME --path PATH --numpads NUM --length LENGTH]
976
+ Generate a new one-time pad file
977
+ Use --name to create agent-specific pad directories
978
+
979
+ stats [--path PATH]
980
+ Show statistics about one-time pads in a directory
981
+
982
+ Examples:
983
+ number_station pad create --name Shadow --numpads 1000 --length 1000
984
+ number_station pad create --name Shadow
985
+ number_station pad create --path ~/number_station/pads --numpads 10 --length 500
986
+ number_station pad create
987
+ number_station pad stats
988
+ number_station pad stats --path ~/custom/pads
989
+
990
+ For help on a specific subcommand:
991
+ number_station pad help SUBCOMMAND
992
+ PAD_LONG_DESC
993
+ subcommand "pad", Pad
198
994
 
199
- # version
200
995
  desc "version", "Print the version of the Number Stations gem."
201
996
  long_desc <<-VERSION_LONG_DESC
202
997
  Prints the version of the Number Stations gem.
203
998
  VERSION_LONG_DESC
204
- def version()
205
- NumberStation::ConfigReader.read_config()
206
- NumberStation.log.debug "Version: #{NumberStation::VERSION}"
999
+ def version
1000
+ ensure_config_loaded
1001
+ puts NumberStation::VERSION
207
1002
  end
208
1003
 
209
- def self.exit_on_failure?()
1004
+ def self.exit_on_failure?
210
1005
  false
211
1006
  end
1007
+
1008
+ # Remove the built-in 'tree' command
1009
+ def self.all_commands
1010
+ super.reject { |k, v| k == 'tree' }
1011
+ end
1012
+
1013
+ private
1014
+
1015
+ def ensure_config_loaded
1016
+ NumberStation::ConfigReader.read_config unless NumberStation.data
1017
+ end
1018
+
1019
+ def load_message_component(type, override_path)
1020
+ resource_key = type.to_s
1021
+ config = NumberStation.data["resources"][resource_key]
1022
+
1023
+ content = if override_path
1024
+ # For intro, outro, and repeat, read as-is (not converted to phonetic)
1025
+ File.read(override_path)
1026
+ elsif config && config["enabled"]
1027
+ # For intro, outro, and repeat, read as-is (not converted to phonetic)
1028
+ File.read(config["template"])
1029
+ else
1030
+ ""
1031
+ end
1032
+
1033
+ # Strip trailing newlines to avoid extra blank lines when joining
1034
+ content.chomp
1035
+ end
1036
+
1037
+ def find_config_template
1038
+ # Try multiple possible locations
1039
+ possible_paths = [
1040
+ File.expand_path(File.join(File.dirname(__FILE__), "../../resources/conf.yaml")),
1041
+ File.expand_path(File.join(File.dirname(__FILE__), "../../../resources/conf.yaml"))
1042
+ ]
1043
+
1044
+ # Try gem datadir if available
1045
+ begin
1046
+ gem_path = File.join(Gem.datadir('number_station'), 'conf.yaml')
1047
+ possible_paths << gem_path if gem_path
1048
+ rescue
1049
+ # Gem.datadir not available, skip
1050
+ end
1051
+
1052
+ possible_paths.find { |path| path && File.exist?(path) }
1053
+ end
1054
+
1055
+ def find_resource_file(filename)
1056
+ # Try multiple possible locations for resource files
1057
+ possible_paths = [
1058
+ File.expand_path(File.join(File.dirname(__FILE__), "../../resources/#{filename}")),
1059
+ File.expand_path(File.join(File.dirname(__FILE__), "../../../resources/#{filename}"))
1060
+ ]
1061
+
1062
+ # Try gem datadir if available
1063
+ begin
1064
+ gem_path = File.join(Gem.datadir('number_station'), filename)
1065
+ possible_paths << gem_path if gem_path
1066
+ rescue
1067
+ # Gem.datadir not available, skip
1068
+ end
1069
+
1070
+ possible_paths.find { |path| path && File.exist?(path) }
1071
+ end
1072
+
1073
+ def copy_message_template_files(target_path)
1074
+ # Copy intro_message.txt, outro_message.txt, and repeat_message.txt
1075
+ template_files = ['intro_message.txt', 'outro_message.txt', 'repeat_message.txt']
1076
+
1077
+ template_files.each do |filename|
1078
+ source_file = find_resource_file(filename)
1079
+ target_file = File.join(target_path, filename)
1080
+
1081
+ unless File.exist?(target_file)
1082
+ if source_file && File.exist?(source_file)
1083
+ FileUtils.cp(source_file, target_file)
1084
+ NumberStation.log.info "Created template file: #{target_file}"
1085
+ else
1086
+ NumberStation.log.warn "Template file not found: #{filename}"
1087
+ end
1088
+ else
1089
+ NumberStation.log.debug "Template file already exists: #{target_file}"
1090
+ end
1091
+ end
1092
+ end
1093
+
1094
+ def create_default_config(target_file)
1095
+ default_config = <<~YAML
1096
+ logging:
1097
+ level: 0
1098
+
1099
+ server:
1100
+ host: "0.0.0.0"
1101
+ port: 8080
1102
+
1103
+ resources:
1104
+ intro:
1105
+ template: "intro_message.txt"
1106
+ enabled: true
1107
+ outro:
1108
+ template: "outro_message.txt"
1109
+ enabled: true
1110
+ repeat:
1111
+ template: "repeat_message.txt"
1112
+ enabled: false
1113
+
1114
+ agent_list:
1115
+ - name: Abyss
1116
+ location: null
1117
+ handler_codeword: null
1118
+ start_date: null
1119
+ end_date: null
1120
+ active: false
1121
+ - name: Ash
1122
+ location: null
1123
+ handler_codeword: null
1124
+ start_date: null
1125
+ end_date: null
1126
+ active: false
1127
+ - name: Blade
1128
+ location: null
1129
+ handler_codeword: null
1130
+ start_date: null
1131
+ end_date: null
1132
+ active: false
1133
+ - name: Blitz
1134
+ location: null
1135
+ handler_codeword: null
1136
+ start_date: null
1137
+ end_date: null
1138
+ active: false
1139
+ - name: Cipher
1140
+ location: null
1141
+ handler_codeword: null
1142
+ start_date: null
1143
+ end_date: null
1144
+ active: false
1145
+ - name: Cobra
1146
+ location: null
1147
+ handler_codeword: null
1148
+ start_date: null
1149
+ end_date: null
1150
+ active: false
1151
+ - name: Dagger
1152
+ location: null
1153
+ handler_codeword: null
1154
+ start_date: null
1155
+ end_date: null
1156
+ active: false
1157
+ - name: Drift
1158
+ location: null
1159
+ handler_codeword: null
1160
+ start_date: null
1161
+ end_date: null
1162
+ active: false
1163
+ - name: Dusk
1164
+ location: null
1165
+ handler_codeword: null
1166
+ start_date: null
1167
+ end_date: null
1168
+ active: false
1169
+ - name: Eclipse
1170
+ location: null
1171
+ handler_codeword: null
1172
+ start_date: null
1173
+ end_date: null
1174
+ active: false
1175
+ - name: Enigma
1176
+ location: null
1177
+ handler_codeword: null
1178
+ start_date: null
1179
+ end_date: null
1180
+ active: false
1181
+ - name: Fang
1182
+ location: null
1183
+ handler_codeword: null
1184
+ start_date: null
1185
+ end_date: null
1186
+ active: false
1187
+ - name: Flux
1188
+ location: null
1189
+ handler_codeword: null
1190
+ start_date: null
1191
+ end_date: null
1192
+ active: false
1193
+ - name: Frost
1194
+ location: null
1195
+ handler_codeword: null
1196
+ start_date: null
1197
+ end_date: null
1198
+ active: false
1199
+ - name: Frostbite
1200
+ location: null
1201
+ handler_codeword: null
1202
+ start_date: null
1203
+ end_date: null
1204
+ active: false
1205
+ - name: Ghost
1206
+ location: null
1207
+ handler_codeword: null
1208
+ start_date: null
1209
+ end_date: null
1210
+ active: false
1211
+ - name: Grim
1212
+ location: null
1213
+ handler_codeword: null
1214
+ start_date: null
1215
+ end_date: null
1216
+ active: false
1217
+ - name: Grimlock
1218
+ location: null
1219
+ handler_codeword: null
1220
+ start_date: null
1221
+ end_date: null
1222
+ active: false
1223
+ - name: Havoc
1224
+ location: null
1225
+ handler_codeword: null
1226
+ start_date: null
1227
+ end_date: null
1228
+ active: false
1229
+ - name: Helix
1230
+ location: null
1231
+ handler_codeword: null
1232
+ start_date: null
1233
+ end_date: null
1234
+ active: false
1235
+ - name: Hunter
1236
+ location: null
1237
+ handler_codeword: null
1238
+ start_date: null
1239
+ end_date: null
1240
+ active: false
1241
+ - name: Iron
1242
+ location: null
1243
+ handler_codeword: null
1244
+ start_date: null
1245
+ end_date: null
1246
+ active: false
1247
+ - name: Ironclad
1248
+ location: null
1249
+ handler_codeword: null
1250
+ start_date: null
1251
+ end_date: null
1252
+ active: false
1253
+ - name: Jinx
1254
+ location: null
1255
+ handler_codeword: null
1256
+ start_date: null
1257
+ end_date: null
1258
+ active: false
1259
+ - name: Kraken
1260
+ location: null
1261
+ handler_codeword: null
1262
+ start_date: null
1263
+ end_date: null
1264
+ active: false
1265
+ - name: Nexus
1266
+ location: null
1267
+ handler_codeword: null
1268
+ start_date: null
1269
+ end_date: null
1270
+ active: false
1271
+ - name: Nightfall
1272
+ location: null
1273
+ handler_codeword: null
1274
+ start_date: null
1275
+ end_date: null
1276
+ active: false
1277
+ - name: Nightshade
1278
+ location: null
1279
+ handler_codeword: null
1280
+ start_date: null
1281
+ end_date: null
1282
+ active: false
1283
+ - name: Nova
1284
+ location: null
1285
+ handler_codeword: null
1286
+ start_date: null
1287
+ end_date: null
1288
+ active: false
1289
+ - name: Nyx
1290
+ location: null
1291
+ handler_codeword: null
1292
+ start_date: null
1293
+ end_date: null
1294
+ active: false
1295
+ - name: Obsidian
1296
+ location: null
1297
+ handler_codeword: null
1298
+ start_date: null
1299
+ end_date: null
1300
+ active: false
1301
+ - name: Onyx
1302
+ location: null
1303
+ handler_codeword: null
1304
+ start_date: null
1305
+ end_date: null
1306
+ active: false
1307
+ - name: Phantom
1308
+ location: null
1309
+ handler_codeword: null
1310
+ start_date: null
1311
+ end_date: null
1312
+ active: false
1313
+ - name: Pulse
1314
+ location: null
1315
+ handler_codeword: null
1316
+ start_date: null
1317
+ end_date: null
1318
+ active: false
1319
+ - name: Raptor
1320
+ location: null
1321
+ handler_codeword: null
1322
+ start_date: null
1323
+ end_date: null
1324
+ active: false
1325
+ - name: Raven
1326
+ location: null
1327
+ handler_codeword: null
1328
+ start_date: null
1329
+ end_date: null
1330
+ active: false
1331
+ - name: Razor
1332
+ location: null
1333
+ handler_codeword: null
1334
+ start_date: null
1335
+ end_date: null
1336
+ active: false
1337
+ - name: Reaper
1338
+ location: null
1339
+ handler_codeword: null
1340
+ start_date: null
1341
+ end_date: null
1342
+ active: false
1343
+ - name: Revenant
1344
+ location: null
1345
+ handler_codeword: null
1346
+ start_date: null
1347
+ end_date: null
1348
+ active: false
1349
+ - name: Riven
1350
+ location: null
1351
+ handler_codeword: null
1352
+ start_date: null
1353
+ end_date: null
1354
+ active: false
1355
+ - name: Rogue
1356
+ location: null
1357
+ handler_codeword: null
1358
+ start_date: null
1359
+ end_date: null
1360
+ active: false
1361
+ - name: Saber
1362
+ location: null
1363
+ handler_codeword: null
1364
+ start_date: null
1365
+ end_date: null
1366
+ active: false
1367
+ - name: Scorch
1368
+ location: null
1369
+ handler_codeword: null
1370
+ start_date: null
1371
+ end_date: null
1372
+ active: false
1373
+ - name: Scorpion
1374
+ location: null
1375
+ handler_codeword: null
1376
+ start_date: null
1377
+ end_date: null
1378
+ active: false
1379
+ - name: Shade
1380
+ location: null
1381
+ handler_codeword: null
1382
+ start_date: null
1383
+ end_date: null
1384
+ active: false
1385
+ - name: Shadow
1386
+ location: null
1387
+ handler_codeword: null
1388
+ start_date: null
1389
+ end_date: null
1390
+ active: false
1391
+ - name: Shard
1392
+ location: null
1393
+ handler_codeword: null
1394
+ start_date: null
1395
+ end_date: null
1396
+ active: false
1397
+ - name: Slate
1398
+ location: null
1399
+ handler_codeword: null
1400
+ start_date: null
1401
+ end_date: null
1402
+ active: false
1403
+ - name: Specter
1404
+ location: null
1405
+ handler_codeword: null
1406
+ start_date: null
1407
+ end_date: null
1408
+ active: false
1409
+ - name: Storm
1410
+ location: null
1411
+ handler_codeword: null
1412
+ start_date: null
1413
+ end_date: null
1414
+ active: false
1415
+ - name: Striker
1416
+ location: null
1417
+ handler_codeword: null
1418
+ start_date: null
1419
+ end_date: null
1420
+ active: false
1421
+ - name: Surge
1422
+ location: null
1423
+ handler_codeword: null
1424
+ start_date: null
1425
+ end_date: null
1426
+ active: false
1427
+ - name: Talon
1428
+ location: null
1429
+ handler_codeword: null
1430
+ start_date: null
1431
+ end_date: null
1432
+ active: false
1433
+ - name: Tempest
1434
+ location: null
1435
+ handler_codeword: null
1436
+ start_date: null
1437
+ end_date: null
1438
+ active: false
1439
+ - name: Thorn
1440
+ location: null
1441
+ handler_codeword: null
1442
+ start_date: null
1443
+ end_date: null
1444
+ active: false
1445
+ - name: Titan
1446
+ location: null
1447
+ handler_codeword: null
1448
+ start_date: null
1449
+ end_date: null
1450
+ active: false
1451
+ - name: Vantage
1452
+ location: null
1453
+ handler_codeword: null
1454
+ start_date: null
1455
+ end_date: null
1456
+ active: false
1457
+ - name: Venom
1458
+ location: null
1459
+ handler_codeword: null
1460
+ start_date: null
1461
+ end_date: null
1462
+ active: false
1463
+ - name: Vex
1464
+ location: null
1465
+ handler_codeword: null
1466
+ start_date: null
1467
+ end_date: null
1468
+ active: false
1469
+ - name: Viper
1470
+ location: null
1471
+ handler_codeword: null
1472
+ start_date: null
1473
+ end_date: null
1474
+ active: false
1475
+ - name: Void
1476
+ location: null
1477
+ handler_codeword: null
1478
+ start_date: null
1479
+ end_date: null
1480
+ active: false
1481
+ - name: Voidwalker
1482
+ location: null
1483
+ handler_codeword: null
1484
+ start_date: null
1485
+ end_date: null
1486
+ active: false
1487
+ - name: Vortex
1488
+ location: null
1489
+ handler_codeword: null
1490
+ start_date: null
1491
+ end_date: null
1492
+ active: false
1493
+ - name: Wraith
1494
+ location: null
1495
+ handler_codeword: null
1496
+ start_date: null
1497
+ end_date: null
1498
+ active: false
1499
+ - name: Zephyr
1500
+ location: null
1501
+ handler_codeword: null
1502
+ start_date: null
1503
+ end_date: null
1504
+ active: false
1505
+ - name: Zero
1506
+ location: null
1507
+ handler_codeword: null
1508
+ start_date: null
1509
+ end_date: null
1510
+ active: false
1511
+ YAML
1512
+
1513
+ File.write(target_file, default_config)
1514
+ end
212
1515
  end
213
1516
  end