slk 0.2.0 → 0.4.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.
@@ -6,21 +6,41 @@ module Slk
6
6
  module Services
7
7
  # Encrypts/decrypts tokens using age with SSH keys
8
8
  class Encryption
9
+ SUPPORTED_KEY_TYPES = %w[ssh-rsa ssh-ed25519].freeze
10
+
11
+ attr_accessor :on_prompt_pub_key
12
+
9
13
  def available?
10
- system('which age > /dev/null 2>&1')
14
+ # Cross-platform check for age command
15
+ _output, _error, status = Open3.capture3('age', '--version')
16
+ status.success?
17
+ rescue Errno::ENOENT
18
+ false
11
19
  end
12
20
 
13
- def encrypt(content, ssh_key_path, output_file) # rubocop:disable Naming/PredicateMethod
14
- raise EncryptionError, 'age encryption tool not available' unless available?
21
+ # Validate that the SSH key is a type supported by age
22
+ # @param ssh_key_path [String] Path to the SSH private key (public key at .pub)
23
+ # @return [true] if valid
24
+ # @raise [EncryptionError] if private key not found, public key not found,
25
+ # key type is unsupported, or public key doesn't match private key
26
+ def validate_key_type!(ssh_key_path)
27
+ raise EncryptionError, "Private key not found: #{ssh_key_path}" unless File.exist?(ssh_key_path)
15
28
 
16
- public_key = "#{ssh_key_path}.pub"
17
- raise EncryptionError, "Public key not found: #{public_key}" unless File.exist?(public_key)
18
-
19
- _output, error, status = Open3.capture3('age', '-R', public_key, '-o', output_file, stdin_data: content)
29
+ public_key = find_public_key(ssh_key_path)
30
+ validate_public_key_type!(public_key)
31
+ validate_key_pair_match!(ssh_key_path, public_key)
32
+ end
20
33
 
21
- raise EncryptionError, "Failed to encrypt: #{error.strip}" unless status.success?
34
+ # Encrypt content using age with an SSH public key
35
+ # @param content [String] The content to encrypt
36
+ # @param ssh_key_path [String] Path to the SSH private key (public key at .pub)
37
+ # @param output_file [String] Path where encrypted output will be written
38
+ # @raise [EncryptionError] If age tool not available or public key not found
39
+ def encrypt(content, ssh_key_path, output_file)
40
+ raise EncryptionError, 'age encryption tool not available' unless available?
22
41
 
23
- true
42
+ public_key = find_public_key(ssh_key_path)
43
+ run_age_encrypt(content, public_key, output_file)
24
44
  end
25
45
 
26
46
  # Decrypt an age-encrypted file using an SSH key
@@ -29,14 +49,97 @@ module Slk
29
49
  # @return [String, nil] Decrypted content, or nil if file doesn't exist
30
50
  # @raise [EncryptionError] If age tool not available, key not found, or decryption fails
31
51
  def decrypt(encrypted_file, ssh_key_path)
32
- # File not existing is not an error - it just means no encrypted data yet
33
52
  return nil unless File.exist?(encrypted_file)
34
53
 
35
54
  raise EncryptionError, 'age encryption tool not available' unless available?
36
55
  raise EncryptionError, "SSH key not found: #{ssh_key_path}" unless File.exist?(ssh_key_path)
37
56
 
38
- output, error, status = Open3.capture3('age', '-d', '-i', ssh_key_path, encrypted_file)
57
+ run_age_decrypt(encrypted_file, ssh_key_path)
58
+ end
59
+
60
+ private
61
+
62
+ def find_public_key(ssh_key_path)
63
+ default_pub = "#{ssh_key_path}.pub"
64
+ return default_pub if File.exist?(default_pub)
65
+
66
+ prompted_path = prompt_and_validate_public_key(ssh_key_path)
67
+ return prompted_path if prompted_path
68
+
69
+ raise EncryptionError, "Public key not found: #{default_pub}"
70
+ end
71
+
72
+ def prompt_and_validate_public_key(ssh_key_path)
73
+ return nil unless @on_prompt_pub_key
74
+
75
+ prompted_path = @on_prompt_pub_key.call(ssh_key_path)
76
+ return nil unless prompted_path && File.exist?(prompted_path)
77
+
78
+ # Validate the prompted key before accepting it
79
+ validate_public_key_type!(prompted_path)
80
+ validate_key_pair_match!(ssh_key_path, prompted_path)
81
+ prompted_path
82
+ end
83
+
84
+ def validate_public_key_type!(public_key)
85
+ first_line = File.read(public_key).lines.first&.strip || ''
86
+ key_type = first_line.split.first
39
87
 
88
+ return true if SUPPORTED_KEY_TYPES.include?(key_type)
89
+
90
+ raise EncryptionError,
91
+ "Unsupported SSH key type: #{key_type || 'unknown'}. " \
92
+ "age only supports: #{SUPPORTED_KEY_TYPES.join(', ')}"
93
+ end
94
+
95
+ def validate_key_pair_match!(private_key_path, public_key_path)
96
+ derived_pub = derive_public_key(private_key_path)
97
+ return true unless derived_pub # Skip validation if ssh-keygen not available
98
+
99
+ provided_pub = File.read(public_key_path).lines.first&.strip || ''
100
+ return true if keys_match?(derived_pub, provided_pub)
101
+
102
+ raise EncryptionError,
103
+ 'Public key does not match private key. ' \
104
+ 'Please provide the correct public key for this private key.'
105
+ end
106
+
107
+ def derive_public_key(private_key_path)
108
+ output, error, status = Open3.capture3('ssh-keygen', '-y', '-f', private_key_path)
109
+ return output.strip if status.success?
110
+
111
+ # Check if ssh-keygen is missing vs other failures
112
+ return nil if ssh_keygen_not_found?(error)
113
+
114
+ # For other failures (passphrase-protected, corrupted), warn the user
115
+ raise EncryptionError,
116
+ "Cannot verify key pair: #{error.strip}. " \
117
+ 'This may indicate a passphrase-protected or corrupted private key.'
118
+ end
119
+
120
+ # Heuristics for detecting missing ssh-keygen command.
121
+ # These strings vary by OS/shell but cover common cases.
122
+ def ssh_keygen_not_found?(error)
123
+ error.include?('command not found') ||
124
+ error.include?('not recognized') ||
125
+ error.include?('No such file or directory')
126
+ end
127
+
128
+ # SSH public key format: "type base64-data comment"
129
+ # Compare only type and key data, ignore the optional comment field
130
+ def keys_match?(derived, provided)
131
+ derived_parts = derived.split[0..1]
132
+ provided_parts = provided.split[0..1]
133
+ derived_parts == provided_parts
134
+ end
135
+
136
+ def run_age_encrypt(content, public_key, output_file)
137
+ _output, error, status = Open3.capture3('age', '-R', public_key, '-o', output_file, stdin_data: content)
138
+ raise EncryptionError, "Failed to encrypt: #{error.strip}" unless status.success?
139
+ end
140
+
141
+ def run_age_decrypt(encrypted_file, ssh_key_path)
142
+ output, error, status = Open3.capture3('age', '-d', '-i', ssh_key_path, encrypted_file)
40
143
  raise EncryptionError, "Failed to decrypt #{encrypted_file}: #{error.strip}" unless status.success?
41
144
 
42
145
  output
@@ -122,9 +122,9 @@ module Slk
122
122
  @output.success('Setup complete!')
123
123
  @output.puts
124
124
  @output.puts 'Try these commands:'
125
- @output.puts ' slack status - View your status'
126
- @output.puts ' slack messages #general - Read channel messages'
127
- @output.puts ' slack help - See all commands'
125
+ @output.puts ' slk status - View your status'
126
+ @output.puts ' slk messages general - Read channel messages'
127
+ @output.puts ' slk help - See all commands'
128
128
  end
129
129
  end
130
130
  end
@@ -91,17 +91,40 @@ module Slk
91
91
  end
92
92
 
93
93
  def find_user_id(workspace, username)
94
+ # Check cache first (reverse lookup by name)
95
+ cached_id = @cache.get_user_id_by_name(workspace.name, username)
96
+ return cached_id if cached_id
97
+
98
+ # Fall back to API call
99
+ fetch_user_id_from_api(workspace, username)
100
+ end
101
+
102
+ def fetch_user_id_from_api(workspace, username)
94
103
  api = @runner.users_api(workspace.name)
95
- response = api.list
96
- users = response['members'] || []
104
+ users = api.list['members'] || []
105
+ user = find_user_by_name(users, username)
106
+ cache_user(workspace.name, user) if user
107
+ user&.dig('id')
108
+ end
97
109
 
98
- user = users.find do |u|
110
+ def find_user_by_name(users, username)
111
+ users.find do |u|
99
112
  u['name'] == username ||
100
113
  u.dig('profile', 'display_name') == username ||
101
114
  u.dig('profile', 'real_name') == username
102
115
  end
116
+ end
103
117
 
104
- user&.dig('id')
118
+ def cache_user(workspace_name, user)
119
+ display_name = extract_display_name(user)
120
+ @cache.set_user(workspace_name, user['id'], display_name, persist: true)
121
+ end
122
+
123
+ def extract_display_name(user)
124
+ name = user.dig('profile', 'display_name').to_s.strip
125
+ name = user.dig('profile', 'real_name') if name.empty?
126
+ name = user['name'] if name.to_s.empty?
127
+ name
105
128
  end
106
129
  end
107
130
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Handles loading tokens from encrypted or plaintext files
6
+ class TokenLoader
7
+ def initialize(encryption:, paths:)
8
+ @encryption = encryption
9
+ @paths = paths
10
+ end
11
+
12
+ def load(ssh_key)
13
+ if encrypted_file_exists? && ssh_key
14
+ decrypt_with_key(ssh_key)
15
+ elsif encrypted_file_exists?
16
+ raise EncryptionError, 'Cannot read encrypted tokens without SSH key'
17
+ elsif plain_file_exists?
18
+ parse_plain_file
19
+ else
20
+ {}
21
+ end
22
+ end
23
+
24
+ def load_auto(config)
25
+ if encrypted_file_exists?
26
+ load_encrypted(config)
27
+ elsif plain_file_exists?
28
+ parse_plain_file
29
+ else
30
+ {}
31
+ end
32
+ end
33
+
34
+ def load_encrypted(config)
35
+ raise_missing_key_error unless config.ssh_key
36
+ decrypt_with_key(config.ssh_key)
37
+ end
38
+
39
+ def raise_missing_key_error
40
+ raise EncryptionError,
41
+ 'Cannot read encrypted tokens - no SSH key configured. Run: slk config set ssh_key <path>'
42
+ end
43
+
44
+ def encrypted_file_exists?
45
+ File.exist?(encrypted_tokens_file)
46
+ end
47
+
48
+ def plain_file_exists?
49
+ File.exist?(plain_tokens_file)
50
+ end
51
+
52
+ def encrypted_tokens_file
53
+ @paths.config_file('tokens.age')
54
+ end
55
+
56
+ def plain_tokens_file
57
+ @paths.config_file('tokens.json')
58
+ end
59
+
60
+ private
61
+
62
+ def decrypt_with_key(ssh_key)
63
+ content = @encryption.decrypt(encrypted_tokens_file, ssh_key)
64
+ if content.nil?
65
+ raise TokenStoreError, "Encrypted tokens file disappeared unexpectedly: #{encrypted_tokens_file}"
66
+ end
67
+
68
+ JSON.parse(content)
69
+ rescue JSON::ParserError => e
70
+ raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
71
+ end
72
+
73
+ def parse_plain_file
74
+ content = File.read(plain_tokens_file)
75
+ JSON.parse(content)
76
+ rescue Errno::ENOENT
77
+ raise TokenStoreError, "Tokens file disappeared unexpectedly: #{plain_tokens_file}"
78
+ rescue JSON::ParserError => e
79
+ raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Handles saving tokens to encrypted or plaintext files.
6
+ # Uses atomic writes (temp file + mv) for the token file itself.
7
+ class TokenSaver
8
+ # File system errors we catch and wrap in TokenStoreError
9
+ FILE_ERRORS = [
10
+ Errno::ENOENT,
11
+ Errno::EACCES,
12
+ Errno::EPERM,
13
+ Errno::ENOSPC,
14
+ Errno::EDQUOT,
15
+ Errno::EROFS,
16
+ Errno::EIO
17
+ ].freeze
18
+
19
+ def initialize(encryption:, paths:)
20
+ @encryption = encryption
21
+ @paths = paths
22
+ end
23
+
24
+ def save(tokens, ssh_key)
25
+ @paths.ensure_config_dir
26
+
27
+ if ssh_key
28
+ save_encrypted(tokens, ssh_key)
29
+ else
30
+ save_plaintext(tokens)
31
+ end
32
+ end
33
+
34
+ def save_with_cleanup(tokens, ssh_key)
35
+ @paths.ensure_config_dir
36
+
37
+ if ssh_key
38
+ save_encrypted(tokens, ssh_key)
39
+ FileUtils.rm_f(plain_tokens_file)
40
+ else
41
+ save_plaintext(tokens)
42
+ FileUtils.rm_f(encrypted_tokens_file)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def save_encrypted(tokens, ssh_key)
49
+ temp_file = "#{encrypted_tokens_file}.tmp"
50
+ @encryption.encrypt(JSON.generate(tokens), ssh_key, temp_file)
51
+ FileUtils.mv(temp_file, encrypted_tokens_file)
52
+ FileUtils.rm_f(plain_tokens_file)
53
+ rescue EncryptionError => e
54
+ FileUtils.rm_f(temp_file)
55
+ raise TokenStoreError, "Failed to encrypt tokens: #{e.message}"
56
+ rescue *FILE_ERRORS => e
57
+ FileUtils.rm_f(temp_file)
58
+ raise TokenStoreError, "Failed to save encrypted tokens: #{e.message}"
59
+ end
60
+
61
+ def save_plaintext(tokens)
62
+ temp_file = "#{plain_tokens_file}.tmp"
63
+ File.write(temp_file, JSON.pretty_generate(tokens))
64
+ restrict_file_permissions(temp_file)
65
+ FileUtils.mv(temp_file, plain_tokens_file)
66
+ rescue *FILE_ERRORS => e
67
+ FileUtils.rm_f(temp_file)
68
+ raise TokenStoreError, "Failed to save tokens: #{e.message}"
69
+ end
70
+
71
+ # Restrict file to owner-only access.
72
+ # On Unix: chmod 600. On Windows: chmod is a no-op for security;
73
+ # files in %APPDATA% are already user-private by default.
74
+ def restrict_file_permissions(file)
75
+ File.chmod(0o600, file) unless Gem.win_platform?
76
+ end
77
+
78
+ def encrypted_tokens_file
79
+ @paths.config_file('tokens.age')
80
+ end
81
+
82
+ def plain_tokens_file
83
+ @paths.config_file('tokens.json')
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,118 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'token_loader'
4
+ require_relative 'token_saver'
5
+
3
6
  module Slk
4
7
  module Services
5
8
  # Manages workspace tokens with optional encryption
6
9
  class TokenStore
7
- attr_accessor :on_warning
10
+ attr_accessor :on_warning, :on_info, :on_prompt_pub_key
8
11
 
9
12
  def initialize(config: nil, encryption: nil, paths: nil)
10
13
  @config = config || Configuration.new
11
14
  @encryption = encryption || Encryption.new
12
15
  @paths = paths || Support::XdgPaths.new
16
+ @loader = TokenLoader.new(encryption: @encryption, paths: @paths)
17
+ @saver = TokenSaver.new(encryption: @encryption, paths: @paths)
13
18
  @on_warning = nil
19
+ @on_info = nil
20
+ @on_prompt_pub_key = nil
14
21
  end
15
22
 
16
23
  def workspace(name)
17
- tokens = load_tokens
24
+ tokens = @loader.load_auto(@config)
18
25
  data = tokens[name]
19
26
  raise WorkspaceNotFoundError, "Workspace '#{name}' not found" unless data
20
27
 
21
- Models::Workspace.new(
22
- name: name,
23
- token: data['token'],
24
- cookie: data['cookie']
25
- )
28
+ Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
26
29
  end
27
30
 
28
31
  def all_workspaces
29
- load_tokens.map do |name, data|
30
- Models::Workspace.new(
31
- name: name,
32
- token: data['token'],
33
- cookie: data['cookie']
34
- )
32
+ @loader.load_auto(@config).map do |name, data|
33
+ Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
35
34
  end
36
35
  end
37
36
 
38
37
  def workspace_names
39
- load_tokens.keys
38
+ @loader.load_auto(@config).keys
40
39
  end
41
40
 
42
41
  def exists?(name)
43
- load_tokens.key?(name)
42
+ @loader.load_auto(@config).key?(name)
44
43
  end
45
44
 
46
45
  def add(name, token, cookie = nil)
47
- # Validate by constructing a Workspace (will raise ArgumentError if invalid)
48
46
  Models::Workspace.new(name: name, token: token, cookie: cookie)
49
-
50
- tokens = load_tokens
47
+ tokens = @loader.load_auto(@config)
51
48
  tokens[name] = { 'token' => token, 'cookie' => cookie }.compact
52
- save_tokens(tokens)
49
+ @saver.save(tokens, @config.ssh_key)
53
50
  end
54
51
 
55
52
  def remove(name) # rubocop:disable Naming/PredicateMethod
56
- tokens = load_tokens
53
+ tokens = @loader.load_auto(@config)
57
54
  removed = tokens.delete(name)
58
- save_tokens(tokens) if removed
55
+ @saver.save(tokens, @config.ssh_key) if removed
59
56
  !removed.nil?
60
57
  end
61
58
 
62
59
  def empty?
63
- load_tokens.empty?
60
+ @loader.load_auto(@config).empty?
64
61
  end
65
62
 
66
- private
63
+ def migrate_encryption(old_ssh_key, new_ssh_key)
64
+ return if old_ssh_key == new_ssh_key
67
65
 
68
- def load_tokens
69
- if encrypted_file_exists?
70
- decrypt_tokens
71
- elsif plain_file_exists?
72
- JSON.parse(File.read(plain_tokens_file))
73
- else
74
- {}
75
- end
76
- rescue JSON::ParserError => e
77
- raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
66
+ tokens = @loader.load(old_ssh_key)
67
+ return if tokens.empty?
68
+
69
+ @encryption.on_prompt_pub_key = @on_prompt_pub_key
70
+ @encryption.validate_key_type!(new_ssh_key) if new_ssh_key
71
+ @saver.save_with_cleanup(tokens, new_ssh_key)
72
+ notify_encryption_change(old_ssh_key, new_ssh_key)
78
73
  end
79
74
 
80
- def save_tokens(tokens)
81
- @paths.ensure_config_dir
75
+ private
82
76
 
83
- if @config.ssh_key
84
- # When encryption is configured, always use it - don't silently fall back
85
- @encryption.encrypt(JSON.generate(tokens), @config.ssh_key, encrypted_tokens_file)
86
- FileUtils.rm_f(plain_tokens_file)
77
+ def notify_encryption_change(old_ssh_key, new_ssh_key)
78
+ if new_ssh_key && old_ssh_key
79
+ @on_info&.call('Tokens have been re-encrypted with the new SSH key.')
80
+ elsif new_ssh_key
81
+ @on_info&.call('Tokens have been encrypted with the new SSH key.')
87
82
  else
88
- # Plain text storage (no encryption configured)
89
- File.write(plain_tokens_file, JSON.pretty_generate(tokens))
90
- File.chmod(0o600, plain_tokens_file)
83
+ @on_warning&.call('Tokens are now stored in plaintext.')
91
84
  end
92
85
  end
93
-
94
- def decrypt_tokens
95
- content = @encryption.decrypt(encrypted_tokens_file, @config.ssh_key)
96
- content ? JSON.parse(content) : {}
97
- rescue JSON::ParserError => e
98
- raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
99
- end
100
-
101
- def encrypted_file_exists?
102
- File.exist?(encrypted_tokens_file)
103
- end
104
-
105
- def plain_file_exists?
106
- File.exist?(plain_tokens_file)
107
- end
108
-
109
- def encrypted_tokens_file
110
- @paths.config_file('tokens.age')
111
- end
112
-
113
- def plain_tokens_file
114
- @paths.config_file('tokens.json')
115
- end
116
86
  end
117
87
  end
118
88
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Consolidated service for user name resolution
6
+ # Provides ID → name and name → ID lookups with caching
7
+ class UserLookup
8
+ def initialize(cache_store:, workspace:, api_client: nil, on_debug: nil)
9
+ @cache = cache_store
10
+ @api = api_client
11
+ @workspace = workspace
12
+ @on_debug = on_debug
13
+ end
14
+
15
+ # Resolve user ID to display name (most common use case)
16
+ # @param user_id [String] Slack user ID (e.g., "U123ABC")
17
+ # @return [String, nil] Display name or nil if not found
18
+ def resolve_name(user_id)
19
+ return nil if user_id.to_s.empty?
20
+
21
+ cached = @cache.get_user(@workspace.name, user_id)
22
+ return cached if cached
23
+
24
+ fetch_and_cache_name(user_id)
25
+ end
26
+
27
+ # Resolve user ID to display name, handling bots
28
+ # @param user_id [String] Slack user or bot ID
29
+ # @return [String, nil] Display name or nil if not found
30
+ def resolve_name_or_bot(user_id)
31
+ return nil if user_id.to_s.empty?
32
+
33
+ cached = @cache.get_user(@workspace.name, user_id)
34
+ return cached if cached
35
+
36
+ if user_id.start_with?('B')
37
+ fetch_and_cache_bot_name(user_id)
38
+ else
39
+ fetch_and_cache_name(user_id)
40
+ end
41
+ end
42
+
43
+ # Find user ID by display name (reverse lookup)
44
+ # @param name [String] Display name to search for
45
+ # @return [String, nil] User ID or nil if not found
46
+ def find_id_by_name(name)
47
+ return nil if name.to_s.empty?
48
+
49
+ cached = @cache.get_user_id_by_name(@workspace.name, name)
50
+ return cached if cached
51
+
52
+ fetch_id_by_name(name)
53
+ end
54
+
55
+ private
56
+
57
+ def fetch_and_cache_name(user_id)
58
+ return nil unless @api
59
+
60
+ user = fetch_user(user_id)
61
+ return nil unless user
62
+
63
+ @cache.set_user(@workspace.name, user_id, user.best_name, persist: true)
64
+ user.best_name
65
+ rescue ApiError => e
66
+ @on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
67
+ nil
68
+ end
69
+
70
+ def fetch_and_cache_bot_name(bot_id)
71
+ return nil unless @api
72
+
73
+ bots_api = Api::Bots.new(@api, @workspace, on_debug: @on_debug)
74
+ name = bots_api.get_name(bot_id)
75
+ @cache.set_user(@workspace.name, bot_id, name, persist: true) if name
76
+ name
77
+ rescue ApiError => e
78
+ @on_debug&.call("Bot lookup failed for #{bot_id}: #{e.message}")
79
+ nil
80
+ end
81
+
82
+ def fetch_user(user_id)
83
+ users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
84
+ response = users_api.info(user_id)
85
+ return nil unless response['ok'] && response['user']
86
+
87
+ Models::User.from_api(response['user'])
88
+ end
89
+
90
+ def fetch_id_by_name(name)
91
+ return nil unless @api
92
+
93
+ users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
94
+ users = users_api.list['members'] || []
95
+ user = find_user_by_name(users, name)
96
+ cache_user_from_api(user) if user
97
+ user&.dig('id')
98
+ rescue ApiError => e
99
+ @on_debug&.call("User list lookup failed: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ def find_user_by_name(users, name)
104
+ users.find do |u|
105
+ u['name'] == name ||
106
+ u.dig('profile', 'display_name') == name ||
107
+ u.dig('profile', 'real_name') == name
108
+ end
109
+ end
110
+
111
+ def cache_user_from_api(user_data)
112
+ user = Models::User.from_api(user_data)
113
+ @cache.set_user(@workspace.name, user.id, user.best_name, persist: true)
114
+ end
115
+ end
116
+ end
117
+ end