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,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats chat messages as a Markdown document for local storage.
|
|
6
|
+
# Groups messages by date with human-readable formatting.
|
|
7
|
+
class MarkdownFormatter
|
|
8
|
+
REACTION_EMOJI = {
|
|
9
|
+
'like' => "\u{1F44D}", 'heart' => "\u{2764}\u{FE0F}",
|
|
10
|
+
'laugh' => "\u{1F602}", 'surprised' => "\u{1F62E}",
|
|
11
|
+
'sad' => "\u{1F622}", 'angry' => "\u{1F620}",
|
|
12
|
+
'yes-tone1' => "\u{1F44D}\u{1F3FB}", 'yes-tone2' => "\u{1F44D}\u{1F3FC}",
|
|
13
|
+
'support' => "\u{1F91D}", 'heartblue' => "\u{1F499}",
|
|
14
|
+
'computer' => "\u{1F4BB}", '1f37f_popcorn' => "\u{1F37F}",
|
|
15
|
+
'1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(chat_name:, chat_type: nil, synced_at: nil)
|
|
19
|
+
@chat_name = chat_name
|
|
20
|
+
@chat_type = chat_type
|
|
21
|
+
@synced_at = synced_at
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Format an array of Message objects into a Markdown string.
|
|
25
|
+
# Messages should be in chronological order (oldest first).
|
|
26
|
+
def format(messages)
|
|
27
|
+
lines = [build_header, '']
|
|
28
|
+
return (lines << '_No messages_').join("\n") if messages.empty?
|
|
29
|
+
|
|
30
|
+
format_message_groups(messages, lines)
|
|
31
|
+
lines.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def format_message_groups(messages, lines)
|
|
37
|
+
current_date = nil
|
|
38
|
+
messages.reject(&:system_message?).each do |msg|
|
|
39
|
+
current_date = append_date_header(lines, msg, current_date)
|
|
40
|
+
lines.concat(format_message(msg))
|
|
41
|
+
lines << ''
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def append_date_header(lines, msg, current_date)
|
|
46
|
+
msg_date = msg.created_at&.strftime('%Y-%m-%d')
|
|
47
|
+
return current_date if msg_date == current_date
|
|
48
|
+
|
|
49
|
+
lines << '' if current_date
|
|
50
|
+
lines.push("## #{msg_date || 'Unknown Date'}", '')
|
|
51
|
+
msg_date
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_header
|
|
55
|
+
parts = ["# #{@chat_name}"]
|
|
56
|
+
parts << "**Type:** #{@chat_type}" if @chat_type
|
|
57
|
+
parts << "_Synced: #{@synced_at.strftime('%Y-%m-%d %H:%M')}_" if @synced_at
|
|
58
|
+
parts.join("\n\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_message(msg)
|
|
62
|
+
[*(msg.reply? ? ['> _Reply to message_'] : []),
|
|
63
|
+
format_message_header(msg), '',
|
|
64
|
+
*message_body_lines(msg)]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def message_body_lines(msg)
|
|
68
|
+
content = msg.content
|
|
69
|
+
result = content.to_s.empty? ? [] : [content]
|
|
70
|
+
result.concat(format_message_attachments(msg))
|
|
71
|
+
result.concat(format_message_reactions(msg))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def format_message_header(msg)
|
|
75
|
+
prefix = msg.important? ? '**[!]** ' : ''
|
|
76
|
+
edited = msg.edited? ? ' _(edited)_' : ''
|
|
77
|
+
"### #{msg.created_at&.strftime('%H:%M') || '??:??'} — #{prefix}#{msg.sender_name || 'Unknown'}#{edited}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_message_attachments(msg)
|
|
81
|
+
Array(msg.attachments).map { |att| format_attachment(att) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def format_attachment(att)
|
|
85
|
+
return "\u{1F4CE} #{att}" unless att.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
format_file_attachment(att)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def format_file_attachment(att)
|
|
91
|
+
name = att['fileName'] || att['name'] || 'file'
|
|
92
|
+
url = att['siteUrl']
|
|
93
|
+
url&.start_with?('https://') ? "\u{1F4CE} [#{name}](#{url})" : "\u{1F4CE} #{name}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_message_reactions(msg)
|
|
97
|
+
reactions = msg.reactions
|
|
98
|
+
return [] unless displayable_reactions?(reactions)
|
|
99
|
+
|
|
100
|
+
[format_reactions_line(reactions)]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def displayable_reactions?(reactions) = reactions.is_a?(Array) && reactions.any?
|
|
104
|
+
|
|
105
|
+
def format_reactions_line(reactions)
|
|
106
|
+
parts = reactions.map { |reaction| FormatUtils.format_single_reaction(reaction, REACTION_EMOJI) }
|
|
107
|
+
"#{reactions_label} #{parts.join(' ')}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reactions_label = 'Reactions:'
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats messages for terminal display
|
|
6
|
+
class MessageFormatter
|
|
7
|
+
REACTION_EMOJI = {
|
|
8
|
+
'like' => "\u{1F44D}", 'heart' => "\u{2764}\u{FE0F}",
|
|
9
|
+
'laugh' => "\u{1F602}", 'surprised' => "\u{1F62E}",
|
|
10
|
+
'sad' => "\u{1F622}", 'angry' => "\u{1F620}",
|
|
11
|
+
'yes-tone1' => "\u{1F44D}\u{1F3FB}", 'yes-tone2' => "\u{1F44D}\u{1F3FC}",
|
|
12
|
+
'support' => "\u{1F91D}", 'heartblue' => "\u{1F499}",
|
|
13
|
+
'computer' => "\u{1F4BB}", '1f37f_popcorn' => "\u{1F37F}",
|
|
14
|
+
'1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(output:, cache_store: nil)
|
|
18
|
+
@output = output
|
|
19
|
+
@cache_store = cache_store
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format(message)
|
|
23
|
+
content = highlight_mentions(message.content, message.mentions)
|
|
24
|
+
[format_header(message), " #{content}", format_attachments(message), format_reactions(message)]
|
|
25
|
+
.compact.join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def format_header(message)
|
|
31
|
+
importance = message.important? ? @output.red('!') : ''
|
|
32
|
+
hash = @output.gray(message.short_hash)
|
|
33
|
+
"#{format_timestamp(message)} #{hash} #{importance}#{@output.bold(message.sender_name)}:#{edited_tag(message)}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_timestamp(message) = @output.blue("[#{message.created_at&.strftime('%Y-%m-%d %H:%M')}]")
|
|
37
|
+
|
|
38
|
+
def edited_tag(message) = message.edited? ? " #{@output.gray('(edited)')}" : ''
|
|
39
|
+
|
|
40
|
+
def format_attachments(message)
|
|
41
|
+
attachments = message.attachments
|
|
42
|
+
return unless attachments.any?
|
|
43
|
+
|
|
44
|
+
" #{@output.gray("\u{1F4CE} #{attachment_names(attachments)}")}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_reactions(message)
|
|
48
|
+
reactions = message.reactions
|
|
49
|
+
return unless reactions.any?
|
|
50
|
+
|
|
51
|
+
" #{@output.gray(reaction_parts(reactions))}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def highlight_mentions(content, mentions)
|
|
55
|
+
mentions.inject(content) { |text, name| text.gsub(name, @output.bold(name)) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def attachment_names(attachments)
|
|
59
|
+
attachments.map { |att| FormatUtils.attachment_name(att) }.join(', ')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reaction_parts(reactions)
|
|
63
|
+
reactions.map { |reaction| FormatUtils.format_single_reaction(reaction, REACTION_EMOJI) }.join(' ')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Formatters
|
|
5
|
+
# ANSI color wrapping helpers for Output
|
|
6
|
+
module OutputColors
|
|
7
|
+
def red(text) = wrap(:red, text)
|
|
8
|
+
def green(text) = wrap(:green, text)
|
|
9
|
+
def yellow(text) = wrap(:yellow, text)
|
|
10
|
+
def blue(text) = wrap(:blue, text)
|
|
11
|
+
def magenta(text) = wrap(:magenta, text)
|
|
12
|
+
def cyan(text) = wrap(:cyan, text)
|
|
13
|
+
def gray(text) = wrap(:gray, text)
|
|
14
|
+
def bold(text) = wrap(:bold, text)
|
|
15
|
+
|
|
16
|
+
def with_verbose(mode = :verbose)
|
|
17
|
+
self.class.new(io: @io, err: @err, color: @color, mode: mode)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_quiet(mode = :quiet)
|
|
21
|
+
self.class.new(io: @io, err: @err, color: @color, mode: mode)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Terminal output with ANSI color support
|
|
26
|
+
class Output
|
|
27
|
+
include OutputColors
|
|
28
|
+
|
|
29
|
+
COLORS = {
|
|
30
|
+
red: "\e[0;31m",
|
|
31
|
+
green: "\e[0;32m",
|
|
32
|
+
yellow: "\e[0;33m",
|
|
33
|
+
blue: "\e[0;34m",
|
|
34
|
+
magenta: "\e[0;35m",
|
|
35
|
+
cyan: "\e[0;36m",
|
|
36
|
+
gray: "\e[0;90m",
|
|
37
|
+
bold: "\e[1m",
|
|
38
|
+
reset: "\e[0m"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
attr_reader :mode
|
|
42
|
+
|
|
43
|
+
def initialize(io: $stdout, err: $stderr, color: nil, mode: :normal)
|
|
44
|
+
@io = io
|
|
45
|
+
@err = err
|
|
46
|
+
@color = [true, false].include?(color) ? color : io.tty?
|
|
47
|
+
@mode = mode
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def verbose? = @mode == :verbose
|
|
51
|
+
def quiet? = @mode == :quiet
|
|
52
|
+
def tty? = @io.tty?
|
|
53
|
+
|
|
54
|
+
def puts(message = '')
|
|
55
|
+
write_unless_quiet { @io.puts(message) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def print(message)
|
|
59
|
+
write_unless_quiet { @io.print(message) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flush
|
|
63
|
+
@io.flush
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def error(message)
|
|
67
|
+
@err.puts(colorize("#{red('Error:')} #{message}"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def warn(message)
|
|
71
|
+
write_unless_quiet { @err.puts(colorize("#{yellow('Warning:')} #{message}")) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def success(message)
|
|
75
|
+
puts(colorize("#{green('✓')} #{message}"))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def info(message)
|
|
79
|
+
puts(colorize(message))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def debug(message)
|
|
83
|
+
return unless verbose?
|
|
84
|
+
|
|
85
|
+
@err.puts(colorize("#{gray('[debug]')} #{message}"))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def write_unless_quiet
|
|
91
|
+
yield unless quiet?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def wrap(color, text)
|
|
95
|
+
return text.to_s unless @color
|
|
96
|
+
|
|
97
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def colorize(text)
|
|
101
|
+
text
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a Teams account with authentication tokens
|
|
6
|
+
Account = Data.define(:name, :auth_token, :skype_token, :chatsvc_token, :presence_token) do
|
|
7
|
+
def initialize(name:, auth_token:, skype_token:, chatsvc_token: nil, presence_token: nil)
|
|
8
|
+
validate_tokens!(auth_token, skype_token)
|
|
9
|
+
super(
|
|
10
|
+
name: name.to_s.freeze,
|
|
11
|
+
auth_token: auth_token.to_s.freeze,
|
|
12
|
+
skype_token: skype_token.to_s.freeze,
|
|
13
|
+
chatsvc_token: chatsvc_token&.to_s&.freeze,
|
|
14
|
+
presence_token: presence_token&.to_s&.freeze
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Authorization header for Teams API endpoints
|
|
19
|
+
def teams_auth_header
|
|
20
|
+
"Bearer #{auth_token}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Authorization header for Skype/chat API endpoints
|
|
24
|
+
def skype_auth_header
|
|
25
|
+
"skypetoken=#{skype_token}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Authorization header for chatsvcagg endpoints
|
|
29
|
+
def chatsvc_auth_header
|
|
30
|
+
return nil unless chatsvc_token
|
|
31
|
+
|
|
32
|
+
"Bearer #{chatsvc_token}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Headers for Teams API requests
|
|
36
|
+
def teams_headers
|
|
37
|
+
{
|
|
38
|
+
'Authorization' => teams_auth_header,
|
|
39
|
+
'Content-Type' => 'application/json'
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Headers for Skype API requests
|
|
44
|
+
def skype_headers
|
|
45
|
+
{
|
|
46
|
+
'Authentication' => skype_auth_header,
|
|
47
|
+
'Content-Type' => 'application/json'
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_tokens!(auth_token, skype_token)
|
|
54
|
+
raise ArgumentError, 'auth_token is required' if auth_token.to_s.empty?
|
|
55
|
+
raise ArgumentError, 'skype_token is required' if skype_token.to_s.empty?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a Teams channel within a team
|
|
6
|
+
Channel = Data.define(:id, :name, :team_id, :team_name, :description, :membership_type) do
|
|
7
|
+
def self.from_api(data, team_id: nil, team_name: nil)
|
|
8
|
+
new(
|
|
9
|
+
id: data['id'],
|
|
10
|
+
name: data['displayName'],
|
|
11
|
+
team_id: team_id,
|
|
12
|
+
team_name: team_name,
|
|
13
|
+
description: data['description'],
|
|
14
|
+
membership_type: data['membershipType']
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def display_name
|
|
19
|
+
team_name ? "#{team_name} / #{name}" : name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def private?
|
|
23
|
+
membership_type == 'private'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
"##{name}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
CHAT_TYPE_LABELS = {
|
|
6
|
+
'oneOnOne' => '1:1 Chat', 'group' => 'Group Chat', 'meeting' => 'Meeting Chat',
|
|
7
|
+
'channel' => 'Channel', 'space' => 'Space'
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
# Represents a chat (1:1, group, or meeting chat)
|
|
11
|
+
Chat = Data.define(:id, :topic, :chat_type, :created_at, :last_updated, :unread, :favorite, :pinned) do
|
|
12
|
+
def self.from_api(data)
|
|
13
|
+
# Handle both Graph API format and ng.msg format
|
|
14
|
+
if data['threadProperties']
|
|
15
|
+
from_ngmsg(data)
|
|
16
|
+
else
|
|
17
|
+
from_graph(data)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.from_graph(data)
|
|
22
|
+
new(
|
|
23
|
+
id: data['id'],
|
|
24
|
+
topic: data['topic'],
|
|
25
|
+
chat_type: data['chatType'],
|
|
26
|
+
created_at: parse_time(data['createdDateTime']),
|
|
27
|
+
last_updated: parse_time(data['lastUpdatedDateTime']),
|
|
28
|
+
unread: false, favorite: false, pinned: false
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_ngmsg(data)
|
|
33
|
+
thread_props = data['threadProperties'] || {}
|
|
34
|
+
props = data['properties'] || {}
|
|
35
|
+
new(
|
|
36
|
+
id: data['id'],
|
|
37
|
+
topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
|
|
38
|
+
chat_type: normalize_chat_type(thread_props['threadType']),
|
|
39
|
+
created_at: parse_time(thread_props['createdat']),
|
|
40
|
+
last_updated: parse_time(props['lastimreceivedtime']),
|
|
41
|
+
**ngmsg_status(props, data['lastMessage'])
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.ngmsg_status(props, last_message)
|
|
46
|
+
horizon = props['consumptionhorizon']
|
|
47
|
+
last_msg_id = last_message&.dig('id')
|
|
48
|
+
{
|
|
49
|
+
unread: horizon && last_msg_id ? horizon.split(';').first.to_i < last_msg_id.to_i : false,
|
|
50
|
+
favorite: props['favorite'] == 'true',
|
|
51
|
+
pinned: props['ispinned'] == 'true'
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalize ng.msg threadType to a consistent chat type.
|
|
56
|
+
# ng.msg uses: 'chat' (group), 'meeting', 'topic' (channel), 'space'
|
|
57
|
+
# Normalized to: 'group', 'meeting', 'channel', 'space'
|
|
58
|
+
def self.normalize_chat_type(type)
|
|
59
|
+
case type&.downcase
|
|
60
|
+
when 'chat' then 'group'
|
|
61
|
+
when 'meeting' then 'meeting'
|
|
62
|
+
when 'topic' then 'channel'
|
|
63
|
+
when 'space' then 'space'
|
|
64
|
+
else type
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.parse_time(time_str)
|
|
69
|
+
return nil unless time_str
|
|
70
|
+
|
|
71
|
+
Time.parse(time_str)
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def display_name
|
|
77
|
+
topic.to_s.empty? ? chat_type_label : topic
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def chat_type_label = CHAT_TYPE_LABELS.fetch(chat_type, chat_type)
|
|
81
|
+
|
|
82
|
+
def unread? = unread
|
|
83
|
+
def favorite? = favorite
|
|
84
|
+
def pinned? = pinned
|
|
85
|
+
|
|
86
|
+
def one_on_one?
|
|
87
|
+
chat_type == 'oneOnOne'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def group?
|
|
91
|
+
chat_type == 'group'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def meeting?
|
|
95
|
+
chat_type == 'meeting'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def channel?
|
|
99
|
+
chat_type == 'channel'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def space?
|
|
103
|
+
chat_type == 'space'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def to_s
|
|
107
|
+
display_name
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
DURATION_PATTERN = /\A(?:(\d+)h)?(?:(\d+)m)?\z/
|
|
6
|
+
|
|
7
|
+
# Immutable duration value object for human-friendly time input
|
|
8
|
+
Duration = Data.define(:seconds) do
|
|
9
|
+
def self.parse(input)
|
|
10
|
+
label = input.inspect
|
|
11
|
+
match = DURATION_PATTERN.match(input.to_s.strip)
|
|
12
|
+
raise ArgumentError, "Invalid duration: #{label}" unless match
|
|
13
|
+
|
|
14
|
+
total = (match[1].to_i * 3600) + (match[2].to_i * 60)
|
|
15
|
+
raise ArgumentError, "Duration must be greater than zero: #{label}" if total.zero?
|
|
16
|
+
|
|
17
|
+
new(seconds: total)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_iso8601_duration
|
|
21
|
+
parts = []
|
|
22
|
+
parts << "#{hours}H" if hours.positive?
|
|
23
|
+
parts << "#{minutes}M" if minutes.positive?
|
|
24
|
+
"PT#{parts.join}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_expiration
|
|
28
|
+
(Time.now.utc + seconds).strftime('%Y-%m-%dT%H:%M:%S.0000000Z')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s
|
|
32
|
+
parts = []
|
|
33
|
+
parts << "#{hours}h" if hours.positive?
|
|
34
|
+
parts << "#{minutes}m" if minutes.positive?
|
|
35
|
+
parts.join(' ')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty? = seconds.zero?
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def hours = seconds / 3600
|
|
43
|
+
def minutes = (seconds % 3600) / 60
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
PENDING_RESPONSES = [nil, 'none'].freeze
|
|
6
|
+
|
|
7
|
+
# Represents a calendar event from Microsoft Graph API
|
|
8
|
+
Event = Data.define(
|
|
9
|
+
:id,
|
|
10
|
+
:subject,
|
|
11
|
+
:start_time,
|
|
12
|
+
:end_time,
|
|
13
|
+
:location,
|
|
14
|
+
:is_all_day,
|
|
15
|
+
:organizer,
|
|
16
|
+
:attendees,
|
|
17
|
+
:body_preview,
|
|
18
|
+
:online_meeting_url,
|
|
19
|
+
:show_as,
|
|
20
|
+
:importance,
|
|
21
|
+
:is_cancelled,
|
|
22
|
+
:response_status,
|
|
23
|
+
:sensitivity,
|
|
24
|
+
:event_type
|
|
25
|
+
) do
|
|
26
|
+
extend Parsing
|
|
27
|
+
|
|
28
|
+
def self.from_api(data)
|
|
29
|
+
new(**event_attrs(data))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.event_attrs(data)
|
|
33
|
+
core_attrs(data).merge(detail_attrs(data))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.core_attrs(data)
|
|
37
|
+
{
|
|
38
|
+
id: data['id'],
|
|
39
|
+
subject: data['subject'] || '(No subject)',
|
|
40
|
+
start_time: parse_time(data.dig('start', 'dateTime')),
|
|
41
|
+
end_time: parse_time(data.dig('end', 'dateTime')),
|
|
42
|
+
location: data.dig('location', 'displayName'),
|
|
43
|
+
is_all_day: data['isAllDay'] || false,
|
|
44
|
+
organizer: parse_organizer(data['organizer']),
|
|
45
|
+
attendees: parse_attendees(data['attendees'])
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.detail_attrs(data)
|
|
50
|
+
{
|
|
51
|
+
body_preview: data['bodyPreview'] || strip_html(data.dig('body', 'content')),
|
|
52
|
+
online_meeting_url: data.dig('onlineMeeting', 'joinUrl'),
|
|
53
|
+
show_as: data['showAs'],
|
|
54
|
+
importance: data['importance'],
|
|
55
|
+
is_cancelled: data['isCancelled'] || false,
|
|
56
|
+
response_status: data.dig('responseStatus', 'response'),
|
|
57
|
+
sensitivity: data['sensitivity'],
|
|
58
|
+
event_type: data['type']
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.parse_organizer(organizer_data)
|
|
63
|
+
return nil unless organizer_data
|
|
64
|
+
|
|
65
|
+
email_data = organizer_data['emailAddress'] || {}
|
|
66
|
+
{ name: email_data['name'], email: email_data['address'] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.parse_attendees(attendees_data)
|
|
70
|
+
return [] unless attendees_data.is_a?(Array)
|
|
71
|
+
|
|
72
|
+
attendees_data.map do |att_data|
|
|
73
|
+
email_data = att_data['emailAddress'] || {}
|
|
74
|
+
{
|
|
75
|
+
name: email_data['name'],
|
|
76
|
+
email: email_data['address'],
|
|
77
|
+
type: att_data['type'],
|
|
78
|
+
response: att_data.dig('status', 'response')
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def short_hash = Digest::SHA256.hexdigest(id.to_s)[0, 6]
|
|
84
|
+
|
|
85
|
+
def to_json_hash
|
|
86
|
+
to_h.merge(start_time: start_time&.iso8601, end_time: end_time&.iso8601)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def all_day? = is_all_day
|
|
90
|
+
def cancelled? = is_cancelled
|
|
91
|
+
def recurring? = %w[occurrence exception].include?(event_type)
|
|
92
|
+
|
|
93
|
+
def time_range_display
|
|
94
|
+
return 'ALL DAY' if all_day?
|
|
95
|
+
return '' unless start_time && end_time
|
|
96
|
+
|
|
97
|
+
"#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def date_display
|
|
101
|
+
if all_day?
|
|
102
|
+
"#{start_time&.strftime('%Y-%m-%d')} (all day)"
|
|
103
|
+
elsif start_time && end_time
|
|
104
|
+
"#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def create_summary_lines
|
|
109
|
+
lines = []
|
|
110
|
+
lines << date_display if date_display
|
|
111
|
+
lines << "Location: #{location}" if location && !location.empty?
|
|
112
|
+
lines << "Teams link: #{online_meeting_url}" if online_meeting_url
|
|
113
|
+
lines
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def required_attendees = attendees.select { |att| att[:type] == 'required' }
|
|
117
|
+
def optional_attendees = attendees.select { |att| att[:type] == 'optional' }
|
|
118
|
+
def accepted_attendees = attendees.select { |att| att[:response] == 'accepted' }
|
|
119
|
+
def declined_attendees = attendees.select { |att| att[:response] == 'declined' }
|
|
120
|
+
def tentative_attendees = attendees.select { |att| att[:response] == 'tentativelyAccepted' }
|
|
121
|
+
def pending_attendees = attendees.select { |att| PENDING_RESPONSES.include?(att[:response]) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|