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,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Message serialization helpers for sync storage
|
|
6
|
+
module SyncSerializer
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def message_to_hash(message) = msg_core_hash(message).merge(msg_extras_hash(message))
|
|
10
|
+
|
|
11
|
+
def msg_core_hash(message)
|
|
12
|
+
{
|
|
13
|
+
'id' => message.id, 'sender_id' => message.sender_id,
|
|
14
|
+
'sender_name' => message.sender_name, 'content' => message.content,
|
|
15
|
+
'created_at' => message.created_at&.iso8601, 'message_type' => message.message_type
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def msg_extras_hash(message)
|
|
20
|
+
{
|
|
21
|
+
'reply_to_id' => message.reply_to_id,
|
|
22
|
+
'reactions' => message.reactions.map do |reaction|
|
|
23
|
+
{ 'type' => reaction[:type], 'count' => reaction[:count] }
|
|
24
|
+
end,
|
|
25
|
+
'attachments' => message.attachments, 'importance' => message.importance,
|
|
26
|
+
'edited' => message.edited, 'mentions' => message.mentions
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse_stored_reactions(reactions)
|
|
31
|
+
Array(reactions).map { |reaction| stored_reaction_hash(reaction) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stored_reaction_hash(reaction) = { type: reaction['type'], count: reaction['count'] }
|
|
35
|
+
|
|
36
|
+
def stored_msg_attrs(data)
|
|
37
|
+
stored_msg_core(data).merge(stored_msg_extras(data))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stored_msg_core(data)
|
|
41
|
+
created_at_raw = data['created_at']
|
|
42
|
+
{ id: data['id'], sender_id: data['sender_id'], sender_name: data['sender_name'],
|
|
43
|
+
content: data['content'], created_at: created_at_raw ? Time.parse(created_at_raw) : nil,
|
|
44
|
+
message_type: data['message_type'] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stored_msg_extras(data)
|
|
48
|
+
{ reply_to_id: data['reply_to_id'], reactions: parse_stored_reactions(data['reactions']),
|
|
49
|
+
attachments: stored_default(data, 'attachments', []),
|
|
50
|
+
importance: data['importance'],
|
|
51
|
+
edited: stored_default(data, 'edited', false),
|
|
52
|
+
mentions: stored_default(data, 'mentions', []) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stored_default(data, key, fallback) = data[key] || fallback
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Pagination helpers for fetching chat messages across pages
|
|
59
|
+
module SyncPagination
|
|
60
|
+
API_DELAY_SECONDS = 0.5
|
|
61
|
+
MAX_PAGES = 500
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def paginate_messages(chat_id, start_time)
|
|
66
|
+
@backward_link = nil
|
|
67
|
+
@page_count = 0
|
|
68
|
+
messages = []
|
|
69
|
+
loop do
|
|
70
|
+
page_messages = fetch_next_page(chat_id, start_time)
|
|
71
|
+
break if page_messages.empty? || log_and_check_max?(@page_count += 1, page_messages)
|
|
72
|
+
|
|
73
|
+
@backward_link = accumulate_page(messages, page_messages, start_time)
|
|
74
|
+
break unless @backward_link
|
|
75
|
+
end
|
|
76
|
+
messages
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def accumulate_page(messages, page_messages, start_time)
|
|
80
|
+
parsed, cutoff = parse_page_messages(page_messages, start_time)
|
|
81
|
+
messages.concat(parsed)
|
|
82
|
+
cutoff ? nil : advance_link(start_time)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fetch_next_page(chat_id, start_time)
|
|
86
|
+
response = @runner.messages_api.chat_messages_page(
|
|
87
|
+
chat_id: chat_id, start_time: @page_count.zero? ? start_time : nil, backward_link: @backward_link
|
|
88
|
+
)
|
|
89
|
+
msgs = response['messages'] || response['value'] || []
|
|
90
|
+
@backward_link = response.dig('_metadata', 'backwardLink')
|
|
91
|
+
msgs
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def log_and_check_max?(page_count, page_messages)
|
|
95
|
+
debug(" Page #{page_count}: #{page_messages.length} message(s)")
|
|
96
|
+
return false unless page_count >= MAX_PAGES
|
|
97
|
+
|
|
98
|
+
debug(" Reached max pages (#{MAX_PAGES}), stopping pagination")
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_page_messages(page_messages, start_time)
|
|
103
|
+
parsed = page_messages.map { |msg_data| Models::Message.from_api(msg_data) }
|
|
104
|
+
cutoff = reached_cutoff?(parsed, start_time)
|
|
105
|
+
[parsed, cutoff]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def reached_cutoff?(parsed, start_time)
|
|
109
|
+
oldest = parsed.min_by { |msg| msg.created_at || Time.now }
|
|
110
|
+
return false unless oldest&.created_at && oldest.created_at < start_time
|
|
111
|
+
|
|
112
|
+
debug(' Reached cutoff date, stopping pagination')
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def advance_link(start_time)
|
|
117
|
+
return nil unless @backward_link
|
|
118
|
+
|
|
119
|
+
sleep(API_DELAY_SECONDS)
|
|
120
|
+
@backward_link.gsub(/startTime=\d+/, "startTime=#{(start_time.to_f * 1000).to_i}")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Core sync logic: fetch, merge, and write chat messages.
|
|
125
|
+
# Extracted from Commands::Sync to keep the command thin.
|
|
126
|
+
class SyncEngine
|
|
127
|
+
include SyncSerializer
|
|
128
|
+
include SyncPagination
|
|
129
|
+
|
|
130
|
+
def initialize(runner:, sync_store:, state:, output:)
|
|
131
|
+
@runner = runner
|
|
132
|
+
@sync_store = sync_store
|
|
133
|
+
@state = state
|
|
134
|
+
@output = output
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Fetch all messages from a chat since start_time with pagination
|
|
138
|
+
def fetch_all_messages(chat_id, start_time)
|
|
139
|
+
messages = paginate_messages(chat_id, start_time)
|
|
140
|
+
filter_and_sort_messages(messages, start_time)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def merge_and_write(chat, existing_raw, new_messages)
|
|
144
|
+
existing = existing_raw.map { |data| message_from_stored(data) }
|
|
145
|
+
all_messages = merge_messages(existing, new_messages)
|
|
146
|
+
write_chat_files(chat, all_messages)
|
|
147
|
+
all_messages
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def message_from_stored(data) = Models::Message.new(**stored_msg_attrs(data))
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def filter_and_sort_messages(messages, start_time)
|
|
155
|
+
messages.reject(&:system_message?)
|
|
156
|
+
.filter_map { |msg| message_with_timestamp(msg, start_time) }
|
|
157
|
+
.sort_by(&:first)
|
|
158
|
+
.map(&:last)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def message_with_timestamp(msg, start_time)
|
|
162
|
+
created_at = msg.created_at
|
|
163
|
+
timestamp = created_at || Time.at(0)
|
|
164
|
+
[timestamp, msg] if !created_at || timestamp >= start_time
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def merge_messages(existing, new_messages)
|
|
168
|
+
merged = index_by_id(existing).merge(index_by_id(new_messages))
|
|
169
|
+
merged.values.sort_by { |msg| msg.created_at || Time.at(0) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def index_by_id(messages) = messages.to_h { |msg| [msg.id, msg] }
|
|
173
|
+
|
|
174
|
+
def write_chat_files(chat, messages)
|
|
175
|
+
fmt = Formatters::MarkdownFormatter.new(chat_name: chat.display_name,
|
|
176
|
+
chat_type: chat.chat_type, synced_at: Time.now)
|
|
177
|
+
json = JSON.pretty_generate(messages.map { |msg| message_to_hash(msg) })
|
|
178
|
+
@sync_store.write_messages(chat.id, messages_md: fmt.format(messages), messages_json: json, state: @state)
|
|
179
|
+
write_metadata(chat)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def write_metadata(chat)
|
|
183
|
+
@sync_store.write_chat_metadata(chat.id, chat_metadata_hash(chat), state: @state)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def chat_metadata_hash(chat)
|
|
187
|
+
{ 'id' => chat.id, 'display_name' => chat.display_name,
|
|
188
|
+
'type' => chat.chat_type, 'synced_at' => Time.now.iso8601 }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def debug(message) = @output&.verbose? && @output.debug(message)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# File I/O helpers for SyncStore: atomic writes, backup, and JSON persistence
|
|
6
|
+
module SyncFileOps
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def atomic_write(path, content)
|
|
10
|
+
tmp_path = "#{path}.tmp"
|
|
11
|
+
File.write(tmp_path, content)
|
|
12
|
+
File.rename(tmp_path, path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def backup_corrupt_file(path)
|
|
16
|
+
backup_path = "#{path}.corrupt.#{Time.now.strftime('%Y%m%d%H%M%S')}"
|
|
17
|
+
File.rename(path, backup_path)
|
|
18
|
+
rescue SystemCallError, IOError => e
|
|
19
|
+
warn "teems: Could not back up corrupt file #{path}: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_json_or_default(path, default)
|
|
23
|
+
return default unless File.exist?(path)
|
|
24
|
+
|
|
25
|
+
JSON.parse(File.read(path))
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
backup_corrupt_file(path)
|
|
28
|
+
default
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Chat state query operations for SyncStore
|
|
33
|
+
module SyncStateQuery
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_synced_at(timestamp)
|
|
37
|
+
return nil unless timestamp
|
|
38
|
+
|
|
39
|
+
Time.parse(timestamp)
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def chat_entry_unavailable?(entry)
|
|
45
|
+
entry&.dig('unavailable') == true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Chat state mutation operations for SyncStore
|
|
50
|
+
module SyncStateMutation
|
|
51
|
+
def update_chat_state(state, chat_id, attrs:)
|
|
52
|
+
display_name, synced_at, count, chat_type =
|
|
53
|
+
attrs.values_at(:display_name, :last_synced_at, :message_count, :chat_type)
|
|
54
|
+
entry = (state['chats'] ||= {})[chat_id] ||= {}
|
|
55
|
+
entry.merge!('last_synced_at' => synced_at.iso8601, 'message_count' => count,
|
|
56
|
+
'display_name' => display_name, 'chat_type' => chat_type,
|
|
57
|
+
'dir_name' => build_dir_name(chat_id, display_name))
|
|
58
|
+
state
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mark_unavailable(state, chat_id, **opts)
|
|
62
|
+
entry = (state['chats'] ||= {})[chat_id] ||= {}
|
|
63
|
+
apply_unavailable(entry)
|
|
64
|
+
apply_chat_type(entry, opts[:chat_type])
|
|
65
|
+
apply_display_info(entry, chat_id, opts[:display_name])
|
|
66
|
+
state
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def apply_unavailable(entry)
|
|
72
|
+
entry.merge!('unavailable' => true, 'unavailable_at' => Time.now.iso8601)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def apply_chat_type(entry, chat_type)
|
|
76
|
+
entry['chat_type'] = chat_type if chat_type
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def apply_display_info(entry, chat_id, display_name)
|
|
80
|
+
return unless display_name
|
|
81
|
+
|
|
82
|
+
entry.merge!('display_name' => display_name,
|
|
83
|
+
'dir_name' => build_dir_name(chat_id, display_name))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Chat directory resolution for SyncStore
|
|
88
|
+
module SyncChatDir
|
|
89
|
+
def chat_dir(chat_id, state: nil)
|
|
90
|
+
chat_entry = state&.dig('chats', chat_id)
|
|
91
|
+
dir_name = chat_entry&.dig('dir_name') || sanitize_id(chat_id)
|
|
92
|
+
File.join(sync_dir, SyncStore::CHATS_DIR, type_dir(chat_entry&.dig('chat_type')), dir_name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def read_messages_json(chat_id, state: nil)
|
|
96
|
+
load_json_or_default(File.join(chat_dir(chat_id, state: state), 'messages.json'), [])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Chat file write operations for SyncStore
|
|
101
|
+
module SyncChatWrite
|
|
102
|
+
def write_messages(chat_id, **opts)
|
|
103
|
+
md, json, state = opts.values_at(:messages_md, :messages_json, :state)
|
|
104
|
+
write_to_dir(chat_dir(chat_id, state: state),
|
|
105
|
+
'messages.md' => md, 'messages.json' => json)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def write_chat_metadata(chat_id, metadata, state: nil)
|
|
109
|
+
dir = chat_dir(chat_id, state: state)
|
|
110
|
+
write_to_dir(dir, 'chat_metadata.json' => JSON.pretty_generate(metadata))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def write_to_dir(dir, files)
|
|
116
|
+
FileUtils.mkdir_p(dir)
|
|
117
|
+
files.each { |name, content| atomic_write(File.join(dir, name), content) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Directory rename helpers for SyncStore
|
|
122
|
+
module SyncRenameOps
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def rename_entry_dir(entry, new_dir_name, chat_type)
|
|
126
|
+
old_name = entry['dir_name']
|
|
127
|
+
old_type = entry['chat_type']
|
|
128
|
+
if old_name && (old_name != new_dir_name || old_type != chat_type)
|
|
129
|
+
move_chat_dir(chat_type_path(old_type, old_name),
|
|
130
|
+
chat_type_path(chat_type, new_dir_name))
|
|
131
|
+
end
|
|
132
|
+
entry.merge!('dir_name' => new_dir_name, 'chat_type' => chat_type)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def move_chat_dir(old_path, new_path)
|
|
136
|
+
return if old_path == new_path || !File.directory?(old_path) || File.exist?(new_path)
|
|
137
|
+
|
|
138
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
139
|
+
File.rename(old_path, new_path)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def chat_type_path(type, name) = File.join(sync_dir, SyncStore::CHATS_DIR, type_dir(type), name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Manages local sync state and file storage for the sync command.
|
|
146
|
+
# Stores chat history as Markdown + JSON in XDG data directory.
|
|
147
|
+
class SyncStore
|
|
148
|
+
include SyncDirNaming
|
|
149
|
+
include SyncFileOps
|
|
150
|
+
include SyncStateQuery
|
|
151
|
+
include SyncStateMutation
|
|
152
|
+
include SyncChatDir
|
|
153
|
+
include SyncChatWrite
|
|
154
|
+
include SyncRenameOps
|
|
155
|
+
|
|
156
|
+
SYNC_DIR = 'sync'
|
|
157
|
+
STATE_FILE = 'sync_state.json'
|
|
158
|
+
CHATS_DIR = 'chats'
|
|
159
|
+
|
|
160
|
+
def initialize(xdg_paths: Support::XdgPaths.new)
|
|
161
|
+
@xdg_paths = xdg_paths
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def sync_dir = @sync_dir ||= File.join(@xdg_paths.data_dir, SYNC_DIR)
|
|
165
|
+
|
|
166
|
+
def last_synced_time(state, chat_id)
|
|
167
|
+
parse_synced_at(state.dig('chats', chat_id, 'last_synced_at'))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def chat_unavailable?(state, chat_id)
|
|
171
|
+
chat_entry = state.dig('chats', chat_id)
|
|
172
|
+
chat_entry_unavailable?(chat_entry)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def load_state
|
|
176
|
+
load_json_or_default(File.join(sync_dir, STATE_FILE), {})
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def save_state(state)
|
|
180
|
+
FileUtils.mkdir_p(sync_dir)
|
|
181
|
+
atomic_write(File.join(sync_dir, STATE_FILE), JSON.pretty_generate(state))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def ensure_dir_name(state, chat_info:)
|
|
185
|
+
chat_id, display_name, chat_type = chat_info.values_at(:chat_id, :display_name, :chat_type)
|
|
186
|
+
new_dir_name = build_dir_name(chat_id, display_name)
|
|
187
|
+
entry = (state['chats'] ||= {})[chat_id] ||= {}
|
|
188
|
+
rename_entry_dir(entry, new_dir_name, chat_type)
|
|
189
|
+
new_dir_name
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Teems
|
|
7
|
+
module Services
|
|
8
|
+
# Parses Microsoft Teams URLs to extract conversation and message identifiers
|
|
9
|
+
class TeamsUrlParser
|
|
10
|
+
# Parsed components from a Teams message URL
|
|
11
|
+
Result = Struct.new(:conversation_id, :message_id, :context_type, :team_id)
|
|
12
|
+
|
|
13
|
+
TEAMS_HOST = 'teams.microsoft.com'
|
|
14
|
+
MESSAGE_PATH_PATTERN = %r{^/l/message/([^/]+)/(\d+)$}
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def parse(url)
|
|
18
|
+
uri = URI.parse(url)
|
|
19
|
+
return nil unless teams_url?(uri)
|
|
20
|
+
|
|
21
|
+
match_and_build(uri)
|
|
22
|
+
rescue URI::InvalidURIError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def match_and_build(uri)
|
|
27
|
+
match = uri.path.match(MESSAGE_PATH_PATTERN)
|
|
28
|
+
match ? build_result(match, uri.query) : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def teams_url?(uri_or_string)
|
|
32
|
+
uri = uri_or_string.is_a?(URI) ? uri_or_string : URI.parse(uri_or_string.to_s)
|
|
33
|
+
uri.host == TEAMS_HOST
|
|
34
|
+
rescue URI::InvalidURIError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_result(match, query_string)
|
|
41
|
+
context = parse_context(query_string)
|
|
42
|
+
Result.new(
|
|
43
|
+
conversation_id: URI.decode_www_form_component(match[1]),
|
|
44
|
+
message_id: match[2],
|
|
45
|
+
context_type: context[:context_type],
|
|
46
|
+
team_id: context[:team_id]
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_context(query_string)
|
|
51
|
+
return {} unless query_string
|
|
52
|
+
|
|
53
|
+
parse_context_param(extract_context_param(query_string))
|
|
54
|
+
rescue JSON::ParserError => e
|
|
55
|
+
log_context_parse_error(e)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_context_param(context_json)
|
|
59
|
+
context_json ? parse_context_json(context_json) : {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def log_context_parse_error(err)
|
|
63
|
+
warn "teems: Could not parse Teams URL context: #{err.message}"
|
|
64
|
+
{}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_context_param(query_string)
|
|
68
|
+
URI.decode_www_form(query_string).to_h['context']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_context_json(json)
|
|
72
|
+
context = JSON.parse(json)
|
|
73
|
+
{ context_type: context['contextType'], team_id: context['teamId'] }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# JavaScript and text constants for token exchange and manual instructions.
|
|
6
|
+
# Separated from TokenExtractorScripts to keep modules under line limits.
|
|
7
|
+
module TokenExchangeScripts
|
|
8
|
+
# JavaScript to exchange skype spaces token for skypeToken via authsvc
|
|
9
|
+
EXCHANGE_TOKEN_JS = <<~JS
|
|
10
|
+
(function(skypeSpacesToken) {
|
|
11
|
+
var xhr = new XMLHttpRequest();
|
|
12
|
+
xhr.open('POST', 'https://teams.microsoft.com/api/authsvc/v1.0/authz', false);
|
|
13
|
+
xhr.setRequestHeader('Authorization', 'Bearer ' + skypeSpacesToken);
|
|
14
|
+
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
15
|
+
try {
|
|
16
|
+
xhr.send('{}');
|
|
17
|
+
if (xhr.status === 200) {
|
|
18
|
+
var result = JSON.parse(xhr.responseText);
|
|
19
|
+
return JSON.stringify({
|
|
20
|
+
skype_token: result.tokens.skypeToken,
|
|
21
|
+
region: result.region,
|
|
22
|
+
chat_service: result.regionGtms.chatService
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
} catch(e) {}
|
|
26
|
+
return JSON.stringify({error: 'Exchange failed'});
|
|
27
|
+
})(%s)
|
|
28
|
+
JS
|
|
29
|
+
|
|
30
|
+
MANUAL_TOKEN_INSTRUCTIONS = <<~INSTRUCTIONS
|
|
31
|
+
To manually extract tokens:
|
|
32
|
+
|
|
33
|
+
1. Open https://teams.microsoft.com in your browser
|
|
34
|
+
2. Log in with your credentials
|
|
35
|
+
3. Open Developer Tools (F12 or Cmd+Option+I)
|
|
36
|
+
4. Go to Console tab and run:
|
|
37
|
+
|
|
38
|
+
// Get Graph token (for teams/channels)
|
|
39
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
40
|
+
let key = localStorage.key(i);
|
|
41
|
+
if (key.includes('accesstoken') && key.includes('graph.microsoft.com')) {
|
|
42
|
+
console.log('auth_token:', JSON.parse(localStorage.getItem(key)).secret);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
5. To get the skypeToken (for messages), you need to:
|
|
47
|
+
a. Find the api.spaces.skype.com token in localStorage
|
|
48
|
+
b. Exchange it via POST to /api/authsvc/v1.0/authz
|
|
49
|
+
c. Or capture it from Network tab (Authentication header)
|
|
50
|
+
|
|
51
|
+
The skypeToken uses format: Authentication: skypetoken=<token>
|
|
52
|
+
The Graph token uses format: Authorization: Bearer <token>
|
|
53
|
+
INSTRUCTIONS
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|