teems 0.1.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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -0
  5. data/bin/teems +7 -0
  6. data/lib/teems/api/calendar.rb +94 -0
  7. data/lib/teems/api/channels.rb +26 -0
  8. data/lib/teems/api/chats.rb +29 -0
  9. data/lib/teems/api/client.rb +40 -0
  10. data/lib/teems/api/files.rb +12 -0
  11. data/lib/teems/api/messages.rb +58 -0
  12. data/lib/teems/api/users.rb +88 -0
  13. data/lib/teems/api/users_mailbox.rb +16 -0
  14. data/lib/teems/api/users_presence.rb +43 -0
  15. data/lib/teems/cli.rb +133 -0
  16. data/lib/teems/commands/activity.rb +222 -0
  17. data/lib/teems/commands/auth.rb +268 -0
  18. data/lib/teems/commands/base.rb +146 -0
  19. data/lib/teems/commands/cal.rb +891 -0
  20. data/lib/teems/commands/channels.rb +115 -0
  21. data/lib/teems/commands/chats.rb +159 -0
  22. data/lib/teems/commands/help.rb +107 -0
  23. data/lib/teems/commands/messages.rb +281 -0
  24. data/lib/teems/commands/ooo.rb +385 -0
  25. data/lib/teems/commands/org.rb +232 -0
  26. data/lib/teems/commands/status.rb +224 -0
  27. data/lib/teems/commands/sync.rb +390 -0
  28. data/lib/teems/commands/who.rb +377 -0
  29. data/lib/teems/formatters/calendar_formatter.rb +227 -0
  30. data/lib/teems/formatters/format_utils.rb +56 -0
  31. data/lib/teems/formatters/markdown_formatter.rb +113 -0
  32. data/lib/teems/formatters/message_formatter.rb +67 -0
  33. data/lib/teems/formatters/output.rb +105 -0
  34. data/lib/teems/models/account.rb +59 -0
  35. data/lib/teems/models/channel.rb +31 -0
  36. data/lib/teems/models/chat.rb +111 -0
  37. data/lib/teems/models/duration.rb +46 -0
  38. data/lib/teems/models/event.rb +124 -0
  39. data/lib/teems/models/message.rb +125 -0
  40. data/lib/teems/models/parsing.rb +56 -0
  41. data/lib/teems/models/user.rb +25 -0
  42. data/lib/teems/models/user_profile.rb +45 -0
  43. data/lib/teems/runner.rb +81 -0
  44. data/lib/teems/services/api_client.rb +217 -0
  45. data/lib/teems/services/cache_store.rb +32 -0
  46. data/lib/teems/services/configuration.rb +56 -0
  47. data/lib/teems/services/file_downloader.rb +39 -0
  48. data/lib/teems/services/headless_extract.rb +192 -0
  49. data/lib/teems/services/safari_oauth.rb +285 -0
  50. data/lib/teems/services/sync_dir_naming.rb +42 -0
  51. data/lib/teems/services/sync_engine.rb +194 -0
  52. data/lib/teems/services/sync_store.rb +193 -0
  53. data/lib/teems/services/teams_url_parser.rb +78 -0
  54. data/lib/teems/services/token_exchange_scripts.rb +56 -0
  55. data/lib/teems/services/token_extractor.rb +401 -0
  56. data/lib/teems/services/token_extractor_scripts.rb +116 -0
  57. data/lib/teems/services/token_refresher.rb +169 -0
  58. data/lib/teems/services/token_store.rb +116 -0
  59. data/lib/teems/support/error_logger.rb +35 -0
  60. data/lib/teems/support/help_formatter.rb +80 -0
  61. data/lib/teems/support/timezone.rb +44 -0
  62. data/lib/teems/support/xdg_paths.rb +62 -0
  63. data/lib/teems/version.rb +5 -0
  64. data/lib/teems.rb +117 -0
  65. data/support/token_helper.swift +485 -0
  66. metadata +110 -0
data/lib/teems/cli.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ # Verbose mode and logging helpers for CLI
5
+ module CLIVerbose
6
+ private
7
+
8
+ def verbose_mode? = @argv.include?('-v') || @argv.include?('--verbose')
9
+
10
+ def build_runner
11
+ out = verbose_mode? ? @output.with_verbose : @output
12
+ Runner.new(output: out).tap { |new_runner| setup_verbose_logging(new_runner, out) if out.verbose? }
13
+ end
14
+
15
+ def setup_verbose_logging(runner, out)
16
+ runner.api_client.on_request = lambda { |method, count|
17
+ out.debug("[API ##{count}] #{method}")
18
+ }
19
+ end
20
+
21
+ def execute_command(command_class, args, runner)
22
+ command = command_class.new(args, runner: runner)
23
+ result = command.execute
24
+ out = runner.output || @output
25
+ log_api_call_count(out, runner.api_client.call_count) if out.verbose?
26
+ result
27
+ end
28
+
29
+ def log_api_call_count(output, count)
30
+ output.debug("Total API calls: #{count}") if @output && count.positive?
31
+ end
32
+
33
+ def log_error(error) = Support::ErrorLogger.log(error)
34
+ end
35
+
36
+ # Command-line interface entry point that dispatches to commands
37
+ class CLI
38
+ include CLIVerbose
39
+
40
+ ERROR_LABELS = { AuthError => 'Auth error', ApiError => 'API error' }.freeze
41
+
42
+ COMMANDS = {
43
+ 'activity' => Commands::Activity,
44
+ 'auth' => Commands::Auth,
45
+ 'cal' => Commands::Cal,
46
+ 'channels' => Commands::Channels,
47
+ 'chats' => Commands::Chats,
48
+ 'messages' => Commands::Messages,
49
+ 'sync' => Commands::Sync,
50
+ 'who' => Commands::Who,
51
+ 'org' => Commands::Org,
52
+ 'ooo' => Commands::Ooo,
53
+ 'status' => Commands::Status,
54
+ 'help' => Commands::Help
55
+ }.freeze
56
+
57
+ def initialize(argv, output: Formatters::Output.new)
58
+ @argv = argv.dup
59
+ @output = output
60
+ end
61
+
62
+ def run
63
+ command_name, *args = @argv
64
+ route_command(command_name, args)
65
+ rescue Interrupt
66
+ handle_interrupt
67
+ rescue StandardError => e
68
+ handle_error(e)
69
+ end
70
+
71
+ def route_command(command_name, args)
72
+ return show_help if help_requested?
73
+ return show_version if version_requested?
74
+
75
+ dispatch_command(command_name, args)
76
+ end
77
+
78
+ private
79
+
80
+ def help_requested? = @argv.empty? || ['-h', '--help'].include?(@argv.first)
81
+ def version_requested? = ['--version', '-V', 'version'].include?(@argv.first)
82
+
83
+ def show_help = run_command('help', [])
84
+
85
+ def show_version
86
+ @output.puts "teems v#{VERSION}"
87
+ 0
88
+ end
89
+
90
+ def dispatch_command(command_name, args)
91
+ COMMANDS[command_name] ? run_command(command_name, args) : show_unknown_command(command_name)
92
+ rescue ConfigError, AuthError, ApiError => e
93
+ handle_known_error(e)
94
+ end
95
+
96
+ def show_unknown_command(command_name)
97
+ @output.error("Unknown command: #{command_name}")
98
+ @output.puts
99
+ @output.puts "Run 'teems help' for available commands."
100
+ 1
101
+ end
102
+
103
+ def handle_known_error(err)
104
+ label = ERROR_LABELS[err.class]
105
+ @output.error([label, err.message].compact.join(': '))
106
+ log_error(err)
107
+ 1
108
+ end
109
+
110
+ def handle_interrupt
111
+ @output.puts
112
+ @output.puts 'Interrupted.'
113
+ 130
114
+ end
115
+
116
+ def handle_error(error)
117
+ @output.error("Unexpected error: #{error.message}")
118
+ log_path = log_error(error)
119
+ @output.puts "Details logged to: #{log_path}" if log_path
120
+ 1
121
+ end
122
+
123
+ def run_command(name, args)
124
+ command_class = COMMANDS[name]
125
+ return 1 unless command_class
126
+
127
+ runner = build_runner
128
+ execute_command(command_class, args, runner)
129
+ ensure
130
+ runner&.api_client&.close
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # Format activity feed notifications for display
6
+ module ActivityFormatting
7
+ ACTIVITY_DESCRIPTIONS = {
8
+ %w[msGraph privateMeetingCanceled] => 'canceled',
9
+ %w[msGraph privateMeetingCreated] => 'invited you',
10
+ %w[msGraph privateMeetingUpdated] => 'updated',
11
+ %w[msGraph privateMeetingForwarded] => 'forwarded you',
12
+ %w[msGraph delegateMeetingCreated] => 'invited you',
13
+ %w[msGraph delegateMeetingUpdated] => 'updated',
14
+ %w[mentionInChat person] => 'mentioned you',
15
+ %w[mentionInChat everyone] => 'mentioned Everyone',
16
+ %w[mention channel] => 'mentioned channel',
17
+ %w[reactionInChat] => 'reacted'
18
+ }.freeze
19
+
20
+ START_DATETIME_PATTERN = %r{<StartDateTime>(.+?)</StartDateTime>}
21
+ END_DATETIME_PATTERN = %r{<EndDateTime>(.+?)</EndDateTime>}
22
+ MAX_PREVIEW = 120
23
+
24
+ private
25
+
26
+ def format_activity(activity, composetime)
27
+ [format_activity_header(activity, composetime),
28
+ format_preview(activity),
29
+ format_meeting_time(activity),
30
+ format_activity_source(activity)].compact.join("\n")
31
+ end
32
+
33
+ def format_activity_header(activity, composetime)
34
+ who = activity['sourceUserImDisplayName'] || 'Unknown'
35
+ behalf = parse_behalf(activity)
36
+ name = behalf ? "#{who} on behalf of #{behalf}" : who
37
+ time_str = Formatters::FormatUtils.format_time(composetime)
38
+ "#{output.blue(time_str)} #{output.bold(name)} #{describe_action(activity)}"
39
+ end
40
+
41
+ def format_preview(activity)
42
+ text = activity['messagePreview'].to_s.strip.gsub(/[\r\n]+/, ' ')
43
+ text.empty? ? nil : " #{Formatters::FormatUtils.truncate(text, MAX_PREVIEW)}"
44
+ end
45
+
46
+ def format_activity_source(activity)
47
+ topic = activity['sourceThreadTopic']
48
+ topic ? " #{output.gray(topic)}" : nil
49
+ end
50
+
51
+ def describe_action(activity)
52
+ desc = resolve_activity_description(activity['activityType'], activity['activitySubtype'])
53
+ output.yellow(desc)
54
+ end
55
+
56
+ def resolve_activity_description(type, subtype)
57
+ ACTIVITY_DESCRIPTIONS[[type, subtype]] || ACTIVITY_DESCRIPTIONS[[type]] || subtype || type
58
+ end
59
+
60
+ def format_meeting_time(activity)
61
+ return nil unless activity['activityType'] == 'msGraph'
62
+
63
+ location = activity.dig('activityContext', 'location')
64
+ parsed = location && parse_meeting_range(location)
65
+ " #{format_time_range(*parsed)}" if parsed
66
+ end
67
+
68
+ def parse_meeting_range(location)
69
+ start_match, end_match = extract_datetime_matches(location)
70
+ return nil unless start_match
71
+
72
+ Formatters::FormatUtils.build_time_range(start_match[1], end_match)
73
+ rescue ArgumentError
74
+ nil
75
+ end
76
+
77
+ def extract_datetime_matches(text)
78
+ [text.match(START_DATETIME_PATTERN),
79
+ text.match(END_DATETIME_PATTERN)]
80
+ end
81
+
82
+ def format_time_range(start_time, end_time)
83
+ start_str = start_time.strftime('%b %-d, %-I:%M %p')
84
+ return start_str unless end_time
85
+
86
+ "#{start_str} - #{Formatters::FormatUtils.format_end_time(start_time, end_time)}"
87
+ end
88
+
89
+ def parse_behalf(activity)
90
+ params = activity.dig('activityContext', 'templateParameters')
91
+ return nil unless params.is_a?(String)
92
+
93
+ JSON.parse(params)['behalfOf']
94
+ rescue JSON::ParserError
95
+ nil
96
+ end
97
+ end
98
+
99
+ # Show activity feed (mentions, reactions, calendar notifications)
100
+ class Activity < Base
101
+ include ActivityFormatting
102
+
103
+ NOTIFICATION_THREAD = '48:notifications'
104
+
105
+ ACTIVITY_OPTIONS = {
106
+ '--unread' => ->(opts, _pending) { opts[:unread] = true }
107
+ }.freeze
108
+
109
+ def initialize(args, runner:)
110
+ @options = {}
111
+ super
112
+ end
113
+
114
+ def execute
115
+ result = validate_options
116
+ return result if result
117
+
118
+ auth_result = require_auth
119
+ return auth_result if auth_result
120
+
121
+ show_activity
122
+ end
123
+
124
+ protected
125
+
126
+ def handle_option(arg, pending)
127
+ handler = ACTIVITY_OPTIONS[arg]
128
+ return super unless handler
129
+
130
+ handler.call(@options, pending)
131
+ end
132
+
133
+ def help_text
134
+ <<~HELP
135
+ #{output.bold('teems activity')} - Show activity feed
136
+
137
+ #{output.bold('USAGE:')}
138
+ teems activity [options]
139
+
140
+ #{output.bold('OPTIONS:')}
141
+ -n, --limit N Number of activities to show (default: 20)
142
+ --unread Show only unread activities
143
+ -v, --verbose Show debug output
144
+ -q, --quiet Suppress output
145
+ --json Output as JSON
146
+
147
+ #{output.bold('EXAMPLES:')}
148
+ teems activity # Show recent activity
149
+ teems activity --unread # Show only unread
150
+ teems activity -n 50 # Show 50 activities
151
+ HELP
152
+ end
153
+
154
+ private
155
+
156
+ def show_activity
157
+ render_activities(filtered_activities)
158
+ 0
159
+ rescue ApiError => e
160
+ activity_fetch_error(e)
161
+ end
162
+
163
+ def filtered_activities
164
+ items = sorted_activities
165
+ @options[:unread] ? items.select { |item| item[:unread] } : items
166
+ end
167
+
168
+ def sorted_activities = fetch_and_parse.sort_by { |item| item[:time] || '' }.reverse
169
+
170
+ def activity_fetch_error(err)
171
+ error("Failed to fetch activity: #{err.message}")
172
+ 1
173
+ end
174
+
175
+ def fetch_and_parse
176
+ response = with_token_refresh do
177
+ runner.messages_api.chat_messages(chat_id: NOTIFICATION_THREAD, limit: @options[:limit])
178
+ end
179
+ (response['messages'] || []).filter_map { |msg| parse_activity(msg) }
180
+ end
181
+
182
+ def render_activities(items)
183
+ if items.empty?
184
+ puts 'No activity found'
185
+ elsif @options[:json]
186
+ output_json(items.map { |item| item.except(:raw_activity) })
187
+ else
188
+ display_activities(items)
189
+ end
190
+ end
191
+
192
+ def parse_activity(msg)
193
+ activity = msg.dig('properties', 'activity')
194
+ return nil unless activity.is_a?(Hash)
195
+
196
+ { type: activity['activityType'], subtype: activity['activitySubtype'],
197
+ who: activity['sourceUserImDisplayName'], preview: activity['messagePreview'],
198
+ where: activity['sourceThreadTopic'],
199
+ time: activity['activityTimestamp'] || msg['composetime'],
200
+ unread: msg.dig('properties', 'isread')&.to_s != 'true',
201
+ raw_activity: activity }
202
+ end
203
+
204
+ def display_activities(items)
205
+ items.each { |item| display_single_activity(item) }
206
+ end
207
+
208
+ def display_single_activity(item)
209
+ marker = item[:unread] ? output.bold('* ') : ' '
210
+ print_activity_lines(marker, format_activity(item[:raw_activity], item[:time]))
211
+ end
212
+
213
+ def print_activity_lines(marker, text)
214
+ text.lines.each_with_index do |line, idx|
215
+ trimmed = line.chomp
216
+ puts idx.zero? ? "#{marker}#{trimmed}" : " #{trimmed}"
217
+ end
218
+ puts
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ AUTH_HELP = <<~HELP
6
+ teems auth - Manage Teams authentication
7
+
8
+ USAGE:
9
+ teems auth [action]
10
+
11
+ ACTIONS:
12
+ login Authenticate via Safari (opens browser)
13
+ logout Remove stored tokens
14
+ status Show current authentication status
15
+ manual Show manual token extraction instructions
16
+ set-tokens Manually enter tokens from browser
17
+
18
+ OPTIONS:
19
+ --certauth Use certificate authentication (requires VPN)
20
+
21
+ EXAMPLES:
22
+ teems auth login # Authenticate (headless or Safari OAuth)
23
+ teems auth login --certauth # Use cert auth (on VPN)
24
+ teems auth status # Check if authenticated
25
+ teems auth logout # Clear stored tokens
26
+ HELP
27
+
28
+ # Token input methods for manual token entry and file import
29
+ module AuthTokenInput
30
+ def set_tokens
31
+ file_path = positional_args[1]
32
+ return import_tokens_from_file(file_path) if file_path
33
+
34
+ prompt_and_save_tokens
35
+ end
36
+
37
+ private
38
+
39
+ def prompt_and_save_tokens
40
+ print_token_prompt
41
+ auth_token = prompt_for_token('Auth token (from Authorization: Bearer header or authtoken cookie)')
42
+ return error('Auth token is required') if auth_token.to_s.empty?
43
+
44
+ save_extracted_tokens(auth_token, prompt_for_skype_token)
45
+ end
46
+
47
+ def print_token_prompt
48
+ puts 'Enter your Teams tokens.'
49
+ puts '(Tokens are long - you can also use: teems auth set-tokens <file>)'
50
+ puts
51
+ end
52
+
53
+ def prompt_for_skype_token
54
+ puts
55
+ puts 'Skype token is optional (needed for some chat APIs).'
56
+ puts 'Press Enter twice to skip, or paste the skypetoken_asm cookie value:'
57
+ prompt_for_token('Skype token (optional)')
58
+ end
59
+
60
+ def prompt_for_token(label)
61
+ puts "#{label}:\n(paste token, then press Enter twice or Ctrl-D)"
62
+ lines = []
63
+ while (input = $stdin.gets)
64
+ break if input.strip.empty?
65
+
66
+ lines << input.chomp
67
+ end
68
+ clean_token(lines.join)
69
+ end
70
+
71
+ def clean_token(raw)
72
+ stripped = raw.sub(/^Bearer\s+/i, '').sub(/^skypetoken=/i, '').strip
73
+ debug("Cleaned token (#{stripped.length} chars)")
74
+ stripped
75
+ end
76
+
77
+ def import_tokens_from_file(file_path)
78
+ return error("File not found: #{file_path}") unless File.exist?(file_path)
79
+
80
+ read_and_save_token_file(file_path)
81
+ rescue Errno::EACCES => e
82
+ error("Cannot read file: #{e.message}")
83
+ rescue Errno::EISDIR
84
+ error("Path is a directory, not a file: #{file_path}")
85
+ end
86
+
87
+ def read_and_save_token_file(file_path)
88
+ data = parse_token_file(file_path)
89
+ return data if data.is_a?(Integer)
90
+
91
+ validate_and_save_tokens(data)
92
+ end
93
+
94
+ def validate_and_save_tokens(data)
95
+ auth_token, skype_token, chatsvc = data.values_at('auth_token', 'skype_token', 'chatsvc_token')
96
+ auth_token ||= data['authtoken']
97
+ return error('File must contain auth_token key') unless auth_token
98
+
99
+ debug('Found auth token in file data')
100
+ save_extracted_tokens(auth_token, skype_token || data['skypetoken'] || auth_token, chatsvc)
101
+ end
102
+
103
+ def parse_token_file(file_path)
104
+ JSON.parse(File.read(file_path))
105
+ rescue JSON::ParserError
106
+ error('Invalid JSON file. Expected: {"auth_token": "..."}')
107
+ end
108
+
109
+ def save_extracted_tokens(auth_token, skype_token, chatsvc_token = nil)
110
+ token_store.save(
111
+ name: 'default', auth_token: clean_token(auth_token),
112
+ skype_token: clean_token(skype_token), chatsvc_token: chatsvc_token
113
+ )
114
+ success('Tokens saved successfully!')
115
+ 0
116
+ end
117
+ end
118
+
119
+ AUTH_ACTIONS = {
120
+ 'login' => :login, 'logout' => :logout, 'clear' => :logout,
121
+ 'status' => :status, 'manual' => :show_manual_instructions,
122
+ 'set-tokens' => :set_tokens, 'set' => :set_tokens
123
+ }.freeze
124
+
125
+ # Status display helpers for auth command
126
+ module AuthStatus
127
+ private
128
+
129
+ def status
130
+ return display_unauthenticated_status unless token_store.configured?
131
+
132
+ display_authenticated_status
133
+ end
134
+
135
+ def display_authenticated_status
136
+ account = token_store.account
137
+ return display_incomplete_tokens unless account
138
+
139
+ puts "#{output.green("\u2713")} Authenticated as: #{account.name}"
140
+ display_token_age
141
+ 0
142
+ end
143
+
144
+ def display_incomplete_tokens
145
+ puts "#{output.yellow("\u26A0")} Token file exists but is incomplete"
146
+ puts 'Run: teems auth login'
147
+ 0
148
+ end
149
+
150
+ def display_token_age
151
+ age = token_store.token_age
152
+ return unless age
153
+
154
+ hours = (age / 3600).to_i
155
+ if hours >= 24
156
+ puts "#{output.yellow("\u26A0")} Tokens are #{hours} hours old (may be expired)"
157
+ else
158
+ puts " Token age: #{hours} hours"
159
+ end
160
+ end
161
+
162
+ def display_unauthenticated_status
163
+ puts "#{output.red("\u2717")} Not authenticated\n\nRun: teems auth login"
164
+ 0
165
+ end
166
+ end
167
+
168
+ # Manages authentication with Microsoft Teams
169
+ class Auth < Base
170
+ include AuthTokenInput
171
+ include AuthStatus
172
+
173
+ AUTH_OPTIONS = {
174
+ '--certauth' => ->(opts, _args) { opts[:certauth] = true }
175
+ }.freeze
176
+
177
+ def initialize(args, runner:)
178
+ @options = {}
179
+ super
180
+ end
181
+
182
+ def execute
183
+ result = validate_options
184
+ return result if result
185
+
186
+ dispatch_action(positional_args.first)
187
+ end
188
+
189
+ protected
190
+
191
+ def handle_option(arg, pending)
192
+ handler = AUTH_OPTIONS[arg]
193
+ return super unless handler
194
+
195
+ handler.call(@options, pending)
196
+ end
197
+
198
+ def help_text = AUTH_HELP
199
+
200
+ private
201
+
202
+ def dispatch_action(action)
203
+ method_name = AUTH_ACTIONS[action || 'status']
204
+ return unknown_action(action) unless method_name
205
+
206
+ send(method_name)
207
+ end
208
+
209
+ def unknown_action(action)
210
+ error("Unknown auth action: #{action}")
211
+ puts 'Available actions: login, logout, status, manual, set-tokens'
212
+ 1
213
+ end
214
+
215
+ def login
216
+ print_login_banner
217
+ mode = @options[:certauth] ? :certauth : :default
218
+ tokens = runner.token_extractor(auth_mode: mode).extract
219
+ handle_login_result(tokens)
220
+ end
221
+
222
+ def print_login_banner
223
+ puts 'Starting Teams authentication...'
224
+ puts 'Safari will open to teams.microsoft.com'
225
+ puts 'Please complete the login process'
226
+ puts
227
+ end
228
+
229
+ def handle_login_result(tokens)
230
+ if tokens && tokens[:auth_token] && tokens[:skype_token]
231
+ save_tokens(tokens)
232
+ success('Authentication successful!')
233
+ 0
234
+ else
235
+ suggest_manual_auth
236
+ end
237
+ end
238
+
239
+ def suggest_manual_auth
240
+ error('Failed to extract tokens automatically')
241
+ puts "\nTry manual extraction instead:\n teems auth manual"
242
+ 1
243
+ end
244
+
245
+ def save_tokens(tokens)
246
+ token_store.save(name: 'default', **tokens.slice(:auth_token, :skype_token,
247
+ :skype_spaces_token, :chatsvc_token,
248
+ :refresh_token, :client_id, :tenant_id))
249
+ end
250
+
251
+ def logout
252
+ if token_store.configured?
253
+ token_store.clear
254
+ success('Tokens cleared')
255
+ else
256
+ puts 'No tokens to clear'
257
+ end
258
+ 0
259
+ end
260
+
261
+ def show_manual_instructions
262
+ puts runner.token_extractor.manual_instructions
263
+ puts "\nOnce you have the tokens, you can set them manually:\n teems auth set-tokens"
264
+ 0
265
+ end
266
+ end
267
+ end
268
+ end