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
|
@@ -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
|