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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Token lookup accessors for TokenStore
6
+ module TokenLookup
7
+ # Get the skype_spaces_token for refresh
8
+ def skype_spaces_token = load_tokens['skype_spaces_token']
9
+
10
+ # Get OIDC refresh credentials
11
+ def refresh_token = load_tokens['refresh_token']
12
+ def client_id = load_tokens['client_id']
13
+ def tenant_id = load_tokens['tenant_id']
14
+
15
+ def configured?
16
+ File.exist?(tokens_file) && !load_tokens.empty?
17
+ end
18
+
19
+ def token_age
20
+ return nil unless File.exist?(tokens_file)
21
+
22
+ tokens = load_tokens
23
+ timestamp = tokens['tokens_refreshed_at'] || tokens['saved_at']
24
+ timestamp ? Time.now - Time.parse(timestamp) : nil
25
+ rescue ArgumentError
26
+ nil
27
+ end
28
+ end
29
+
30
+ # Manages Teams authentication tokens stored in config directory
31
+ class TokenStore
32
+ include TokenLookup
33
+
34
+ TOKENS_FILE = 'tokens.json'
35
+
36
+ def initialize(paths: Support::XdgPaths.new)
37
+ @paths = paths
38
+ end
39
+
40
+ def account
41
+ auth, skype, name, chatsvc, presence =
42
+ load_tokens.values_at('auth_token', 'skype_token', 'name', 'chatsvc_token', 'skype_spaces_token')
43
+ return nil unless auth && skype
44
+
45
+ Models::Account.new(
46
+ name: name || 'default', auth_token: auth,
47
+ skype_token: skype, chatsvc_token: chatsvc, presence_token: presence
48
+ )
49
+ end
50
+
51
+ def save(**tokens)
52
+ @paths.ensure_config_dir
53
+ data = tokens.transform_keys(&:to_s).merge('saved_at' => Time.now.iso8601).compact
54
+ write_token_file(data)
55
+ rescue SystemCallError, IOError => e
56
+ warn "teems: Could not save tokens: #{e.message}"
57
+ false
58
+ end
59
+
60
+ # Update just the skype_token (used during refresh)
61
+ def update_skype_token(skype_token)
62
+ with_loaded_tokens { |data| apply_skype_token(data, skype_token) }
63
+ rescue SystemCallError, IOError => e
64
+ warn "teems: Could not update token file: #{e.message}"
65
+ false
66
+ end
67
+
68
+ # Update all tokens at once (used by OIDC refresh)
69
+ def update_all_tokens(**tokens)
70
+ with_loaded_tokens { |data| apply_all_tokens(data, tokens) }
71
+ rescue SystemCallError, IOError => e
72
+ warn "teems: Could not update token file: #{e.message}"
73
+ false
74
+ end
75
+
76
+ def clear = FileUtils.rm_f(tokens_file)
77
+
78
+ private
79
+
80
+ def with_loaded_tokens
81
+ data = load_tokens
82
+ return false if data.empty?
83
+
84
+ yield data
85
+ end
86
+
87
+ def tokens_file
88
+ @paths.config_file(TOKENS_FILE)
89
+ end
90
+
91
+ def apply_skype_token(data, skype_token)
92
+ data.merge!('skype_token' => skype_token, 'skype_token_refreshed_at' => Time.now.iso8601)
93
+ write_token_file(data)
94
+ end
95
+
96
+ def apply_all_tokens(data, tokens)
97
+ updates = tokens.compact.transform_keys(&:to_s).merge('tokens_refreshed_at' => Time.now.iso8601)
98
+ write_token_file(data.merge!(updates))
99
+ end
100
+
101
+ def write_token_file(data)
102
+ File.write(tokens_file, JSON.pretty_generate(data))
103
+ File.chmod(0o600, tokens_file)
104
+ end
105
+
106
+ def load_tokens
107
+ return {} unless File.exist?(tokens_file)
108
+
109
+ JSON.parse(File.read(tokens_file))
110
+ rescue JSON::ParserError => e
111
+ warn "teems: Token file corrupted (#{e.message}), please re-authenticate with: teems auth login"
112
+ {}
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Logs errors to a file for debugging
6
+ module ErrorLogger
7
+ module_function
8
+
9
+ def log(error, paths: XdgPaths.new)
10
+ log_file = prepare_log_file(paths)
11
+ append_error_entry(log_file, error)
12
+ rescue SystemCallError, IOError => e
13
+ warn "teems: Could not write error log: #{e.message}"
14
+ nil
15
+ end
16
+
17
+ def append_error_entry(log_file, error)
18
+ File.open(log_file, 'a') { |file| write_entry(file, error) }
19
+ log_file
20
+ end
21
+
22
+ def prepare_log_file(paths)
23
+ paths.ensure_cache_dir
24
+ paths.cache_file('error.log')
25
+ end
26
+
27
+ def write_entry(file, error)
28
+ file.puts "#{Time.now.iso8601} - #{error.class}: #{error.message}"
29
+ backtrace = error.backtrace
30
+ file.puts backtrace.first(10).map { |line| " #{line}" }.join("\n") if backtrace
31
+ file.puts
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Formats help text for commands
6
+ class HelpFormatter
7
+ def initialize(usage)
8
+ @usage = usage
9
+ @description = nil
10
+ @sections = []
11
+ @notes = []
12
+ end
13
+
14
+ def description(text)
15
+ @description = text
16
+ end
17
+
18
+ def note(text)
19
+ @notes << text
20
+ end
21
+
22
+ def section(title)
23
+ section = Section.new(title)
24
+ yield section
25
+ @sections << section
26
+ end
27
+
28
+ def render
29
+ lines = build_help_lines
30
+ @notes.each { |note| lines << "Note: #{note}" }
31
+ lines.join("\n")
32
+ end
33
+
34
+ def build_help_lines
35
+ lines = [@usage, '']
36
+ lines.push(@description, '') if @description
37
+ @sections.each { |section| lines.push(section.render, '') }
38
+ lines
39
+ end
40
+
41
+ # Section within help text
42
+ class Section
43
+ ITEM_RENDERERS = {
44
+ option: ->(args) { " #{args[0].ljust(20)} #{args[1]}" },
45
+ item: ->(args) { " #{args[0].ljust(20)} #{args[1]}" },
46
+ text: ->(args) { " #{args.first}" }
47
+ }.freeze
48
+
49
+ def initialize(title)
50
+ @title = title
51
+ @items = []
52
+ end
53
+
54
+ def option(flags, description)
55
+ @items << [:option, flags, description]
56
+ end
57
+
58
+ def item(label, description)
59
+ @items << [:item, label, description]
60
+ end
61
+
62
+ def text(content)
63
+ @items << [:text, content]
64
+ end
65
+
66
+ def render
67
+ lines = ["#{@title}:"]
68
+ @items.each { |type, *args| lines << render_item(type, args) }
69
+ lines.join("\n")
70
+ end
71
+
72
+ private
73
+
74
+ def render_item(type, args)
75
+ ITEM_RENDERERS[type]&.call(args)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Shared timezone detection logic for calendar and schedule commands
6
+ module Timezone
7
+ TIMEZONE_MAP = {
8
+ 'EST' => 'America/New_York', 'EDT' => 'America/New_York',
9
+ 'CST' => 'America/Chicago', 'CDT' => 'America/Chicago',
10
+ 'MST' => 'America/Denver', 'MDT' => 'America/Denver',
11
+ 'PST' => 'America/Los_Angeles', 'PDT' => 'America/Los_Angeles',
12
+ 'AKST' => 'America/Anchorage', 'AKDT' => 'America/Anchorage',
13
+ 'HST' => 'Pacific/Honolulu', 'UTC' => 'UTC', 'GMT' => 'UTC'
14
+ }.freeze
15
+
16
+ def detect_timezone
17
+ tz_from_env || tz_from_system
18
+ end
19
+
20
+ def short_tz_label
21
+ abbrev = tz_from_system_abbrev
22
+ abbrev.gsub(/[DS](?=T$)/, '')
23
+ end
24
+
25
+ private
26
+
27
+ def tz_from_system_abbrev
28
+ Time.now.strftime('%Z')
29
+ end
30
+
31
+ def tz_from_env
32
+ tz_env = ENV.fetch('TZ', nil)
33
+ return nil if tz_env.to_s.empty?
34
+ return tz_env if tz_env.include?('/')
35
+
36
+ TIMEZONE_MAP[tz_env]
37
+ end
38
+
39
+ def tz_from_system
40
+ TIMEZONE_MAP[Time.now.strftime('%Z')] || 'UTC'
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # XDG-compliant paths for config, cache, and data directories
6
+ class XdgPaths
7
+ def config_dir
8
+ @config_dir ||= File.join(
9
+ ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config')),
10
+ 'teems'
11
+ )
12
+ end
13
+
14
+ def cache_dir
15
+ @cache_dir ||= File.join(
16
+ ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache')),
17
+ 'teems'
18
+ )
19
+ end
20
+
21
+ def data_dir
22
+ @data_dir ||= File.join(
23
+ ENV.fetch('XDG_DATA_HOME', File.join(Dir.home, '.local', 'share')),
24
+ 'teems'
25
+ )
26
+ end
27
+
28
+ def config_file(filename)
29
+ File.join(config_dir, filename)
30
+ end
31
+
32
+ def cache_file(filename)
33
+ File.join(cache_dir, filename)
34
+ end
35
+
36
+ def data_file(filename)
37
+ File.join(data_dir, filename)
38
+ end
39
+
40
+ def ensure_config_dir
41
+ FileUtils.mkdir_p(config_dir)
42
+ rescue SystemCallError => e
43
+ warn "teems: Could not create config directory #{config_dir}: #{e.message}"
44
+ raise
45
+ end
46
+
47
+ def ensure_cache_dir
48
+ FileUtils.mkdir_p(cache_dir)
49
+ rescue SystemCallError => e
50
+ warn "teems: Could not create cache directory #{cache_dir}: #{e.message}"
51
+ raise
52
+ end
53
+
54
+ def ensure_data_dir
55
+ FileUtils.mkdir_p(data_dir)
56
+ rescue SystemCallError => e
57
+ warn "teems: Could not create data directory #{data_dir}: #{e.message}"
58
+ raise
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ VERSION = '0.1.0'
5
+ end
data/lib/teems.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'fileutils'
7
+ require 'optparse'
8
+ require 'time'
9
+ require 'io/console'
10
+ require 'digest'
11
+
12
+ # Microsoft Teams CLI - A command-line interface for Microsoft Teams
13
+ module Teems
14
+ # Base error class for all Teems errors
15
+ class Error < StandardError; end
16
+
17
+ # Error raised when a Teams API request fails (4xx/5xx responses)
18
+ class ApiError < Error
19
+ attr_reader :status_code
20
+
21
+ def initialize(message = nil, status_code: nil)
22
+ @status_code = status_code
23
+ super(message)
24
+ end
25
+
26
+ def not_found? = status_code == 404
27
+ def unauthorized? = status_code == 401
28
+ def forbidden? = status_code == 403
29
+ def rate_limited? = status_code == 429
30
+ end
31
+
32
+ # Error raised when required configuration is missing or invalid
33
+ class ConfigError < Error; end
34
+
35
+ # Error raised when authentication fails or tokens are missing
36
+ class AuthError < Error; end
37
+
38
+ # Error raised when token storage operations fail
39
+ class TokenStoreError < Error; end
40
+
41
+ autoload :VERSION, 'teems/version'
42
+ autoload :CLI, 'teems/cli'
43
+ autoload :Runner, 'teems/runner'
44
+
45
+ # Data models for Teams entities
46
+ module Models
47
+ autoload :Account, 'teems/models/account'
48
+ autoload :Channel, 'teems/models/channel'
49
+ autoload :Chat, 'teems/models/chat'
50
+ autoload :Event, 'teems/models/event'
51
+ autoload :Message, 'teems/models/message'
52
+ autoload :Parsing, 'teems/models/parsing'
53
+ autoload :User, 'teems/models/user'
54
+ autoload :UserProfile, 'teems/models/user_profile'
55
+ autoload :Duration, 'teems/models/duration'
56
+ end
57
+
58
+ # Application services for configuration, caching, and API communication
59
+ module Services
60
+ autoload :ApiClient, 'teems/services/api_client'
61
+ autoload :Configuration, 'teems/services/configuration'
62
+ autoload :TokenStore, 'teems/services/token_store'
63
+ autoload :TokenExtractor, 'teems/services/token_extractor'
64
+ autoload :TokenRefresher, 'teems/services/token_refresher'
65
+ autoload :CacheStore, 'teems/services/cache_store'
66
+ autoload :FileDownloader, 'teems/services/file_downloader'
67
+ autoload :TeamsUrlParser, 'teems/services/teams_url_parser'
68
+ autoload :SyncStore, 'teems/services/sync_store'
69
+ autoload :SyncDirNaming, 'teems/services/sync_dir_naming'
70
+ autoload :SyncEngine, 'teems/services/sync_engine'
71
+ end
72
+
73
+ # Output formatters for messages and terminal output
74
+ module Formatters
75
+ autoload :Output, 'teems/formatters/output'
76
+ autoload :FormatUtils, 'teems/formatters/format_utils'
77
+ autoload :MessageFormatter, 'teems/formatters/message_formatter'
78
+ autoload :MarkdownFormatter, 'teems/formatters/markdown_formatter'
79
+ autoload :CalendarFormatter, 'teems/formatters/calendar_formatter'
80
+ end
81
+
82
+ # CLI commands implementing user-facing functionality
83
+ module Commands
84
+ autoload :Base, 'teems/commands/base'
85
+ autoload :Activity, 'teems/commands/activity'
86
+ autoload :Auth, 'teems/commands/auth'
87
+ autoload :Cal, 'teems/commands/cal'
88
+ autoload :Channels, 'teems/commands/channels'
89
+ autoload :Chats, 'teems/commands/chats'
90
+ autoload :Messages, 'teems/commands/messages'
91
+ autoload :Sync, 'teems/commands/sync'
92
+ autoload :Help, 'teems/commands/help'
93
+ autoload :Who, 'teems/commands/who'
94
+ autoload :Ooo, 'teems/commands/ooo'
95
+ autoload :Org, 'teems/commands/org'
96
+ autoload :Status, 'teems/commands/status'
97
+ end
98
+
99
+ # Thin wrappers around Teams API endpoints
100
+ module Api
101
+ autoload :Client, 'teems/api/client'
102
+ autoload :Calendar, 'teems/api/calendar'
103
+ autoload :Channels, 'teems/api/channels'
104
+ autoload :Chats, 'teems/api/chats'
105
+ autoload :Files, 'teems/api/files'
106
+ autoload :Messages, 'teems/api/messages'
107
+ autoload :Users, 'teems/api/users'
108
+ end
109
+
110
+ # Utility classes for paths and helpers
111
+ module Support
112
+ autoload :XdgPaths, 'teems/support/xdg_paths'
113
+ autoload :HelpFormatter, 'teems/support/help_formatter'
114
+ autoload :ErrorLogger, 'teems/support/error_logger'
115
+ autoload :Timezone, 'teems/support/timezone'
116
+ end
117
+ end