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
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # List joined teams and their channels
6
+ class Channels < Base
7
+ def initialize(args, runner:)
8
+ @options = {}
9
+ super
10
+ end
11
+
12
+ def execute
13
+ result = validate_options
14
+ return result if result
15
+
16
+ auth_result = require_auth
17
+ return auth_result if auth_result
18
+
19
+ list_teams_and_channels
20
+ end
21
+
22
+ protected
23
+
24
+ def help_text
25
+ <<~HELP
26
+ #{output.bold('teems channels')} - List teams and channels
27
+
28
+ #{output.bold('USAGE:')}
29
+ teems channels [options]
30
+
31
+ #{output.bold('OPTIONS:')}
32
+ -v, --verbose Show debug output
33
+ -q, --quiet Suppress output
34
+ --json Output as JSON
35
+
36
+ #{output.bold('EXAMPLES:')}
37
+ teems channels # List all teams and channels
38
+ teems channels --json # Output as JSON
39
+ HELP
40
+ end
41
+
42
+ private
43
+
44
+ def list_teams_and_channels
45
+ display_teams_list(fetch_teams)
46
+ rescue ApiError => e
47
+ teams_fetch_error(e)
48
+ end
49
+
50
+ def display_teams_list(teams)
51
+ teams.empty? ? puts('No teams found') : render_teams(teams)
52
+ 0
53
+ end
54
+
55
+ def teams_fetch_error(err)
56
+ error("Failed to fetch teams: #{err.message}")
57
+ 1
58
+ end
59
+
60
+ def fetch_teams
61
+ response = runner.channels_api.list_teams
62
+ response['value'] || []
63
+ end
64
+
65
+ def render_teams(teams)
66
+ if @options[:json]
67
+ output_json(build_json_output(teams))
68
+ else
69
+ display_teams(teams)
70
+ end
71
+ end
72
+
73
+ def display_teams(teams)
74
+ api = runner.channels_api
75
+ teams.each do |team_data|
76
+ puts output.bold(team_data['displayName'])
77
+ display_team_channels(api, team_data)
78
+ puts
79
+ end
80
+ end
81
+
82
+ def display_team_channels(api, team_data)
83
+ channels = api.list_channels(team_id: team_data['id'])['value'] || []
84
+ channels.each { |channel_data| display_channel(channel_data, team_data) }
85
+ rescue ApiError => e
86
+ puts " #{output.red('Error:')} #{e.message}"
87
+ end
88
+
89
+ def display_channel(channel_data, team_data)
90
+ channel = Models::Channel.from_api(channel_data, team_id: team_data['id'],
91
+ team_name: team_data['displayName'])
92
+ puts " #{channel_prefix(channel)} #{channel.name} (#{channel.id})"
93
+ end
94
+
95
+ def channel_prefix(channel) = channel.private? ? output.yellow('🔒') : ' '
96
+
97
+ def build_json_output(teams)
98
+ api = runner.channels_api
99
+ teams.map { |team| team_to_hash(api, team) }
100
+ end
101
+
102
+ def team_to_hash(api, team_data)
103
+ team_id = team_data['id']
104
+ channels_response = api.list_channels(team_id: team_id)
105
+ {
106
+ id: team_id,
107
+ name: team_data['displayName'],
108
+ channels: (channels_response['value'] || []).map do |channel|
109
+ { id: channel['id'], name: channel['displayName'], membership_type: channel['membershipType'] }
110
+ end
111
+ }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # Parsing and filtering logic for chats command
6
+ module ChatsParsing
7
+ private
8
+
9
+ def render_chats(chats)
10
+ filtered = apply_filters(parse_all_chats(chats))
11
+ @options[:json] ? output_json(filtered.map { |chat| chat_to_hash(chat) }) : display_chats(filtered)
12
+ end
13
+
14
+ def parse_all_chats(chats)
15
+ space_names = build_space_names(chats)
16
+ chats.filter_map { |chat_data| parse_chat(chat_data, space_names) }
17
+ end
18
+
19
+ def build_space_names(chats)
20
+ chats.each_with_object({}) do |chat_data, map|
21
+ tp = chat_data['threadProperties'] || {}
22
+ map[chat_data['id']] = tp['spaceThreadTopic'] if tp['threadType'] == 'space'
23
+ end
24
+ end
25
+
26
+ def parse_chat(chat_data, space_names)
27
+ return nil if chat_data['id'] == '48:notifications'
28
+
29
+ chat = Models::Chat.from_api(chat_data)
30
+ resolve_channel_name(chat, chat_data, space_names)
31
+ end
32
+
33
+ def resolve_channel_name(chat, chat_data, space_names)
34
+ return chat unless chat.channel?
35
+
36
+ team_name = space_names[chat_data.dig('threadProperties', 'spaceId')]
37
+ team_name ? chat.with(topic: "#{team_name} -> #{chat.topic}") : chat
38
+ end
39
+
40
+ def apply_filters(chats)
41
+ chats = chats.select(&:unread?) if @options[:unread]
42
+ chats = chats.select(&:favorite?) if @options[:favorites]
43
+ chats = chats.select(&:pinned?) if @options[:pinned]
44
+ chats
45
+ end
46
+
47
+ def chat_to_hash(chat)
48
+ { id: chat.id, topic: chat.topic, chat_type: chat.chat_type,
49
+ last_updated: chat.last_updated&.iso8601,
50
+ unread: chat.unread?, favorite: chat.favorite?, pinned: chat.pinned? }
51
+ end
52
+ end
53
+
54
+ # Display helpers for chats command
55
+ module ChatsDisplay
56
+ CHAT_TYPE_ICONS = {
57
+ 'oneOnOne' => "\u{1F464}", 'group' => "\u{1F465}", 'meeting' => "\u{1F4C5}"
58
+ }.freeze
59
+
60
+ private
61
+
62
+ def display_chats(chats)
63
+ return puts('No chats found') if chats.empty?
64
+
65
+ chats.each { |chat| display_single_chat(chat) }
66
+ end
67
+
68
+ def display_single_chat(chat)
69
+ marker = chat.unread? ? output.bold('* ') : ' '
70
+ icon = CHAT_TYPE_ICONS.fetch(chat.chat_type, "\u{1F4AC}")
71
+ puts "#{marker}#{icon} #{output.bold(chat.display_name)}"
72
+ print_chat_details(chat)
73
+ end
74
+
75
+ def print_chat_details(chat)
76
+ puts " ID: #{chat.id}"
77
+ time_str = chat.last_updated&.strftime('%Y-%m-%d %H:%M')
78
+ puts " Last updated: #{time_str}" if time_str
79
+ puts
80
+ end
81
+ end
82
+
83
+ # List recent chats
84
+ class Chats < Base
85
+ include ChatsParsing
86
+ include ChatsDisplay
87
+
88
+ def initialize(args, runner:)
89
+ @options = {}
90
+ super
91
+ end
92
+
93
+ def execute
94
+ result = validate_options
95
+ return result if result
96
+
97
+ auth_result = require_auth
98
+ return auth_result if auth_result
99
+
100
+ list_chats
101
+ end
102
+
103
+ protected
104
+
105
+ CHATS_OPTIONS = {
106
+ '--unread' => ->(opts, _pending) { opts[:unread] = true },
107
+ '--favorites' => ->(opts, _pending) { opts[:favorites] = true },
108
+ '--pinned' => ->(opts, _pending) { opts[:pinned] = true }
109
+ }.freeze
110
+
111
+ def handle_option(arg, pending)
112
+ handler = CHATS_OPTIONS[arg]
113
+ return super unless handler
114
+
115
+ handler.call(@options, pending)
116
+ end
117
+
118
+ def help_text
119
+ <<~HELP
120
+ #{output.bold('teems chats')} - List recent chats
121
+
122
+ #{output.bold('USAGE:')}
123
+ teems chats [options]
124
+
125
+ #{output.bold('OPTIONS:')}
126
+ -n, --limit N Number of chats to show (default: 20)
127
+ --unread Show only unread chats
128
+ --favorites Show only favorite chats
129
+ --pinned Show only pinned chats
130
+ -v, --verbose Show debug output
131
+ -q, --quiet Suppress output
132
+ --json Output as JSON
133
+
134
+ #{output.bold('EXAMPLES:')}
135
+ teems chats # List recent chats
136
+ teems chats --unread # Show only unread chats
137
+ teems chats --favorites # Show only favorites
138
+ teems chats -n 50 # Show 50 chats
139
+ teems chats --json # Output as JSON
140
+ HELP
141
+ end
142
+
143
+ private
144
+
145
+ def list_chats
146
+ render_chats(fetch_chats)
147
+ 0
148
+ rescue ApiError => e
149
+ error("Failed to fetch chats: #{e.message}")
150
+ 1
151
+ end
152
+
153
+ def fetch_chats
154
+ response = with_token_refresh { runner.chats_api.list(limit: @options[:limit]) }
155
+ response['conversations'] || response['value'] || []
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ COMMAND_DESCRIPTIONS = [
6
+ ['activity', 'Show activity feed (mentions, reactions, calendar)'],
7
+ ['auth', 'Authenticate with Teams'],
8
+ ['cal', 'List calendar events and view details'],
9
+ ['channels', 'List joined teams and channels'],
10
+ ['chats', 'List recent chats'],
11
+ ['messages', 'Read messages from a channel or chat'],
12
+ ['sync', 'Sync chat history locally'],
13
+ ['who', "Look up a user's profile"],
14
+ ['org', 'Show org chart for a user'],
15
+ ['ooo', 'Manage out-of-office status'],
16
+ ['status', 'View and manage your presence status']
17
+ ].freeze
18
+
19
+ # Displays help information for commands
20
+ class Help < Base
21
+ def execute
22
+ topic = positional_args.first
23
+
24
+ if topic
25
+ show_command_help(topic)
26
+ else
27
+ show_general_help
28
+ end
29
+
30
+ 0
31
+ end
32
+
33
+ private
34
+
35
+ def show_general_help
36
+ puts build_header
37
+ puts build_commands_section
38
+ puts build_options_section
39
+ puts build_examples_section
40
+ puts "Run #{output.cyan('teems <command> --help')} for command-specific help."
41
+ end
42
+
43
+ def build_header
44
+ <<~HEADER
45
+ #{output.bold('teems')} - Microsoft Teams CLI v#{VERSION}
46
+
47
+ #{output.bold('USAGE:')}
48
+ teems <command> [options]
49
+ HEADER
50
+ end
51
+
52
+ def build_commands_section
53
+ lines = command_descriptions.map { |name, desc| " #{output.cyan(name.ljust(12))} #{desc}" }
54
+ "#{output.bold('COMMANDS:')}\n#{lines.join("\n")}\n\n"
55
+ end
56
+
57
+ def command_descriptions = COMMAND_DESCRIPTIONS
58
+
59
+ def build_options_section
60
+ <<~OPTIONS
61
+ #{output.bold('GLOBAL OPTIONS:')}
62
+ -n, --limit N Number of items to show (default: 20)
63
+ -v, --verbose Show debug output
64
+ -q, --quiet Suppress output
65
+ --json Output as JSON (where supported)
66
+ -h, --help Show help
67
+ OPTIONS
68
+ end
69
+
70
+ def build_examples_section
71
+ <<~EXAMPLES
72
+ #{output.bold('EXAMPLES:')}
73
+ teems auth login Authenticate via Safari
74
+ teems auth status Show authentication status
75
+ teems cal List today's calendar events
76
+ teems cal tomorrow Show tomorrow's events
77
+ teems cal --week Show this week's events
78
+ teems cal show 3 View details for event #3
79
+ teems cal accept 3 Accept event #3
80
+ teems cal create "Standup" --start "tomorrow 09:00"
81
+ teems cal delete 3 Delete event #3
82
+ teems channels List all channels
83
+ teems chats List recent chats
84
+ teems messages <channel-id> Read messages from a channel
85
+ teems who Show your profile
86
+ teems who john Search for a user
87
+ teems org Show your org chart
88
+ teems org john --depth 1 Org chart for "john"
89
+ EXAMPLES
90
+ end
91
+
92
+ def show_command_help(topic)
93
+ command_class = CLI::COMMANDS[topic]
94
+ command_class ? execute_help_for(command_class) : unknown_command_help(topic)
95
+ end
96
+
97
+ def execute_help_for(command_class)
98
+ command_class.new(['--help'], runner: Runner.new(output: output)).execute
99
+ end
100
+
101
+ def unknown_command_help(topic)
102
+ error("Unknown command: #{topic}")
103
+ puts "\nAvailable commands: #{CLI::COMMANDS.keys.join(', ')}"
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ MESSAGES_HELP = <<~HELP
6
+ teems messages - Read messages from a channel or chat
7
+
8
+ USAGE:
9
+ teems messages <target> [options]
10
+
11
+ ARGUMENTS:
12
+ target Chat ID, channel ID, or Teams message URL
13
+
14
+ OPTIONS:
15
+ -t, --team ID Team ID (required for channel messages)
16
+ -n, --limit N Number of messages (default: 20)
17
+ --download Download file attachments
18
+ -o, --output-dir Directory for downloads (default: ~/.local/share/teems/downloads)
19
+ -v, --verbose Show debug output
20
+ -q, --quiet Suppress output
21
+ --json Output as JSON
22
+
23
+ EXAMPLES:
24
+ teems messages 19:abc123@thread.v2 # Read chat messages
25
+ teems messages 19:abc123@thread.v2 -n 50 # Show 50 messages
26
+ teems messages "https://teams.microsoft.com/l/message/19:abc@thread.v2/123?context=..."
27
+ HELP
28
+
29
+ # Display formatting for messages command
30
+ module MessagesDisplay
31
+ REACTION_EMOJI = {
32
+ 'like' => "\u{1F44D}", 'heart' => "\u{2764}\u{FE0F}",
33
+ 'laugh' => "\u{1F602}", 'surprised' => "\u{1F62E}",
34
+ 'sad' => "\u{1F622}", 'angry' => "\u{1F620}",
35
+ 'yes-tone1' => "\u{1F44D}\u{1F3FB}", 'yes-tone2' => "\u{1F44D}\u{1F3FC}",
36
+ 'support' => "\u{1F91D}", 'heartblue' => "\u{1F499}",
37
+ 'computer' => "\u{1F4BB}", '1f37f_popcorn' => "\u{1F37F}",
38
+ '1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
39
+ }.freeze
40
+
41
+ private
42
+
43
+ def display_message(message)
44
+ puts format_message_header(message)
45
+ puts " #{formatted_content(message)}"
46
+ display_attachments(message)
47
+ display_reactions(message)
48
+ puts
49
+ end
50
+
51
+ def format_message_header(message)
52
+ importance = message.important? ? output.red('!') : ''
53
+ time = output.blue("[#{message.created_at&.strftime('%Y-%m-%d %H:%M')}]")
54
+ hash = output.gray(message.short_hash)
55
+ "#{time} #{hash} #{importance}#{output.bold(message.sender_name)}:#{edited_tag(message)}"
56
+ end
57
+
58
+ def edited_tag(message) = message.edited? ? " #{output.gray('(edited)')}" : ''
59
+
60
+ def display_attachments(message)
61
+ attachments = message.attachments
62
+ return unless attachments.any?
63
+
64
+ names = attachments.map { |att| Formatters::FormatUtils.attachment_name(att) }
65
+ puts " #{output.gray("\u{1F4CE} #{names.join(', ')}")}"
66
+ end
67
+
68
+ def display_reactions(message)
69
+ reactions = message.reactions
70
+ return unless reactions.any?
71
+
72
+ parts = reactions.map { |reaction| Formatters::FormatUtils.format_single_reaction(reaction, REACTION_EMOJI) }
73
+ puts " #{output.gray(parts.join(' '))}"
74
+ end
75
+
76
+ def formatted_content(message)
77
+ message.content_with_mentions_highlighted { |name| output.bold(name) }
78
+ end
79
+
80
+ def message_to_hash(message)
81
+ { id: message.id, short_hash: message.short_hash,
82
+ sender_id: message.sender_id,
83
+ sender_name: message.sender_name, content: message.content,
84
+ created_at: message.created_at&.iso8601,
85
+ importance: message.importance, reactions: message.reactions,
86
+ attachments: message.attachments, edited: message.edited,
87
+ mentions: message.mentions }
88
+ end
89
+ end
90
+
91
+ # Download logic for file attachments in messages
92
+ module AttachmentDownload
93
+ private
94
+
95
+ def download_attachments(messages)
96
+ attachments = find_downloadable(messages)
97
+ return puts('No downloadable attachments found') if attachments.empty?
98
+
99
+ execute_downloads(attachments)
100
+ end
101
+
102
+ def execute_downloads(attachments)
103
+ dir = prepare_output_dir
104
+ count = attachments.sum { |att| download_one(att, dir) }
105
+ puts "Downloaded #{count} file#{'s' if count != 1} to #{dir}" if count.positive?
106
+ end
107
+
108
+ def find_downloadable(messages)
109
+ messages.flat_map { |msg| downloadable_from(msg) }
110
+ end
111
+
112
+ def downloadable_from(msg) = msg.downloadable_attachments
113
+
114
+ def download_one(att, dir)
115
+ name = Formatters::FormatUtils.safe_filename(att['fileName'] || att['name'] || 'file')
116
+ print "\u{1F4CE} Downloading #{name}..."
117
+ perform_download(att, dir, name)
118
+ rescue StandardError => e
119
+ handle_download_error(e)
120
+ end
121
+
122
+ def handle_download_error(err)
123
+ warn " failed (#{err.message})"
124
+ 0
125
+ end
126
+
127
+ def perform_download(att, dir, name)
128
+ url = resolve_download_url(att['sharepointIds'])
129
+ bytes = file_downloader.download(url, unique_path(dir, name))
130
+ puts " done (#{Formatters::FormatUtils.format_bytes(bytes)})"
131
+ 1
132
+ end
133
+
134
+ def resolve_download_url(sp_ids)
135
+ result = with_token_refresh do
136
+ runner.files_api.drive_item(
137
+ site_id: sp_ids['siteId'], list_id: sp_ids['listId'],
138
+ item_id: sp_ids['listItemUniqueId']
139
+ )
140
+ end
141
+ result['@microsoft.graph.downloadUrl'] or raise Error, 'No download URL in response'
142
+ end
143
+
144
+ def file_downloader
145
+ @file_downloader ||= Services::FileDownloader.new
146
+ end
147
+
148
+ def prepare_output_dir
149
+ dir = @options[:output_dir] || default_download_dir
150
+ FileUtils.mkdir_p(dir)
151
+ dir
152
+ end
153
+
154
+ def default_download_dir
155
+ File.join(Support::XdgPaths.new.data_dir, 'downloads')
156
+ end
157
+
158
+ def unique_path(dir, name)
159
+ path = File.join(dir, name)
160
+ return path unless File.exist?(path)
161
+
162
+ ext = File.extname(name)
163
+ base = File.basename(name, ext)
164
+ counter = 1
165
+ counter += 1 while File.exist?(path = File.join(dir, "#{base}-#{counter}#{ext}"))
166
+ path
167
+ end
168
+ end
169
+
170
+ # Read messages from a channel or chat
171
+ class Messages < Base
172
+ include MessagesDisplay
173
+ include AttachmentDownload
174
+
175
+ def initialize(args, runner:)
176
+ @options = {}
177
+ super
178
+ end
179
+
180
+ def execute
181
+ result = validate_options || require_auth
182
+ return result if result
183
+
184
+ target = resolve_target
185
+ target ? fetch_messages(target) : 1
186
+ end
187
+
188
+ protected
189
+
190
+ MESSAGES_OPTIONS = {
191
+ '-t' => ->(opts, args) { opts[:team_id] = args.shift },
192
+ '--team' => ->(opts, args) { opts[:team_id] = args.shift },
193
+ '--download' => ->(opts, _args) { opts[:download] = true },
194
+ '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
195
+ '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
196
+ }.freeze
197
+
198
+ def handle_option(arg, pending)
199
+ handler = MESSAGES_OPTIONS[arg]
200
+ return super unless handler
201
+
202
+ handler.call(@options, pending)
203
+ end
204
+
205
+ def help_text = MESSAGES_HELP
206
+
207
+ private
208
+
209
+ def resolve_target
210
+ target = positional_args.first
211
+ return missing_target unless target
212
+
213
+ parse_teams_url_if_needed(target)
214
+ end
215
+
216
+ def missing_target
217
+ error('Target required. Specify a channel ID, chat ID, or Teams URL.')
218
+ puts
219
+ puts 'Usage: teems messages <channel-id|chat-id|teams-url>'
220
+ puts 'Use "teems channels" or "teems chats" to find IDs.'
221
+ nil
222
+ end
223
+
224
+ def parse_teams_url_if_needed(target)
225
+ return target unless target.start_with?('https://')
226
+
227
+ result = Services::TeamsUrlParser.parse(target)
228
+ result ? apply_parsed_url(result) : (error('Invalid Teams URL format') || nil)
229
+ end
230
+
231
+ def apply_parsed_url(result)
232
+ conversation_id = result.conversation_id
233
+ team_id = result.team_id
234
+ debug("Parsed URL: conversation=#{conversation_id}, team=#{team_id}")
235
+ @options[:team_id] = team_id if team_id
236
+ conversation_id
237
+ end
238
+
239
+ def fetch_messages(target)
240
+ @options[:team_id] ? fetch_channel_messages(target) : fetch_chat_messages(target)
241
+ end
242
+
243
+ def fetch_channel_messages(channel_id)
244
+ response = with_token_refresh do
245
+ runner.messages_api.channel_messages(
246
+ channel_id: channel_id, limit: @options[:limit]
247
+ )
248
+ end
249
+ display_messages(extract_messages_data(response))
250
+ rescue ApiError => e
251
+ error("Failed to fetch channel messages: #{e.message}")
252
+ end
253
+
254
+ def fetch_chat_messages(chat_id)
255
+ response = with_token_refresh do
256
+ runner.messages_api.chat_messages(chat_id: chat_id, limit: @options[:limit])
257
+ end
258
+ display_messages(extract_messages_data(response))
259
+ rescue ApiError => e
260
+ error("Failed to fetch chat messages: #{e.message}")
261
+ end
262
+
263
+ def extract_messages_data(response) = response['messages'] || response['posts'] || response['value'] || []
264
+
265
+ def display_messages(messages_data)
266
+ return (puts('No messages found') || true) && 0 if messages_data.empty?
267
+
268
+ messages = messages_data.map { |msg_data| Models::Message.from_api(msg_data) }.reject(&:system_message?).reverse
269
+ render_messages(messages)
270
+ 0
271
+ end
272
+
273
+ def render_messages(messages)
274
+ return output_json(messages.map { |msg| message_to_hash(msg) }) if @options[:json]
275
+
276
+ messages.each { |msg| display_message(msg) }
277
+ download_attachments(messages) if @options[:download]
278
+ end
279
+ end
280
+ end
281
+ end