Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,5 @@ wsgi.py

# Docs
docs/build/

.tool-versions
1 change: 1 addition & 0 deletions application/controllers/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:tid>", view_func=user.get_specific_user, methods=["GET"])
mod.add_url_rule(
"/api/v1/user/estimate/<int:tid>",
Expand Down
16 changes: 16 additions & 0 deletions application/controllers/api/v1/user_settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions application/static/settings/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
13 changes: 13 additions & 0 deletions application/templates/settings/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,15 @@ <h5 class="card-title">Tornium API Key Settings</h5>
<div class="list-group list-group-flush">
<li class="list-group-item">
<label for="cpr-toggle" class="form-label">Share CPR:</label>
<p><small>Collect and share your CPR data for all available organized crimes for the <a href="/faction/crimes/cpr">faction CPR viewer</a>.</small></p>

<input type="radio" class="btn-check" name="cpr-toggle" id="cpr-toggle-enable" autocomplete="off" {% if current_user.settings is not none and current_user.settings.cpr_enabled %} checked {% endif %}>
<label class="btn" for="cpr-toggle-enable">Enable</label>

<input type="radio" class="btn-check" name="cpr-toggle" id="cpr-toggle-disable" autocomplete="off" {% if current_user.settings is none or not current_user.settings.cpr_enabled %} checked {% endif %}>
<label class="btn" for="cpr-toggle-disable">Disable</label>
</li>

<li class="list-group-item">
<label for="cpr-toggle" class="form-label mb-0">Share opponent stats:</label>
<p><small>Collect and share your opponents' stats via fair fight for the <a href="/stats/db">stat database</a>.</small></p>
Expand All @@ -163,6 +165,17 @@ <h5 class="card-title">Tornium API Key Settings</h5>
<input type="radio" class="btn-check" name="stat-db-toggle" id="stat-db-toggle-disable" autocomplete="off" {% if current_user.settings is none or not current_user.settings.stat_db_enabled %} checked {% endif %}>
<label class="btn" for="stat-db-toggle-disable">Disable</label>
</li>

<li class="list-group-item">
<label for="od-drug-toggle" class="form-label mb-0">Share overdosed drug:</label>
<p><small>Collect and share the drug you've overdosed on from your logs.</small></p>

<input type="radio" class="btn-check" name="od-drug-toggle" id="od-drug-toggle-enable" autocomplete="off" {% if current_user.settings is not none and current_user.settings.od_drug_enabled %} checked {% endif %}>
<label class="btn" for="od-drug-toggle-enable">Enable</label>

<input type="radio" class="btn-check" name="od-drug-toggle" id="od-drug-toggle-disable" autocomplete="off" {% if current_user.settings is none or not current_user.settings.od_drug_enabled %} checked {% endif %}>
<label class="btn" for="od-drug-toggle-disable">Disable</label>
</li>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions commons/tornium_commons/models/user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
1 change: 1 addition & 0 deletions worker/.iex.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
IEx.configure(auto_reload: true)
5 changes: 3 additions & 2 deletions worker/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
3 changes: 2 additions & 1 deletion worker/lib/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
174 changes: 174 additions & 0 deletions worker/lib/faction/news/armory_action.ex
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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 "<a href = \"http://www.torn.com/profiles.php?XID=3870167\">Kagat</a> 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)
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil value issue: If Tornium.Item.NameCache.get_by_name(item_name) returns nil (item not found in cache), the item_id will be nil. However, the typespec at line 35 indicates item_id should be of type armory_item() which is non_neg_integer() | :energy_refill | :nerve_refill, not allowing nil. Consider handling the case where the item is not found in the cache, or update the typespec to allow nil.

Suggested change
Tornium.Item.NameCache.get_by_name(item_name)
case Tornium.Item.NameCache.get_by_name(item_name) do
nil -> raise "Item not found in cache: #{inspect(item_name)}"
id -> id
end

Copilot uses AI. Check for mistakes.
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: "<a href = \"http://www.torn.com/profiles.php?XID=2383326\">tiksan</a> 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
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text_action_item/1 function doesn't have a catch-all clause for unmatched text patterns. If the news text doesn't match any of the expected patterns ("used", "filled", "loaned", "returned"), the function will raise a FunctionClauseError. Consider adding a catch-all clause that returns nil or raises a more descriptive error to handle unexpected news formats gracefully.

Suggested change
end
end
defp text_action_item(_), do: nil

Copilot uses AI. Check for mistakes.
end
40 changes: 40 additions & 0 deletions worker/lib/faction/news/news.ex
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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
Loading