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