diff --git a/.gitignore b/.gitignore index c854bfd7..9424cace 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,5 @@ wsgi.py # Docs docs/build/ + +.tool-versions diff --git a/application/controllers/api/v1/__init__.py b/application/controllers/api/v1/__init__.py index 1a852bd7..f707b14d 100644 --- a/application/controllers/api/v1/__init__.py +++ b/application/controllers/api/v1/__init__.py @@ -360,6 +360,7 @@ mod.add_url_rule("/api/v1/user/guilds", view_func=user.get_admin_guilds, methods=["GET"]) mod.add_url_rule("/api/v1/user/settings/cpr", view_func=user_settings.toggle_cpr, methods=["PUT"]) mod.add_url_rule("/api/v1/user/settings/stat-db", view_func=user_settings.toggle_stat_db, methods=["PUT"]) +mod.add_url_rule("/api/v1/user/settings/od-drug", view_func=user_settings.toggle_od_drug, methods=["PUT"]) mod.add_url_rule("/api/v1/user/", view_func=user.get_specific_user, methods=["GET"]) mod.add_url_rule( "/api/v1/user/estimate/", diff --git a/application/controllers/api/v1/user_settings/__init__.py b/application/controllers/api/v1/user_settings/__init__.py index 186af344..8d20fb71 100644 --- a/application/controllers/api/v1/user_settings/__init__.py +++ b/application/controllers/api/v1/user_settings/__init__.py @@ -52,3 +52,19 @@ def toggle_stat_db(*args, **kwargs): settings = UserSettings.create_or_update(kwargs["user"].tid, stat_db_enabled=enabled) return jsonify(settings.to_dict()), api_ratelimit_response(key) + + +@session_required +@ratelimit +def toggle_od_drug(*args, **kwargs): + data = json.loads(request.get_data().decode("utf-8")) + key = f"tornium:ratelimit:{kwargs['user'].tid}" + + enabled = data.get("enabled") + + if not isinstance(enabled, bool): + return make_exception_response("0000", key) + + settings = UserSettings.create_or_update(kwargs["user"].tid, od_drug_enabled=enabled) + + return jsonify(settings.to_dict()), api_ratelimit_response(key) diff --git a/application/static/settings/settings.js b/application/static/settings/settings.js index a3014b95..89d72a23 100644 --- a/application/static/settings/settings.js +++ b/application/static/settings/settings.js @@ -131,6 +131,13 @@ ready(() => { toggleSetting("stat-db", false); }); + document.getElementById("od-drug-toggle-enable").addEventListener("click", (event) => { + toggleSetting("od-drug", true); + }); + document.getElementById("od-drug-toggle-disable").addEventListener("click", (event) => { + toggleSetting("od-drug", false); + }); + Array.from(document.getElementsByClassName("revoke-client")).forEach((button) => { button.addEventListener("click", revokeClient); }); diff --git a/application/templates/settings/settings.html b/application/templates/settings/settings.html index dcd5aaca..2d922133 100644 --- a/application/templates/settings/settings.html +++ b/application/templates/settings/settings.html @@ -146,6 +146,7 @@
Tornium API Key Settings
  • +

    Collect and share your CPR data for all available organized crimes for the faction CPR viewer.

    @@ -153,6 +154,7 @@
    Tornium API Key Settings
  • +
  • Collect and share your opponents' stats via fair fight for the stat database.

    @@ -163,6 +165,17 @@
    Tornium API Key Settings
  • + +
  • + +

    Collect and share the drug you've overdosed on from your logs.

    + + + + + + +
  • diff --git a/commons/tornium_commons/models/user_settings.py b/commons/tornium_commons/models/user_settings.py index 42436d3c..75c83525 100644 --- a/commons/tornium_commons/models/user_settings.py +++ b/commons/tornium_commons/models/user_settings.py @@ -31,6 +31,7 @@ class Meta: cpr_enabled = BooleanField(default=True, null=False) stat_db_enabled = BooleanField(default=True, null=False) + od_drug_enabled = BooleanField(default=False, null=False) def create_or_update(user_id: int, **kwargs: dict): """ diff --git a/worker/.iex.exs b/worker/.iex.exs new file mode 100644 index 00000000..128c00ae --- /dev/null +++ b/worker/.iex.exs @@ -0,0 +1 @@ +IEx.configure(auto_reload: true) diff --git a/worker/config/config.exs b/worker/config/config.exs index 2c0afd5d..5b5bcba2 100644 --- a/worker/config/config.exs +++ b/worker/config/config.exs @@ -80,8 +80,9 @@ config :tornium, Oban, {"*/5 * * * *", Tornium.Workers.OCUpdateScheduler}, {"0 12 * * *", Tornium.Workers.OCCPRUpdateScheduler}, {"0 0 * * *", Tornium.Workers.OAuthRevocation}, - {"*/30 * * * *", Tornium.Workers.OverdoseUpdateScheduler}, - {"10 0 * * *", Tornium.Workers.OverdoseDailyReport} + {"7,37 * * * *", Tornium.Workers.OverdoseUpdateScheduler}, + {"15 0 * * *", Tornium.Workers.OverdoseDailyReport}, + {"*/15 * * * *", Tornium.Workers.ArmoryNewsUpdateScheduler} ] }, {Oban.Plugins.Pruner, max_age: 60 * 60 * 24}, diff --git a/worker/lib/application.ex b/worker/lib/application.ex index 267a948a..7ba55317 100644 --- a/worker/lib/application.ex +++ b/worker/lib/application.ex @@ -54,7 +54,8 @@ defmodule Tornium.Application do Tornex.HTTP.FinchClient, Tornex.Scheduler.Supervisor, {Oban, Application.fetch_env!(:tornium, Oban)}, - Tornium.Web.Endpoint + Tornium.Web.Endpoint, + Tornium.Item.NameCache ] Supervisor.start_link(children, strategy: :one_for_one, name: Tornium.Supervisor) diff --git a/worker/lib/faction/news/armory_action.ex b/worker/lib/faction/news/armory_action.ex new file mode 100644 index 00000000..94ef500f --- /dev/null +++ b/worker/lib/faction/news/armory_action.ex @@ -0,0 +1,174 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +defmodule Tornium.Faction.News.ArmoryAction do + @moduledoc """ + Struct containing parsed `armoryAction` news. + """ + + @behaviour Tornium.Faction.News + + defstruct [:timestamp, :id, :action, :user_id, :item_id, :quantity] + + @typedoc """ + The item ID of the item used from the armory or the type of refill used. + """ + @type armory_item :: non_neg_integer() | :energy_refill | :nerve_refill + + @type t :: %__MODULE__{ + timestamp: DateTime.t(), + id: String.t(), + action: Tornium.Schema.ArmoryUsage.actions(), + user_id: non_neg_integer(), + item_id: armory_item(), + quantity: non_neg_integer() + } + + @impl Tornium.Faction.News + def parse(%Torngen.Client.Schema.FactionNews{timestamp: timestamp, text: text, id: id} = _news) do + %{ + user_id: user_id, + action: action, + item_id: item_id, + quantity: quantity + } = + text + |> Floki.parse_fragment!() + |> do_parse_text() + + %__MODULE__{ + timestamp: DateTime.from_unix!(timestamp), + id: id, + action: action, + user_id: user_id, + item_id: item_id, + quantity: quantity + } + end + + @spec do_parse_text(tree :: Floki.html_tree()) :: %{ + user_id: non_neg_integer(), + action: Tornium.Schema.ArmoryUsage.actions(), + item_id: armory_item(), + quantity: non_neg_integer() + } + defp do_parse_text([{"a", [{"href", faction_member_link}], [_faction_member_name]}, text_remainder] = _tree) do + # e.g. for "Kagat used one of the faction's Blood Bag : O+ items" + # faction_member_link = http://www.torn.com/profiles.php?XID=3870167 + # faction_member_name = Kagat + # text_remainder = " used one of the faction's Blood Bag : O+ items" + + user_id = + faction_member_link + |> URI.parse() + |> Map.get(:query) + |> URI.decode_query() + |> Map.get("XID") + |> String.to_integer() + + %{ + action: action, + quantity: quantity, + item_name: item_name + } = + text_remainder + |> String.trim() + |> text_action_item() + + item_id = + case item_name do + :energy_refill -> + :energy_refill + + :nerve_refill -> + :nerve_refill + + _ when is_binary(item_name) -> + Tornium.Item.NameCache.get_by_name(item_name) + end + + %{user_id: user_id, action: action, item_id: item_id, quantity: quantity} + end + + @spec text_action_item(text :: String.t()) :: + %{ + action: Tornium.Schema.ArmoryUsage.actions(), + quantity: non_neg_integer(), + item_name: armory_item() | String.t() + } + | nil + defp text_action_item("used " <> suffixed_item_string = text) when is_binary(text) do + split_item_string = + String.split(suffixed_item_string, [" of the faction's ", " items", " to refill their "], trim: true) + + case split_item_string do + ["one", item_name] -> + %{ + action: :use, + quantity: 1, + item_name: item_name + } + + [count, "points", "energy"] -> + %{ + action: :use, + quantity: String.to_integer(count), + item_name: :energy_refill + } + + [count, "points", "nerve"] -> + # eg: "tiksan used 30 of the faction's points to refill their nerve" + %{ + action: :use, + quantity: String.to_integer(count), + item_name: :nerve_refill + } + end + end + + defp text_action_item("filled one of the faction's " <> suffixed_item_string = text) when is_binary(text) do + [item_name] = String.split(suffixed_item_string, " items", trim: true) + + %{ + action: :fill, + quantity: 1, + item_name: item_name + } + end + + defp text_action_item("loaned " <> suffixed_item_string = text) when is_binary(text) do + [item_name_quantity, _recipient] = + String.split(suffixed_item_string, [" to ", " from the faction armory"], trim: true) + + # loaned 1x Thompson to themselves from the faction armory + [item_quantity, item_name] = String.split(item_name_quantity, "x ", trim: true) + + %{ + action: :loan, + quantity: String.to_integer(item_quantity), + item_name: item_name + } + end + + defp text_action_item("returned " <> item_name_quantity = text) when is_binary(text) do + [item_quantity, item_name] = String.split(item_name_quantity, "x ", trim: true) + + %{ + action: :return, + quantity: String.to_integer(item_quantity), + item_name: item_name + } + end +end diff --git a/worker/lib/faction/news/news.ex b/worker/lib/faction/news/news.ex new file mode 100644 index 00000000..ab2e015c --- /dev/null +++ b/worker/lib/faction/news/news.ex @@ -0,0 +1,40 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +defmodule Tornium.Faction.News do + @moduledoc """ + Parser for faction news data from `faction/news`. + """ + + @doc """ + Parse a faction news for a specific category. + """ + @spec parse( + category :: Torngen.Client.Schema.FactionNewsCategory.t(), + news_data :: [Torngen.Client.Schema.FactionNews.t()] + ) :: [struct()] + def parse("armoryAction", [%Torngen.Client.Schema.FactionNews{} | _] = news_data) do + Enum.map(news_data, &Tornium.Faction.News.ArmoryAction.parse/1) + end + + def parse(_category, [] = _news_data) do + [] + end + + @doc """ + Parse a faction news struct from Torn for a specific category. + """ + @callback parse(news :: Torngen.Client.Schema.FactionNews.t()) :: struct() +end diff --git a/worker/lib/faction/overdose.ex b/worker/lib/faction/overdose.ex index ae95fe86..9734b205 100644 --- a/worker/lib/faction/overdose.ex +++ b/worker/lib/faction/overdose.ex @@ -18,7 +18,34 @@ defmodule Tornium.Faction.Overdose do Functions to collate overdose data. """ + import Ecto.Query alias Torngen.Client.Schema.FactionContributor + alias Tornium.Repo + + @typedoc """ + An overdose event map. + """ + @type event :: %{ + faction_id: non_neg_integer(), + user_id: non_neg_integer(), + created_at: DateTime.t(), + drug: non_neg_integer() | nil + } + + @drug_items %{ + 196 => "Cannabis", + 197 => "Ecstasy", + 198 => "Ketamine", + 199 => "LSD", + 200 => "Opium", + 201 => "PCP", + 203 => "Shrooms", + 204 => "Speed", + 205 => "Vicodin", + 206 => "Xanax", + 870 => "Love Juice" + } + @drug_item_ids @drug_items |> Map.keys() @doc """ Map the overdose count of faction members and their IDs. @@ -62,22 +89,20 @@ defmodule Tornium.Faction.Overdose do @doc """ Map the updated overdose counts to `Tornium.Schema.OverdoseEvent`. """ - @spec map_events(overdosed_members :: [Tornium.Schema.OverdoseCount.t()], faction_id :: integer()) :: [ - %{faction_id: integer(), user_id: integer(), created_at: DateTime.t(), drug: term()} - ] + @spec map_events(overdosed_members :: [Tornium.Schema.OverdoseCount.t()], faction_id :: integer()) :: [event()] def map_events([%Tornium.Schema.OverdoseCount{} | _] = overdosed_members, faction_id) when is_integer(faction_id) do - # TODO: Determine the drug used - # TODO: Test this - - Enum.map(overdosed_members, fn %Tornium.Schema.OverdoseCount{user_id: member_id} when is_integer(member_id) -> - %{ - guid: Ecto.UUID.generate(), - faction_id: faction_id, - user_id: member_id, - created_at: DateTime.utc_now(:second), - drug: nil - } - end) + Enum.map( + overdosed_members, + fn %Tornium.Schema.OverdoseCount{user_id: member_id} when is_integer(member_id) -> + %{ + guid: Ecto.UUID.generate(), + faction_id: faction_id, + user_id: member_id, + created_at: DateTime.utc_now(:second), + drug: nil + } + end + ) end def map_events([] = _overdosed_members, faction_id) when is_integer(faction_id) do @@ -145,6 +170,74 @@ defmodule Tornium.Faction.Overdose do |> do_to_report_embed(events) end + @doc """ + Attempt to determine the drug used in an overdose event. + + 1) Check if there's a use event for the user in the faction armory logs. + 2) Check the user's logs if enabled and the user has a full access API key + 3) Set drug to `nil` as it is unknown. + """ + @spec set_drug_used(event :: event(), overdose_last_updated :: DateTime.t() | nil) :: event() + def set_drug_used(%{faction_id: faction_id, user_id: user_id, created_at: created_at} = event, overdose_last_updated) + when not is_nil(overdose_last_updated) do + armory_usage_logs = + Tornium.Schema.ArmoryUsage + |> where([u], u.faction_id == ^faction_id and u.user_id == ^user_id and u.item_id in @drug_item_ids) + |> where([u], u.timestamp >= ^overdose_last_updated and u.timestamp <= ^created_at) + |> Repo.all() + + case armory_usage_logs do + [%Tornium.Schema.ArmoryUsage{item_id: item_id}] -> + # Set the drug used from the armory usage log since there's only one potential drug used log + Map.put(event, :drug, item_id) + + _ -> + # One of the following is true: + # - There are no armory usage logs matching the timeframe + # - There are more than one logs matching the timeframe + # + # Therefore, we should try to use the user's logs if we are able to. + set_drug_used_logs(event, overdose_last_updated) + end + end + + def set_drug_used(event, overdose_last_updated) when is_map(event) and is_nil(overdose_last_updated) do + event + end + + @spec set_drug_used_logs(event :: event(), overdose_last_updated :: DateTime.t()) :: event() + defp set_drug_used_logs(%{user_id: user_id, created_at: _created_at} = event, overdose_last_updated) + when is_integer(user_id) do + api_key = Tornium.User.Key.get_by_user(user_id) + + if Tornium.Schema.UserSettings.od_drug?(user_id) and not is_nil(api_key) and api_key.access_level == :full do + # The user needs to have their `od_drug_enabled` set to true and to have a default full access API + # key for this to pull their overdose logs. + + overdose_logs = get_user_overdoses(api_key, overdose_last_updated) + + case overdose_logs do + [%Torngen.Client.Schema.UserLog{timestamp: overdosed_at, data: %{"item" => overdosed_item_id}}] -> + event + |> Map.put(:created_at, overdosed_at) + |> Map.put(:drug, overdosed_item_id) + + _ -> + # One of the following is true: + # - There are no overdose logs + # - The logs are of the wrong shape + # - There are more than one logs, so we can not be sure what drug the user overdosed on. + Map.put(event, :drug, nil) + end + + event + else + # Ensure the drug is set to `nil` so the armory usage logs can attempt to find a matching log + # during its next run + Map.put(event, :drug, nil) + end + end + defp do_to_report_embed(%Nostrum.Struct.Embed{description: description} = embed, [ %Tornium.Schema.OverdoseEvent{ drug: drug, @@ -184,4 +277,58 @@ defmodule Tornium.Faction.Overdose do defp do_to_report_embed(%Nostrum.Struct.Embed{} = embed, []) do embed end + + @doc """ + Get all overdose logs for a user. + """ + @spec get_user_overdoses(api_key :: Tornium.Schema.TornKey.t(), from_timestamp :: DateTime.t()) :: [ + Torngen.Client.Schema.UserLog.t() + ] + def get_user_overdoses(%Tornium.Schema.TornKey{api_key: api_key} = _api_key, from_timestamp) do + query = + Tornex.SpecQuery.new(key: api_key) + |> Tornex.SpecQuery.put_path(Torngen.Client.Path.User.Log) + |> Tornex.SpecQuery.put_parameter(:log, [ + # Cannabis overdose + 2201, + # Ecstacy overdose + 2211, + # Ketamine overdose + 2221, + # LSD overdose + 2231, + # Opium overdose + 2241, + # PCP overdose + 2251, + # Shrooms overdose + 2261, + # Speed overdose + 2281, + # Vicodin overdose + 2281, + # Xanax overdose + 2291 + ]) + |> Tornex.SpecQuery.put_parameter(:from, DateTime.to_unix(from_timestamp)) + |> Tornex.SpecQuery.put_parameter(:limit, 100) + + response = Tornex.API.get(query) + + case response do + {:error, _error} -> + [] + + _ -> + %{ + Torngen.Client.Path.User.Log => %{ + UserLogsResponse => %Torngen.Client.Schema.UserLogsResponse{ + log: logs + } + } + } = Tornex.SpecQuery.parse(query, response) + + logs + end + end end diff --git a/worker/lib/faction/schema/armory_usage.ex b/worker/lib/faction/schema/armory_usage.ex new file mode 100644 index 00000000..91040fa0 --- /dev/null +++ b/worker/lib/faction/schema/armory_usage.ex @@ -0,0 +1,86 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +defmodule Tornium.Schema.ArmoryUsage do + @moduledoc """ + Logs of usage of items and points from the faction armory. + """ + + use Ecto.Schema + alias Tornium.Repo + + @typedoc """ + Possible actions in the faction news with the following mapping: + + "used" => `:use` + "loaned" => `:loan` + "returned" => `:return` + "filled" => `:fill` + "give" => `:give` + """ + @type actions :: :use | :loan | :return | :fill | :give + + @type t :: %__MODULE__{ + timestamp: DateTime.t(), + id: String.t(), + action: actions(), + user_id: non_neg_integer(), + user: Tornium.Schema.User.t(), + faction_id: non_neg_integer(), + faction: Tornium.Schema.Faction.t(), + item_id: non_neg_integer() | nil, + item: Tornium.Schema.Item.t() | nil, + is_energy_refill: boolean(), + is_nerve_refill: boolean(), + quantity: non_neg_integer() + } + + @primary_key {:id, :string, autogenerate: false} + schema "armory_usage" do + field(:timestamp, :utc_datetime) + field(:action, Ecto.Enum, values: [:use, :loan, :return, :fill, :give]) + + belongs_to(:user, Tornium.Schema.User, references: :tid) + belongs_to(:faction, Tornium.Schema.Faction, references: :tid) + + belongs_to(:item, Tornium.Schema.Item, references: :tid) + field(:is_energy_refill, :boolean) + field(:is_nerve_refill, :boolean) + + field(:quantity, :integer) + end + + @doc """ + Insert multiple `Tornium.Faction.News.ArmoryAction` news logs for a specific fction. + """ + @spec insert_all(news :: [Tornium.Faction.News.ArmoryAction.t()], faction_id :: non_neg_integer()) :: + {non_neg_integer(), nil | [term()]} + def insert_all([%Tornium.Faction.News.ArmoryAction{} | _] = news, faction_id) when is_integer(faction_id) do + mapped_news = + Enum.map(news, fn %Tornium.Faction.News.ArmoryAction{} = armory_news -> map(armory_news, faction_id) end) + + Repo.insert_all(__MODULE__, mapped_news, on_conflict: :nothing, conflict_target: [:id]) + end + + @doc """ + Map an `ArmoryAction` struct to a `ArmoryUsage` Ecto struct. + """ + @spec map(news :: Tornium.Faction.News.ArmoryAction.t(), faction_id :: non_neg_integer()) :: map() + def map(%Tornium.Faction.News.ArmoryAction{} = news, faction_id) when is_integer(faction_id) do + news + |> Map.put(:faction_id, faction_id) + |> Map.delete(:__struct__) + end +end diff --git a/worker/lib/item/name_cache.ex b/worker/lib/item/name_cache.ex new file mode 100644 index 00000000..b6a880e2 --- /dev/null +++ b/worker/lib/item/name_cache.ex @@ -0,0 +1,112 @@ +defmodule Tornium.Item.NameCache do + @moduledoc """ + A bidirectional item name cache for mapping item IDs to item names and vice versa. + + ``` + Item ID <=> Item Name + ``` + """ + + use Agent + + @type state :: %{ + forward: %{non_neg_integer() => String.t()}, + backward: %{String.t() => non_neg_integer()}, + ttl: non_neg_integer(), + ttl_unit: :day | :hour | :minute | System.time_unit(), + expiration: DateTime.t() + } + + @spec start_link(opts :: keyword()) :: Agent.on_start() + def start_link(opts \\ []) do + ttl = Keyword.get(opts, :ttl, 3600) + ttl_unit = Keyword.get(opts, :ttl_unit, :second) + + Agent.start( + fn -> + %{ + forward: %{}, + backward: %{}, + ttl: ttl, + ttl_unit: ttl_unit, + expiration: nil + } + end, + name: __MODULE__ + ) + end + + @doc """ + Get the item name by its ID. + """ + @spec get_by_id(item_id :: non_neg_integer()) :: String.t() + def get_by_id(item_id) when is_integer(item_id) do + ensure_fresh() + Agent.get(__MODULE__, &Map.get(&1.forward, item_id)) + end + + @doc """ + Get the item ID by its name. + """ + @spec get_by_name(item_name :: String.t()) :: non_neg_integer() + def get_by_name(item_name) when is_binary(item_name) do + ensure_fresh() + Agent.get(__MODULE__, &Map.get(&1.backward, item_name)) + end + + @doc """ + Get all items in the map. + """ + @spec all() :: %{non_neg_integer() => String.t()} + def all() do + Agent.get(__MODULE__, & &1.forward) + end + + @doc """ + Update the items in the bimap if the data is expired. + + This is a non-blocking operation. + """ + @spec ensure_fresh() :: term() + def ensure_fresh() do + if expired?(), do: rebuild() + end + + def rebuild() do + # TODO: Revert to defp and Agent.cast + Agent.update(__MODULE__, fn %{ttl: ttl, ttl_unit: ttl_unit} = state -> + items_forward = + Tornium.Schema.Item.all() + |> Enum.map(fn %Tornium.Schema.Item{tid: item_id, name: item_name} -> {item_id, item_name} end) + |> Map.new() + + items_backward = Map.new(items_forward, fn {item_id, item_name} -> {item_name, item_id} end) + + state + |> Map.replace!(:expiration, expiration(ttl, ttl_unit)) + |> Map.replace!(:forward, items_forward) + |> Map.replace!(:backward, items_backward) + end) + end + + @spec expired?() :: boolean() + defp expired?() do + expiration = Agent.get(__MODULE__, & &1.expiration) + + case expiration do + nil -> + true + + %DateTime{} -> + DateTime.after?(DateTime.utc_now(), expiration) + end + end + + @spec expiration( + ttl :: non_neg_integer(), + ttl_unit :: :day | :hour | :minute | System.time_unit() + ) :: DateTime.t() + defp expiration(ttl, ttl_unit) when is_integer(ttl) do + DateTime.utc_now() |> DateTime.add(ttl, ttl_unit) + end +end diff --git a/worker/lib/item/schema/item.ex b/worker/lib/item/schema/item.ex index aa5343e8..aa8b4da5 100644 --- a/worker/lib/item/schema/item.ex +++ b/worker/lib/item/schema/item.ex @@ -15,6 +15,7 @@ defmodule Tornium.Schema.Item do use Ecto.Schema + alias Tornium.Repo @type t :: %__MODULE__{ tid: integer(), @@ -33,4 +34,13 @@ defmodule Tornium.Schema.Item do field(:market_value, :integer) field(:circulation, :integer) end + + @doc """ + Get all items. + """ + @spec all() :: [t()] + def all() do + __MODULE__ + |> Repo.all() + end end diff --git a/worker/lib/user/key.ex b/worker/lib/user/key.ex index 8fe7f3b6..176c531f 100644 --- a/worker/lib/user/key.ex +++ b/worker/lib/user/key.ex @@ -15,23 +15,35 @@ defmodule Tornium.User.Key do import Ecto.Query - alias Tornium.Repo - @spec get_by_user(user :: Tornium.Schema.User.t()) :: Tornium.Schema.TornKey.t() | nil + @doc """ + Get the default API key for a specific user or a specific user ID. + """ + @spec get_by_user(user :: Tornium.Schema.User.t() | non_neg_integer()) :: Tornium.Schema.TornKey.t() | nil def get_by_user(%Tornium.Schema.User{} = user) do pid = Process.whereis(Tornium.User.KeyStore) get_by_user(user, pid) end - @spec get_by_user(user :: Tornium.Schema.User.t(), pid :: pid()) :: Tornium.Schema.TornKey.t() | nil - def get_by_user(%Tornium.Schema.User{} = user, pid) when not is_nil(pid) do - case Tornium.User.KeyStore.get(pid, user.tid) do + def get_by_user(user_id) when is_integer(user_id) do + pid = Process.whereis(Tornium.User.KeyStore) + get_by_user(user_id, pid) + end + + @spec get_by_user(user :: Tornium.Schema.User.t() | non_neg_integer(), pid :: pid()) :: + Tornium.Schema.TornKey.t() | nil + def get_by_user(%Tornium.Schema.User{tid: user_id} = _user, pid) when not is_nil(pid) do + get_by_user(user_id, pid) + end + + def get_by_user(user_id, pid) when is_integer(user_id) and not is_nil(pid) do + case Tornium.User.KeyStore.get(pid, user_id) do nil -> - where = [user_id: user.tid, paused: false, disabled: false, default: true] + where = [user_id: user_id, paused: false, disabled: false, default: true] query = from(Tornium.Schema.TornKey, where: ^where) torn_key = Repo.one(query) - Tornium.User.KeyStore.put(pid, user.tid, torn_key) + Tornium.User.KeyStore.put(pid, user_id, torn_key) torn_key torn_key -> diff --git a/worker/lib/user/schema/torn_key.ex b/worker/lib/user/schema/torn_key.ex index 2327a21a..a9981574 100644 --- a/worker/lib/user/schema/torn_key.ex +++ b/worker/lib/user/schema/torn_key.ex @@ -16,6 +16,8 @@ defmodule Tornium.Schema.TornKey do use Ecto.Schema + @type access_levels() :: :public | :minimal | :limited | :full + @type t :: %__MODULE__{ guid: Ecto.UUID.t(), api_key: String.t(), @@ -23,7 +25,7 @@ defmodule Tornium.Schema.TornKey do default: boolean(), disabled: boolean(), paused: boolean(), - access_level: integer() + access_level: access_levels() } @primary_key {:guid, Ecto.UUID, autogenerate: true} @@ -33,6 +35,6 @@ defmodule Tornium.Schema.TornKey do field(:default, :boolean) field(:disabled, :boolean) field(:paused, :boolean) - field(:access_level, :integer) + field(:access_level, Ecto.Enum, values: [public: 1, minimal: 2, limited: 3, full: 4]) end end diff --git a/worker/lib/user/schema/user_settings.ex b/worker/lib/user/schema/user_settings.ex index e652e508..a5b983fc 100644 --- a/worker/lib/user/schema/user_settings.ex +++ b/worker/lib/user/schema/user_settings.ex @@ -24,16 +24,20 @@ defmodule Tornium.Schema.UserSettings do - `:user` - User the settings belong to - `:cpr_enabled` - Toggle for retrieval of the user's CPRs in OCs - `:stat_db_enabled` - Toggle for storage of opponent's stats in the stat DB via FF calculations + - `:od_drug_enabled` - Toggle for retrieval of overdose drug from user's logs """ use Ecto.Schema + import Ecto.Query + alias Tornium.Repo @type t :: %__MODULE__{ guid: Ecto.UUID.t(), - user_id: integer(), + user_id: non_neg_integer(), user: Tornium.Schema.User.t(), cpr_enabled: boolean(), - stat_db_enabled: boolean() + stat_db_enabled: boolean(), + od_drug_enabled: boolean(), } @primary_key {:guid, Ecto.UUID, autogenerate: true} @@ -41,6 +45,26 @@ defmodule Tornium.Schema.UserSettings do belongs_to(:user, Tornium.Schema.User, references: :tid) field(:cpr_enabled, :boolean) + field(:stat_db_enabled, :boolean) + + field(:od_drug_enabled, :boolean) + end + + @doc """ + Check if the user enabled OD drug retrieval from their logs. + + If the user does not have a settings row in the user_settings table, this will default to `false`. + """ + @spec od_drug?(user_id :: non_neg_integer()) :: boolean() + def od_drug?(user_id) when is_integer(user_id) do + od_drug_enabled? = + __MODULE__ + |> select([s], s.od_drug_enabled) + |> where([s], s.user_id == ^user_id) + |> first() + |> Repo.one() + + od_drug_enabled? || false end end diff --git a/worker/lib/workers/armory_news_update.ex b/worker/lib/workers/armory_news_update.ex new file mode 100644 index 00000000..51c8146e --- /dev/null +++ b/worker/lib/workers/armory_news_update.ex @@ -0,0 +1,89 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +defmodule Tornium.Workers.ArmoryNewsUpdate do + @moduledoc """ + Insert new armory news events. + """ + + use Oban.Worker, + max_attempts: 3, + priority: 0, + queue: :faction_processing, + tags: ["faction"], + unique: [ + period: :infinity, + fields: [:worker, :args], + keys: [:api_call_id], + states: :incomplete + ] + + @armory_news_category "armoryAction" + + @impl Oban.Worker + def perform( + %Oban.Job{ + args: %{ + "api_call_id" => api_call_id, + "faction_id" => faction_id + } + } = _job + ) do + case Tornium.API.Store.pop(api_call_id) do + nil -> + {:cancel, :invalid_call_id} + + :expired -> + {:cancel, :expired} + + :not_ready -> + # This uses :error instead of :snooze to allow for an easy cap on the number of retries + {:error, :not_ready} + + result when is_map(result) -> + do_perform(result, faction_id) + end + end + + @spec do_perform(api_call_result :: map(), faction_id :: non_neg_integer()) :: Oban.Worker.result() + def do_perform(api_call_result, faction_id) when is_map(api_call_result) and is_integer(faction_id) do + %{ + Torngen.Client.Path.Faction.News => %{ + FactionNewsResponse => %Torngen.Client.Schema.FactionNewsResponse{news: armory_usage_news} + } + } = + Tornex.SpecQuery.new() + |> Tornex.SpecQuery.put_path(Torngen.Client.Path.Faction.News) + |> Tornex.SpecQuery.put_parameter(:cat, @armory_news_category) + |> Tornex.SpecQuery.parse(api_call_result) + + @armory_news_category + |> Tornium.Faction.News.parse(armory_usage_news) + |> Tornium.Schema.ArmoryUsage.insert_all(faction_id) + + if length(armory_usage_news) == 100 do + # If the length of the news is 100, it can be assumed that there are more logs than what was returned + # as the maximum and default `limit` is 100. + Tornium.Workers.ArmoryNewsUpdateScheduler.schedule_faction(faction_id) + end + + :ok + end + + @impl Oban.Worker + def timeout(%Oban.Job{} = _job) do + :timer.minutes(1) + end +end diff --git a/worker/lib/workers/armory_news_update_scheduler.ex b/worker/lib/workers/armory_news_update_scheduler.ex new file mode 100644 index 00000000..d2e74346 --- /dev/null +++ b/worker/lib/workers/armory_news_update_scheduler.ex @@ -0,0 +1,139 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +defmodule Tornium.Workers.ArmoryNewsUpdateScheduler do + @moduledoc """ + A scheduler to spawn `Tornium.Workers.ArmoryNewsUpdate` for each faction with at least one AA key. + """ + + alias Tornium.Repo + import Ecto.Query + + use Oban.Worker, + max_attempts: 3, + priority: 0, + queue: :scheduler, + tags: ["scheduler", "faction"], + unique: [ + period: :infinity, + fields: [:worker], + states: :incomplete + ] + + @impl Oban.Worker + def perform(%Oban.Job{} = _job) do + Tornium.Schema.TornKey + |> where([k], k.default == true and k.disabled == false and k.paused == false) + |> join(:inner, [k], u in assoc(k, :user), on: u.tid == k.user_id) + |> where([k, u], not is_nil(u.faction_id) and u.faction_id != 0) + |> where([k, u], u.faction_aa == true) + |> distinct([k, u], u.faction_id) + |> select([k, u, f], [k.api_key, u.tid, u.faction_id]) + |> Repo.all() + |> Enum.each(fn [api_key, user_tid, faction_tid] when is_integer(faction_tid) -> + schedule_faction(api_key, user_tid, faction_tid) + end) + + :ok + end + + @doc """ + Schedule an armory news update for a specific faction. + + This will use a random AA member's API key. + """ + @spec schedule_faction(faction_id :: integer()) :: {:ok, Oban.Job.t()} | {:error, Oban.Job.changeset() | term()} + def schedule_faction(faction_id) when is_integer(faction_id) do + api_key_result = + Tornium.Schema.TornKey + |> where([k], k.default == true and k.disabled == false and k.paused == false) + |> join(:inner, [k], u in assoc(k, :user), on: u.tid == k.user_id) + |> where([k, u], u.faction_id == ^faction_id and u.faction_aa == true) + |> select([k, u, f], [k.api_key, u.tid]) + |> order_by(fragment("RANDOM()")) + |> Repo.one() + + case api_key_result do + [api_key, user_id] -> + schedule_faction(api_key, user_id, faction_id) + + _ -> + {:error, "Missing AA key"} + end + end + + @doc """ + Schedule an armory news update for a specific faction using a particular AA API key + """ + @spec schedule_faction(api_key :: String.t(), user_id :: non_neg_integer(), faction_id :: non_neg_integer()) :: + {:ok, Oban.Job.t()} | {:error, Oban.Job.changeset() | term()} + def schedule_faction(api_key, user_id, faction_id) + when is_binary(api_key) and is_integer(user_id) and is_integer(faction_id) do + latest_faction_usage = + Tornium.Schema.ArmoryUsage + |> where([u], u.faction_id == ^faction_id) + |> order_by([u], desc: u.timestamp) + |> limit([u], 1) + |> Repo.one() + + query = + Tornex.SpecQuery.new(key: api_key, key_owner: user_id, nice: 10) + |> Tornex.SpecQuery.put_path(Torngen.Client.Path.Faction.News) + |> Tornex.SpecQuery.put_parameter(:cat, "armoryAction") + + query = + case latest_faction_usage do + %Tornium.Schema.ArmoryUsage{timestamp: %DateTime{} = timestamp} -> + # We want to ensure that the latest timestamp from the armory usage logs is within seven day of now to + # ensure we aren't pulling too much data from the API + timestamp = + DateTime.utc_now() + |> DateTime.add(-7, :day) + |> then(&[&1, timestamp]) + |> Enum.max(DateTime) + + # We do not want to increment the from parameter to avoid missing data occurring at the same second. + # Data already in the database will be handled by an on conflict statement. + query + |> Tornex.SpecQuery.put_parameter(:from, DateTime.to_unix(timestamp, :second)) + |> Tornex.SpecQuery.put_parameter(:sort, "asc") + + nil -> + query + end + + api_call_id = Ecto.UUID.generate() + Tornium.API.Store.create(api_call_id, 300) + + Task.Supervisor.async_nolink(Tornium.TornexTaskSupervisor, fn -> + query + |> Tornex.Scheduler.Bucket.enqueue() + |> Tornium.API.Store.insert(api_call_id) + end) + + %{ + user_id: user_id, + faction_id: faction_id, + api_call_id: api_call_id + } + |> Tornium.Workers.ArmoryNewsUpdate.new(schedule_in: _seconds = 15) + |> Oban.insert() + end + + @impl Oban.Worker + def timeout(%Oban.Job{} = _job) do + :timer.minutes(1) + end +end diff --git a/worker/lib/workers/overdose_update.ex b/worker/lib/workers/overdose_update.ex index 8bb4ef4a..a002e6c4 100644 --- a/worker/lib/workers/overdose_update.ex +++ b/worker/lib/workers/overdose_update.ex @@ -69,9 +69,16 @@ defmodule Tornium.Workers.OverdoseUpdate do Tornex.SpecQuery.new() |> Tornex.SpecQuery.put_path(Torngen.Client.Path.Faction.Contributors) |> Tornex.SpecQuery.put_parameter(:stat, "drugoverdoses") - |> Tornex.SpecQuery.put_parameter(:cat, "current") |> Tornex.SpecQuery.parse(result) + overdose_last_updated = + Tornium.Schema.OverdoseCount + |> select([c], c.updated_at) + |> where([c], c.faction_id == ^faction_id) + |> order_by([c], desc: c.updated_at) + |> first() + |> Repo.one() + original_overdoses = Tornium.Schema.OverdoseCount |> where([c], c.faction_id == ^faction_id) @@ -88,20 +95,29 @@ defmodule Tornium.Workers.OverdoseUpdate do returning: true ) - overdosed_members = - Enum.reject(overdosed_members, fn %Tornium.Schema.OverdoseCount{user_id: user_id, count: count} -> - original_overdoses |> Map.get(user_id) |> is_nil() or - original_overdoses |> Map.get(user_id) |> Kernel.==(count) - end) + # We want to ensure that events are created for differences between extremely old data and current data. + if not old_data?(overdose_last_updated) do + overdosed_members = + Enum.reject(overdosed_members, fn %Tornium.Schema.OverdoseCount{user_id: user_id, count: original_count} -> + member_overdose_count = Map.get(original_overdoses, user_id) - {_, overdose_events} = - Repo.insert_all( - Tornium.Schema.OverdoseEvent, - Tornium.Faction.Overdose.map_events(overdosed_members, faction_id), - returning: true - ) + is_nil(member_overdose_count) or original_count == member_overdose_count + end) + + mapped_overdose_events = + overdosed_members + |> Tornium.Faction.Overdose.map_events(faction_id) + |> Enum.map(fn event -> Tornium.Faction.Overdose.set_drug_used(event, overdose_last_updated) end) + + {_, overdose_events} = + Repo.insert_all( + Tornium.Schema.OverdoseEvent, + mapped_overdose_events, + returning: true + ) - send_notifications(overdose_events, faction_id) + send_notifications(overdose_events, faction_id) + end :ok end @@ -140,6 +156,13 @@ defmodule Tornium.Workers.OverdoseUpdate do nil end + @spec old_data?(last_update :: DateTime.t()) :: boolean() + defp old_data?(last_update) do + DateTime.utc_now() + |> DateTime.add(-1, :day) + |> DateTime.after?(last_update) + end + @impl Oban.Worker def timeout(%Oban.Job{} = _job) do :timer.minutes(3) diff --git a/worker/lib/workers/overdose_update_scheduler.ex b/worker/lib/workers/overdose_update_scheduler.ex index 9eb86620..6617777d 100644 --- a/worker/lib/workers/overdose_update_scheduler.ex +++ b/worker/lib/workers/overdose_update_scheduler.ex @@ -14,6 +14,12 @@ # along with this program. If not, see . defmodule Tornium.Workers.OverdoseUpdateScheduler do + @moduledoc """ + Scheduler to update every factions' overdose events. + + NOTE: This is set to run every hour at 7 and 37 so that `Tornium.Workers.ArmoryNewsUpdateScheduler` + can finish updating the faction's armory news (or attempt to). + """ alias Tornium.Repo import Ecto.Query diff --git a/worker/mix.exs b/worker/mix.exs index d6d17ca3..61babaaf 100644 --- a/worker/mix.exs +++ b/worker/mix.exs @@ -59,7 +59,8 @@ defmodule Tornium.MixProject do {:torngen_elixir_client, ">= 3.0.0"}, # Required for tornex's default HTTP adapater {:finch, "~> 0.19"}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:floki, "~> 0.38"} ] end diff --git a/worker/mix.lock b/worker/mix.lock index e7cc3dd1..c1f3c9d8 100644 --- a/worker/mix.lock +++ b/worker/mix.lock @@ -15,6 +15,7 @@ "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "forecastle": {:hex, :forecastle, "0.1.3", "b07d217ef10799e6d6cc7e47407858e77b1a8cb248f15185534de3403de3aa42", [:mix], [], "hexpm", "07e1ffa79c56f3e0ead59f17c0163a747dafc210ca8f244a7e65a4bfa98dc96d"}, "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, diff --git a/worker/priv/repo/migrations/20251104195426_add_armory_usage.exs b/worker/priv/repo/migrations/20251104195426_add_armory_usage.exs new file mode 100644 index 00000000..b6e7361d --- /dev/null +++ b/worker/priv/repo/migrations/20251104195426_add_armory_usage.exs @@ -0,0 +1,34 @@ +defmodule Tornium.Repo.Migrations.AddArmoryUsage do + use Ecto.Migration + + def change do + create_if_not_exists table("armory_usage", primary_key: false) do + add :timestamp, :utc_datetime, null: false + add :id, :string, primary_key: true, autogenerate: false + add :action, :string, null: false + + add :user_id, references(:user, column: :tid, type: :integer), null: false + add :faction_id, references(:faction, column: :tid, type: :integer), null: false + + add :item_id, references(:item, column: :tid, type: :integer), null: false + add :is_energy_refill, :boolean, default: false, null: false + add :is_nerve_refill, :boolean, default: false, null: false + + add :quantity, :integer, null: false + end + + alter table("user_settings") do + add :od_drug_enabled, :boolean, default: false, null: false + end + + execute(""" + ALTER TABLE armory_usage + ADD CONSTRAINT armory_usage_item_or_refill + CHECK ( + item_id IS NOT NULL + OR is_energy_refill = TRUE + OR is_nerve_refill = TRUE + ) + """) + end +end