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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- 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
|