purtea 0.0.1

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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "installed": {
3
+ "client_id": "CLIENT_ID_HERE",
4
+ "project_id": "PROJECT_ID_HERE",
5
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
6
+ "token_uri": "https://oauth2.googleapis.com/token",
7
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
8
+ "client_secret": "CLIENT_SECRET_HERE",
9
+ "redirect_uris": [
10
+ "urn:ietf:wg:oauth:2.0:oob",
11
+ "http://localhost"
12
+ ]
13
+ }
14
+ }
data/lib/purtea.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A tool to handle importing of FF Logs TEA stats to a Google Sheet.
4
+ module Purtea; end
5
+
6
+ require 'purtea/version'
7
+ require 'purtea/logging'
8
+ require 'purtea/config'
9
+
10
+ require 'purtea/fflogs'
11
+ require 'purtea/sheet'
12
+
13
+ require 'purtea/cli'
data/lib/purtea/cli.rb ADDED
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gli'
4
+
5
+ TEA_ZONE_ID = 887
6
+
7
+ def format_percentage(percentage)
8
+ if percentage.nil?
9
+ 'N/A'
10
+ else
11
+ format('%.2f%%', percentage)
12
+ end
13
+ end
14
+
15
+ def float_comp(first, second)
16
+ (first - second).abs < Float::EPSILON
17
+ end
18
+
19
+ def calc_end_phase(fight) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
20
+ return '???' unless fight.zone_id == TEA_ZONE_ID
21
+
22
+ return 'N/A' if fight.boss_percentage.nil? || fight.fight_percentage.nil?
23
+
24
+ if fight.boss_percentage.zero? && fight.fight_percentage >= 80.0
25
+ return 'Living Liquid (LL)'
26
+ end
27
+
28
+ if fight.boss_percentage.positive? && fight.fight_percentage > 75.0
29
+ return 'Living Liquid (LL)'
30
+ end
31
+
32
+ if fight.boss_percentage.zero? && float_comp(fight.fight_percentage, 75.0)
33
+ return 'Limit Cut (LC)'
34
+ end
35
+
36
+ if fight.fight_percentage >= 50.0 && fight.fight_percentage <= 75.0 &&
37
+ fight.boss_percentage >= 0.0
38
+ return 'Brute Justice / Cruise Chaser (BJ/CC)'
39
+ end
40
+
41
+ if fight.fight_percentage <= 50.0 && fight.fight_percentage >= 30.0
42
+ return 'Alexander Prime (AP)'
43
+ end
44
+
45
+ 'Perfect Alexander (PA)'
46
+ end
47
+
48
+ def parse_fight(fight) # rubocop:disable Metrics/AbcSize
49
+ [
50
+ fight.id,
51
+ fight.encounter_id,
52
+ fight.zone_id,
53
+ fight.zone_name,
54
+ fight.name,
55
+ fight.difficulty,
56
+ format_percentage(fight.boss_percentage),
57
+ format_percentage(fight.fight_percentage),
58
+ fight.start_at.strftime(Purtea::FFLogs::Fight::ISO_FORMAT),
59
+ fight.end_at.strftime(Purtea::FFLogs::Fight::ISO_FORMAT),
60
+ fight.duration.strftime('%H:%M:%S'),
61
+ calc_end_phase(fight),
62
+ fight.kill? ? 'Y' : 'N'
63
+ ]
64
+ end
65
+
66
+ module Purtea
67
+ # Defines the CLI interface for Purtea.
68
+ class CLI
69
+ extend GLI::App
70
+
71
+ program_desc 'FF Logs TEA stats import tool'
72
+ program_long_desc <<~LONGDESC
73
+ This tool helps you import TEA stats from FF Logs and writing it to a
74
+ Google Sheet.
75
+ LONGDESC
76
+ version Purtea::VERSION
77
+
78
+ subcommand_option_handling :normal
79
+ arguments :strict
80
+
81
+ desc 'Be verbose'
82
+ switch %i[v verbose]
83
+
84
+ pre do |global_options, _options, _args|
85
+ if global_options[:verbose]
86
+ Purtea.logger.debug!
87
+ else
88
+ Purtea.logger.info!
89
+ end
90
+ end
91
+
92
+ desc 'Import FF Logs report to Google Sheet'
93
+ long_desc <<~LONGDESC
94
+ report_code should be the code for a report containing TEA fights on
95
+ FF Logs.
96
+
97
+ The tool will read the fight stats and import it as a table into the
98
+ configured Google Sheet (at the configured range).
99
+ LONGDESC
100
+ arg :report_code
101
+ command :import do |c|
102
+ c.action do |_, _, args|
103
+ code = args.shift
104
+ config = Purtea::Config.load
105
+ fflogs_api = Purtea::FFLogs::API.new(
106
+ config['fflogs']['client_id'],
107
+ config['fflogs']['client_secret']
108
+ )
109
+ fights = fflogs_api.fights(code).select { |f| f.zone_id == TEA_ZONE_ID }
110
+
111
+ spreadsheet_data = fights.map { |f| parse_fight(f) }
112
+
113
+ sheet = Purtea::SheetApi.new(config['sheet']['id'])
114
+ sheet.append(config['sheet']['range'], spreadsheet_data)
115
+ end
116
+ end
117
+
118
+ desc 'Tests the logging system'
119
+ command :testlog do |c|
120
+ c.action do
121
+ l = Purtea.logger
122
+ l.debug('This is a debug log')
123
+ l.info('This is an info log')
124
+ l.warn('This is a warn log')
125
+ l.error('This is an error log')
126
+ l.fatal('This is a fatal log')
127
+ l.unknown('This is an unknown log')
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv/load'
4
+ require 'fileutils'
5
+ require 'tomlrb'
6
+
7
+ module Purtea
8
+ # Contains methods to load the Purtea config.
9
+ module Config
10
+ DEFAULT_CONFIG_PATH = 'config.toml'
11
+ DEFAULT_ENV_PREFIX = 'PURTEA_'
12
+ ENV_NESTED_SEPARATOR = '__'
13
+
14
+ class << self
15
+ def load(path = DEFAULT_CONFIG_PATH, env_prefix: DEFAULT_ENV_PREFIX)
16
+ path = resolve_file path, create_dir: true
17
+ config = if File.exist? path
18
+ Tomlrb.load_file path
19
+ else
20
+ {}
21
+ end
22
+
23
+ ENV.select { |k, _| k.start_with? env_prefix }.each do |key, value|
24
+ key_path = key
25
+ .delete_prefix(env_prefix)
26
+ .split(ENV_NESTED_SEPARATOR)
27
+ .map(&:downcase)
28
+ set_hash_by_path config, key_path, value unless key_path.empty?
29
+ end
30
+
31
+ config
32
+ end
33
+
34
+ def resolve_directory(create: false)
35
+ base = ENV['XDG_CONFIG_HOME'] || File.join(Dir.home, '.config')
36
+ File.join(base, 'purtea').tap do |path|
37
+ if create && !Dir.exist?(path)
38
+ Purtea.logger.debug(
39
+ "Config directory doesn't exist, creating #{path}"
40
+ )
41
+ FileUtils.mkpath(path)
42
+ end
43
+ end
44
+ end
45
+
46
+ def resolve_file(file, create_dir: false)
47
+ directory = resolve_directory create: create_dir
48
+ File.join directory, file
49
+ end
50
+
51
+ private
52
+
53
+ def set_hash_by_path(hash, key_path, value)
54
+ if key_path.size == 1
55
+ hash[key_path[0]] = value
56
+ return
57
+ end
58
+
59
+ key = key_path.shift
60
+
61
+ if hash[key].nil?
62
+ hash[key] = {}
63
+ elsif !hash[key].is_a? Hash
64
+ raise ArgumentError, 'Specified key path contains a value'
65
+ end
66
+
67
+ set_hash_by_path hash[key], key_path, value
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'graphql/client'
4
+ require 'graphql/client/http'
5
+
6
+ require 'purtea/fflogs/fight'
7
+ require 'purtea/fflogs/api'
8
+
9
+ module Purtea
10
+ module FFLogs
11
+ BASE_URL = 'https://www.fflogs.com'
12
+ API_URL = "#{BASE_URL}/api/v2/client"
13
+ SCHEMA_FILE = File.expand_path('../../fflogs_schema.json', __dir__)
14
+
15
+ HTTP = GraphQL::Client::HTTP.new(API_URL) do
16
+ def headers(context)
17
+ unless (token = context[:access_token])
18
+ raise 'Missing FF Logs access token'
19
+ end
20
+
21
+ {
22
+ 'Authorization' => "Bearer #{token}"
23
+ }
24
+ end
25
+ end
26
+
27
+ CLIENT = GraphQL::Client.new(
28
+ schema: SCHEMA_FILE,
29
+ execute: HTTP
30
+ )
31
+
32
+ GET_FIGHTS_QUERY = CLIENT.parse <<-GRAPHQL
33
+ query($code: String) {
34
+ reportData {
35
+ report(code: $code) {
36
+ startTime
37
+ endTime
38
+ fights {
39
+ id
40
+ encounterID
41
+ gameZone {
42
+ id
43
+ name
44
+ }
45
+ name
46
+ difficulty
47
+ bossPercentage
48
+ fightPercentage
49
+ startTime
50
+ endTime
51
+ kill
52
+ }
53
+ }
54
+ }
55
+ rateLimitData {
56
+ limitPerHour
57
+ pointsResetIn
58
+ pointsSpentThisHour
59
+ }
60
+ }
61
+ GRAPHQL
62
+ end
63
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oauth2'
4
+ require 'time'
5
+
6
+ module Purtea
7
+ module FFLogs
8
+ TOKEN_FILE = Purtea::Config.resolve_file(
9
+ 'fflogs_token.json',
10
+ create_dir: true
11
+ )
12
+ EXPIRATION_THRESHOLD = 30
13
+
14
+ # Contains methods to interact with the FF Logs API.
15
+ class API
16
+ def initialize(client_id, client_secret)
17
+ @client_id = client_id
18
+ @client_secret = client_secret
19
+ @oa_client = OAuth2::Client.new(
20
+ @client_id, @client_secret, site: BASE_URL
21
+ )
22
+ authorize!
23
+ end
24
+
25
+ def dump_schema(is_retry: false)
26
+ result = GraphQL::Client.dump_schema(
27
+ Purtea::FFLogs::HTTP,
28
+ SCHEMA_FILE,
29
+ context: { access_token: @token.token }
30
+ )
31
+
32
+ err_code = result&.dig('errors', 0, 'message')&.[](0..2)
33
+ if err_code && err_code == '401'
34
+ return result if is_retry
35
+
36
+ Purtea.logger.info 'FF Logs API token expired or revoked, getting new'
37
+
38
+ authorize! true
39
+ return dump_schema true
40
+ end
41
+
42
+ result
43
+ end
44
+
45
+ def fights(code)
46
+ result = CLIENT.query(
47
+ GET_FIGHTS_QUERY,
48
+ variables: { code: code },
49
+ context: { access_token: @token.token }
50
+ )
51
+
52
+ report = result.data.report_data.report
53
+ report.fights.map { |d| Fight.new d, report.start_time }
54
+ end
55
+
56
+ def authorize!(force_refresh: false)
57
+ Purtea.logger.debug 'Authorize FF Logs API'
58
+
59
+ unless force_refresh
60
+ load_token!
61
+ return unless token_expired?
62
+ end
63
+
64
+ refresh_token!
65
+ save_token
66
+ end
67
+
68
+ def save_token
69
+ return if @token.nil?
70
+
71
+ Purtea.logger.debug 'Saving FF Logs API token to file'
72
+ token_hash = @token.to_hash
73
+ token_json = token_hash.to_json
74
+ File.open(TOKEN_FILE, 'w') { |f| f.write token_json }
75
+ end
76
+
77
+ def load_token!
78
+ return unless File.exist? TOKEN_FILE
79
+
80
+ Purtea.logger.debug 'Loading FF Logs API token from file'
81
+ token_hash = JSON.load_file TOKEN_FILE
82
+ @token = OAuth2::AccessToken.from_hash(@oa_client, token_hash)
83
+ end
84
+
85
+ def refresh_token!
86
+ # FF Logs does not issue refresh tokens
87
+ Purtea.logger.debug 'Refreshing FF Logs API token'
88
+ @token = @oa_client.client_credentials.get_token
89
+ end
90
+
91
+ def token_expired?
92
+ return true if @token.nil?
93
+
94
+ expires_at = Time.at(@token.expires_at)
95
+ time_until_expire = expires_at - Time.now
96
+ time_until_expire < EXPIRATION_THRESHOLD
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purtea
4
+ module FFLogs
5
+ # Describes a fight entry in FF Logs.
6
+ class Fight
7
+ ISO_FORMAT = '%Y-%m-%dT%H:%M:%S%:z'
8
+
9
+ attr_reader :id, :encounter_id, :zone_id, :name, :zone_name, :difficulty,
10
+ :boss_percentage, :fight_percentage, :start_at, :end_at,
11
+ :duration
12
+
13
+ # rubocop:disable Metrics/AbcSize
14
+ def initialize(data, report_start_ms)
15
+ @id = data.id
16
+ @encounter_id = data.encounter_id
17
+ @zone_id = data.game_zone.id
18
+ @name = data.name
19
+ @zone_name = data.game_zone.name
20
+ @difficulty = data.difficulty
21
+ @boss_percentage = data.boss_percentage
22
+ @fight_percentage = data.fight_percentage
23
+ @start_at = parse_fight_timestamp report_start_ms, data.start_time
24
+ @end_at = parse_fight_timestamp report_start_ms, data.end_time
25
+ @kill = data.kill
26
+ @duration = parse_duration @start_at, @end_at
27
+ end
28
+ # rubocop:enable Metrics/AbcSize
29
+
30
+ def kill?
31
+ @kill
32
+ end
33
+
34
+ def to_s
35
+ start_f = start_at.strftime(ISO_FORMAT)
36
+ end_f = end_at.strftime(ISO_FORMAT)
37
+ duration = Time.at(end_at - start_at).utc.strftime('%H:%M:%S')
38
+ kw = kill? ? 'CLEAR' : 'WIPE'
39
+ "[#{@id}] #{start_f} -> #{end_f} [#{duration}] " \
40
+ "#{boss_percentage}% (#{fight_percentage}%) - #{kw}"
41
+ end
42
+
43
+ private
44
+
45
+ def parse_fight_timestamp(report_start_ms, time)
46
+ Time.at(0, report_start_ms + time, :millisecond)
47
+ end
48
+
49
+ def parse_duration(start_at, end_at)
50
+ Time.at(end_at - start_at).utc
51
+ end
52
+ end
53
+ end
54
+ end