├── src
├── __init__.py
├── ui
│ ├── __init__.py
│ └── role.py
├── model
│ ├── __init__.py
│ ├── weapon.py
│ └── rivens.py
├── utils
│ ├── __init__.py
│ └── http.py
├── sources
│ ├── __init__.py
│ ├── warframe_wiki.py
│ ├── weapon_lookup.py
│ └── riven_provider.py
├── constants.py
├── state.py
├── warframe.py
├── settings.py
├── pet_counter.py
├── message_provider.py
├── riven_grader.py
├── riven_grader_v1.py
└── jericho.py
├── test
├── __init__.py
├── warframe_test.py
├── weapon_lookup_test.py
├── riven_grader_v1_test.py
├── riven_provider_test.py
├── pet_counter_test.py
├── warframe_wiki_test.py
├── riven_grader_test.py
└── message_provider_test.py
├── .python-version
├── pytest.ini
├── images
├── jericho.png
├── Jericho480.png
├── Jericho_Pet.gif
└── JerichoNoBackground.png
├── requirements.txt
├── messages.csv
├── .vscode
└── settings.json
├── pyproject.toml
├── docker-compose.yaml
├── Dockerfile
├── .github
└── workflows
│ ├── test.yaml
│ └── ci.yaml
├── LICENSE
├── README.md
├── .gitignore
└── docs.md
/src/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.12
2 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | pythonpath = . src
--------------------------------------------------------------------------------
/src/ui/__init__.py:
--------------------------------------------------------------------------------
1 | from .role import RoleView
2 |
--------------------------------------------------------------------------------
/images/jericho.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LLukas22/Cephalon-Jericho/main/images/jericho.png
--------------------------------------------------------------------------------
/images/Jericho480.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LLukas22/Cephalon-Jericho/main/images/Jericho480.png
--------------------------------------------------------------------------------
/images/Jericho_Pet.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LLukas22/Cephalon-Jericho/main/images/Jericho_Pet.gif
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | httpx
2 | discord.py
3 | pydantic
4 | pydantic-settings
5 | Jinja2
6 | beautifulsoup4
7 | gspread
--------------------------------------------------------------------------------
/src/model/__init__.py:
--------------------------------------------------------------------------------
1 | from .rivens import Riven, RivenEffect
2 | from .weapon import WeaponModType, Weapon
3 |
--------------------------------------------------------------------------------
/src/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .http import HardenedHttpClient, DEFAULT_SUCCESS_CODES, WARFRAME_API_SUCCESS_CODES
2 |
--------------------------------------------------------------------------------
/images/JerichoNoBackground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LLukas22/Cephalon-Jericho/main/images/JerichoNoBackground.png
--------------------------------------------------------------------------------
/test/warframe_test.py:
--------------------------------------------------------------------------------
1 | from src.warframe import WarframeAPI
2 |
3 |
4 | def test_is_constructable():
5 | api = WarframeAPI()
6 | assert api is not None
7 |
--------------------------------------------------------------------------------
/src/sources/__init__.py:
--------------------------------------------------------------------------------
1 | from .warframe_wiki import WarframeWiki
2 | from .riven_provider import RivenRecommendationProvider
3 | from .weapon_lookup import WeaponLookup
4 |
--------------------------------------------------------------------------------
/messages.csv:
--------------------------------------------------------------------------------
1 | KEY|MESSAGE|WEIGHT
2 | TEST|Hello Opperator {{ user }}|1
3 | HELLO|Hello, Operator {{ user }}. Cephalon Jericho online. Precepts operational. Please input commands.|1
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.testing.pytestArgs": [
3 | "test"
4 | ],
5 | "python.testing.unittestEnabled": false,
6 | "python.testing.pytestEnabled": true
7 | }
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "cephalon-jericho"
3 | version = "0.1.0"
4 | description = "Add your description here"
5 | readme = "README.md"
6 | requires-python = ">=3.11"
7 | dependencies = []
8 |
--------------------------------------------------------------------------------
/src/constants.py:
--------------------------------------------------------------------------------
1 | from message_provider import MessageProvider
2 | from settings import Settings
3 | from state import State
4 |
5 | SETTINGS = Settings()
6 | STATE: State = State.load()
7 | MESSAGE_PROVIDER = MessageProvider.from_gsheets(SETTINGS.MESSAGE_PROVIDER_URL)
8 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | jericho:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | # image: ghcr.io/violetrimberg/cephalon-jericho:latest
7 | restart: unless-stopped
8 | volumes:
9 | - ./state.json:/app/state.json
10 | - ./.env:/app/.env # Define your environment variables here
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the official Python image from the Docker Hub
2 | FROM python:3.11-slim
3 |
4 | # Set the working directory in the container
5 | WORKDIR /app
6 |
7 | # Copy the requirements file into the container
8 | COPY requirements.txt .
9 |
10 | # Install the dependencies
11 | RUN pip install --no-cache-dir -r requirements.txt
12 |
13 | COPY ./src /app
14 |
15 | # Copy the images folder into the container
16 | COPY images /app/images
17 |
18 | # Specify the command to run the application
19 | CMD ["python", "jericho.py"]
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Python package
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | test:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Set up Python
20 | uses: actions/setup-python@v5
21 | with:
22 | python-version: '3.11'
23 |
24 | - name: Install dependencies
25 | run: |
26 | python -m pip install --upgrade pip
27 | pip install pytest pytest-asyncio
28 | pip install -r requirements.txt
29 |
30 | - name: Run tests
31 | run: |
32 | pytest
--------------------------------------------------------------------------------
/src/state.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 | from logging import error, info
3 | from pathlib import Path
4 |
5 | JERICHO_STATE_FILE = Path("state.json")
6 |
7 |
8 | class State(BaseModel):
9 | # How many times Jericho has been "reset"
10 | deathcounter: int = 0
11 |
12 | @classmethod
13 | def load(cls) -> "State":
14 | """
15 | Load the state from the state file.
16 | """
17 | if JERICHO_STATE_FILE.exists():
18 | with open(JERICHO_STATE_FILE, "r") as f:
19 | try:
20 | state = cls.parse_raw(f.read())
21 | return state
22 | except Exception as e:
23 | error(f"Error reading Jericho state file: {e}. Creating new state.")
24 |
25 | new_state = cls()
26 | new_state.save()
27 | return cls()
28 |
29 | def save(self):
30 | """
31 | Save the state to the state file.
32 | """
33 | with open(JERICHO_STATE_FILE, "w") as f:
34 | f.write(self.model_dump_json(indent=4))
35 | info("Created new state file.")
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 VioletRimberg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/warframe.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from sources import WeaponLookup
3 | from utils.http import HardenedHttpClient, WARFRAME_API_SUCCESS_CODES
4 |
5 |
6 | class WarframeAPI:
7 | """
8 | Class to interface with the Warframe API.
9 | """
10 |
11 | def __init__(self, timeout: int = 10_000):
12 | self.client = HardenedHttpClient(
13 | httpx.AsyncClient(timeout=timeout), success_codes=WARFRAME_API_SUCCESS_CODES
14 | ) # Initialize the HTTP client
15 |
16 | async def get_median_prices(self, weapon_lookup: WeaponLookup):
17 | result = await self.client.get(
18 | "https://www-static.warframe.com/repos/weeklyRivensPC.json"
19 | )
20 | result.raise_for_status()
21 | data = result.json()
22 | for item in data:
23 | if "compatibility" not in item:
24 | continue
25 | if (
26 | item["compatibility"] is None
27 | or item["compatibility"] not in weapon_lookup
28 | ):
29 | continue
30 |
31 | weapon_lookup[item["compatibility"]].median_plat_price = item["median"]
32 |
--------------------------------------------------------------------------------
/test/weapon_lookup_test.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import pytest_asyncio
3 | from src.sources import WarframeWiki, WeaponLookup
4 |
5 |
6 | @pytest_asyncio.fixture(scope="session", autouse=True)
7 | async def real_weapon_lookup():
8 | lookup = WeaponLookup()
9 | wiki = WarframeWiki(lookup)
10 | await wiki.refresh()
11 | return lookup
12 |
13 |
14 | def test_is_constructable():
15 | lookup = WeaponLookup()
16 | assert lookup is not None
17 |
18 |
19 | @pytest.mark.asyncio
20 | async def test_fuzzy_search(real_weapon_lookup):
21 | matches = real_weapon_lookup.fuzzy_search("Bolt")
22 | assert len(matches) > 0
23 | assert any(match.display_name == "Boltor" for match in matches)
24 |
25 |
26 | @pytest.mark.asyncio
27 | async def test_relations(real_weapon_lookup):
28 | real_weapon_lookup.rebuild_weapon_relations()
29 | match = real_weapon_lookup["Boltor"]
30 | assert match.weapon_variants == ["boltor_prime", "telos_boltor"]
31 |
32 | match = real_weapon_lookup["Boltor Prime"]
33 | assert match.base_weapon == "boltor"
34 |
35 | match = real_weapon_lookup["Braton"]
36 | assert match.weapon_variants == ["braton_prime", "braton_vandal", "mk1-braton"]
37 |
--------------------------------------------------------------------------------
/test/riven_grader_v1_test.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from src.riven_grader_v1 import RivenGrader
3 |
4 |
5 | # Parameterized test cases
6 | @pytest.mark.parametrize(
7 | "positives, negatives, expected",
8 | [
9 | (["MS", "CD", "FR"], ["-ZOOM"], 5), # Perfect
10 | (["MS", "CD", "FR"], ["-SC"], 4), # Prestigious
11 | (["DMG", "MS", "TOX"], ["-SC"], 3), # Decent
12 | (["COLD", "TOX"], ["-SC"], 2), # Neutral
13 | (["FR", "CD"], ["-MS"], 1), # Unusable
14 | ],
15 | )
16 | def test_grade_riven(positives, negatives, expected):
17 | # Combine positives and negatives into a single stats list
18 | stats = positives + negatives
19 |
20 | # Mock the RivenProvider methods to return appropriate data
21 | best_stats = ["CD"]
22 | desired_stats = ["DMG", "FR", "MS"]
23 | harmless_negatives = ["ZOOM"] # Keep harmless as "-ZOOM"
24 |
25 | # Initialize the RivenGrader
26 | riven_grader = RivenGrader()
27 |
28 | # Grade the riven
29 | result = riven_grader.grade_riven(
30 | stats, best_stats, desired_stats, harmless_negatives
31 | )
32 |
33 | # Assert the result matches the expected grade
34 | assert result == expected
35 |
--------------------------------------------------------------------------------
/test/riven_provider_test.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from src.sources import WarframeWiki, RivenRecommendationProvider
3 |
4 |
5 | # Test RivenProvider initialization
6 | def test_riven_provider_initialization():
7 | provider = RivenRecommendationProvider()
8 |
9 | # Ensure the object is constructed
10 | assert provider is not None
11 | # Ensure the sheets are properly initialized
12 | assert len(provider.sheets) == 5
13 | assert "Primary" in provider.sheets
14 | assert "Secondary" in provider.sheets
15 | assert "Melee" in provider.sheets
16 | assert "Archgun" in provider.sheets
17 | assert "Robotic" in provider.sheets
18 |
19 |
20 | @pytest.mark.asyncio
21 | async def test_refresh():
22 | # Initialize the provider and fetch sheets
23 | wiki = WarframeWiki()
24 | await wiki.refresh()
25 | weapon_lookup = wiki.weapon_lookup
26 |
27 | provider = RivenRecommendationProvider()
28 | await provider.refresh(weapon_lookup)
29 |
30 | # Ensure the normalized data has been populated after calling refresh
31 | assert len(weapon_lookup) > 0
32 | # Access the weapon name correctly using the key
33 | assert weapon_lookup["Acceltra"].riven_recommendations is not None
34 |
--------------------------------------------------------------------------------
/src/model/weapon.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 | from typing import Optional
3 | from enum import Enum
4 |
5 |
6 | class WeaponModType(str, Enum):
7 | Rifle = "Rifle"
8 | Shotgun = "Shotgun"
9 | Pistol = "Pistol"
10 | Archgun = "Archgun"
11 | Melee = "Melee"
12 | Misc = "Misc" # For stuff like railjack weapons, etc.
13 |
14 | @classmethod
15 | def from_raw_data(cls, slot: str, weapon_type: str) -> "WeaponModType":
16 | if weapon_type == "Shotgun":
17 | return WeaponModType.Shotgun
18 | elif weapon_type == "Glaive":
19 | return WeaponModType.Melee
20 | elif slot == "Primary":
21 | return WeaponModType.Rifle
22 | elif slot == "Secondary":
23 | return WeaponModType.Pistol
24 | elif slot == "Melee":
25 | return WeaponModType.Melee
26 | elif slot == "Archgun":
27 | return WeaponModType.Archgun
28 | else:
29 | return WeaponModType.Misc
30 |
31 |
32 | class RivenDisposition(BaseModel):
33 | disposition: float = 0.5 # Default min value
34 | symbol: str = "●○○○○"
35 |
36 |
37 | class Weapon(BaseModel):
38 | name: str
39 | url: str
40 | image: str | None = None
41 | riven_disposition: RivenDisposition
42 | mod_type: WeaponModType = WeaponModType.Misc
43 | mr: int | None
44 | weapon_type: str | None = None
45 | slot: str | None = None
46 |
--------------------------------------------------------------------------------
/src/settings.py:
--------------------------------------------------------------------------------
1 | from pydantic_settings import BaseSettings, SettingsConfigDict
2 | from pydantic import BaseModel
3 |
4 |
5 | class Role(BaseModel):
6 | name: str
7 | ids: list[int]
8 |
9 |
10 | class Clan(BaseModel):
11 | name: str
12 | description: str
13 | channel: int
14 | roles: list[Role]
15 |
16 |
17 | class Settings(BaseSettings):
18 | model_config = SettingsConfigDict(
19 | env_file=".env", env_file_encoding="utf-8", case_sensitive=False
20 | )
21 | # The token used to authenticate with the Discord API
22 | DISCORD_TOKEN: str
23 |
24 | # The ID of the Discord guild (server) the bot will operate in
25 | GUILD_ID: int
26 |
27 | # The ID of the channel where reports will be sent
28 | REPORT_CHANNEL_ID: int
29 |
30 | # The ID of the role assigned to guests
31 | GUEST_ROLE_ID: int
32 |
33 | # Display name of the guest rank
34 | GUEST_NAME: str = "Guest"
35 |
36 | # The ID of the maintenance role assigned to administration
37 | MAINTENANCE_ROLE_ID: int
38 |
39 | # The URL assigned to the Message Provider
40 | MESSAGE_PROVIDER_URL: str = "https://docs.google.com/spreadsheets/d/1iIcJkWBY898qGPhkQ3GcLlj1KOkgjlWxWkmiHkzDuzk/edit"
41 |
42 | # Possible Roles per Clan for the onboarding process
43 | CLANS: list[Clan] = [
44 | Clan(
45 | name="Golden Tenno",
46 | description="Join Golden Tenno",
47 | channel=1308466222282575944,
48 | roles=[Role(name="Member", ids=[1308470226085744670])],
49 | )
50 | ]
51 | # The Google Credentials Path
52 | GOOGLE_CREDENTIALS: dict[str, str]
53 |
54 | #The Google Pet Sheet ID
55 | GOOGLE_SHEET_PET_ID: str
56 |
57 | #Milestones for pet function
58 | PERSONAL_MILESTONES: list[int] = [10, 25, 50]
59 | GLOBAL_MILESTONES: list[int] = [50, 100, 250, 500]
60 |
61 |
--------------------------------------------------------------------------------
/src/pet_counter.py:
--------------------------------------------------------------------------------
1 | import gspread
2 | from google.oauth2.service_account import Credentials
3 | import os
4 | from settings import Settings
5 |
6 | SETTINGS = Settings()
7 |
8 | # Authenticate
9 | creds = Credentials.from_service_account_info(
10 | SETTINGS.GOOGLE_CREDENTIALS, scopes=["https://www.googleapis.com/auth/spreadsheets"]
11 | )
12 | client = gspread.authorize(creds)
13 |
14 |
15 | def get_pet_sheet():
16 | """Retrieve the pet tracking sheet."""
17 | return client.open_by_key(SETTINGS.GOOGLE_SHEET_PET_ID).sheet1
18 |
19 |
20 | def get_pet_count(user_id: str) -> int:
21 | """Retrieve the pet count for a given user ID."""
22 | sheet = get_pet_sheet()
23 | records = sheet.get_all_records()
24 |
25 | for row in records:
26 | if str(row.get("User ID")) == str(user_id):
27 | return row.get("Pet Count", 0)
28 | return 0 # User not found
29 |
30 |
31 | def update_pet_count(user_id: str):
32 | sheet = get_pet_sheet()
33 | data = sheet.get_all_records()
34 |
35 | user_id = str(user_id) # Convert user ID to string for consistent matching
36 | global_count_cell = sheet.cell(2, 3).value
37 |
38 | global_count = int(global_count_cell) if global_count_cell else 0
39 |
40 | for i, row in enumerate(data):
41 | if str(row["User ID"]) == user_id: # Compare as strings
42 | new_count = int(row["Pet Count"]) + 1
43 | sheet.update_cell(i + 2, 2, new_count) # Update pet count
44 | global_count += 1
45 | sheet.update_cell(2, 3, global_count) # Update global count
46 | return new_count, global_count
47 |
48 | # If user is not found, add them
49 | new_count = 1
50 | global_count += 1
51 | sheet.append_row(
52 | [user_id, new_count, ""]
53 | ) # Append user, new pet count, empty global counter
54 | sheet.update_cell(2, 3, global_count) # Update global counter
55 |
56 | return new_count, global_count
57 |
--------------------------------------------------------------------------------
/src/utils/http.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | import asyncio
3 | import logging
4 |
5 | DEFAULT_SUCCESS_CODES = [200, 201, 202, 203, 204, 205, 206, 207, 208]
6 | WARFRAME_API_SUCCESS_CODES = [200, 201, 202, 203, 204, 205, 206, 207, 208, 409]
7 |
8 |
9 | class HardenedHttpClient:
10 | """
11 | A hardened HTTP client that uses the httpx library, with retry policies and timeouts.
12 | """
13 |
14 | def __init__(
15 | self,
16 | client: httpx.AsyncClient,
17 | success_codes: list[int] = DEFAULT_SUCCESS_CODES,
18 | retries: int = 5,
19 | wait_time: int = 1,
20 | ):
21 | self.client = client
22 | self.success_codes = success_codes
23 | self.retries = retries
24 | self.wait_time = wait_time
25 |
26 | async def get(self, url: str, **kwargs) -> httpx.Response:
27 | retries = 0
28 | while retries < self.retries:
29 | try:
30 | result = await self.client.get(url, **kwargs)
31 | except Exception as e:
32 | logging.debug(f"An error occurred while trying to GET `{url}`: {e}")
33 | await asyncio.sleep(self.wait_time)
34 | retries += 1
35 | continue
36 |
37 | if result.status_code in self.success_codes:
38 | return result
39 | else:
40 | await asyncio.sleep(self.wait_time)
41 | retries += 1
42 | return result
43 |
44 | async def post(self, url: str, **kwargs) -> httpx.Response:
45 | retries = 0
46 | while retries < self.retries:
47 | try:
48 | result = await self.client.post(url, **kwargs)
49 | except Exception as e:
50 | logging.debug(f"An error occurred while trying to POST `{url}`: {e}")
51 | await asyncio.sleep(self.wait_time)
52 | retries += 1
53 | continue
54 |
55 | if result.status_code in self.success_codes:
56 | return result
57 | else:
58 | await asyncio.sleep(self.wait_time)
59 | retries += 1
60 | return result
61 |
--------------------------------------------------------------------------------
/test/pet_counter_test.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import os
3 | import gspread
4 | from google.oauth2.service_account import Credentials
5 | from dotenv import load_dotenv
6 |
7 | # Load environment variables
8 | load_dotenv()
9 |
10 | # Setup Google Sheets client
11 | SHEET_ID = os.getenv("GOOGLE_SHEET_PET_ID")
12 | CREDENTIALS_PATH = os.getenv("GOOGLE_CREDENTIALS_PATH")
13 |
14 |
15 | @pytest.mark.skip(reason="Can't provide Keyfile")
16 | def get_google_sheets_client():
17 | creds = Credentials.from_service_account_file(
18 | CREDENTIALS_PATH, scopes=["https://www.googleapis.com/auth/spreadsheets"]
19 | )
20 | return gspread.authorize(creds)
21 |
22 |
23 | @pytest.mark.skip(reason="Can't provide Keyfile")
24 | def test_can_authenticate():
25 | client = get_google_sheets_client()
26 | assert client is not None, "Failed to authenticate Google Sheets client."
27 |
28 |
29 | @pytest.mark.skip(reason="Can't provide Keyfile")
30 | def test_can_access_sheet():
31 | client = get_google_sheets_client()
32 | sheet = client.open_by_key(SHEET_ID).sheet1
33 | assert sheet is not None, "Failed to access Google Sheet."
34 |
35 |
36 | @pytest.mark.skip(reason="Can't provide Keyfile")
37 | def test_can_read_data():
38 | client = get_google_sheets_client()
39 | sheet = client.open_by_key(SHEET_ID).sheet1
40 | data = sheet.get_all_values()
41 | assert isinstance(data, list), "Sheet data should be a list of rows."
42 | print("Sheet Data:", data)
43 |
44 |
45 | @pytest.mark.skip(reason="Can't provide Keyfile")
46 | def test_can_write_data():
47 | client = get_google_sheets_client()
48 | sheet = client.open_by_key(SHEET_ID).sheet1
49 | test_data = ["TestUser", "1"]
50 |
51 | sheet.append_row(test_data)
52 | data = sheet.get_all_values()
53 |
54 | data = [row[: len(test_data)] for row in data]
55 |
56 | assert test_data in data, "Failed to write data to Google Sheet."
57 |
58 | # Cleanup: Find the row index and delete it
59 | for i, row in enumerate(data, start=1):
60 | if row[: len(test_data)] == test_data:
61 | sheet.delete_rows(i)
62 | break # Stop after deleting the first match
63 |
--------------------------------------------------------------------------------
/test/warframe_wiki_test.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from src.sources import WarframeWiki
3 | import pytest_asyncio
4 |
5 |
6 | @pytest_asyncio.fixture(scope="session", autouse=True)
7 | async def real_wiki():
8 | wiki = WarframeWiki()
9 | await wiki.refresh()
10 | return wiki
11 |
12 |
13 | def test_is_constructable():
14 | wiki = WarframeWiki()
15 | assert wiki is not None
16 |
17 |
18 | @pytest.mark.asyncio
19 | async def test_can_refresh(real_wiki):
20 | assert len(real_wiki.weapon_lookup) > 0
21 |
22 |
23 | @pytest.mark.parametrize(
24 | "weapon_name",
25 | [
26 | "Ack & Brunt",
27 | "Boltor",
28 | "Mausolon",
29 | "Furis",
30 | "Glaive",
31 | "Cedo",
32 | "Dread",
33 | "Sweeper",
34 | "Deconstructor",
35 | "Tombfinger", # Kitgun chamber
36 | ],
37 | )
38 | @pytest.mark.asyncio
39 | async def test_can_get_weapon(weapon_name: str, real_wiki):
40 | weapon = await real_wiki.weapon(weapon_name)
41 | assert weapon is not None
42 | assert weapon.name == weapon_name
43 |
44 |
45 | @pytest.mark.parametrize("weapon_name", ["cryophon_mk_iii"])
46 | @pytest.mark.asyncio
47 | async def test_can_get_railjack_weapon(weapon_name: str, real_wiki):
48 | weapon = await real_wiki.weapon(weapon_name)
49 | assert weapon is not None
50 |
51 |
52 | @pytest.mark.asyncio
53 | async def test_weapon_not_found(real_wiki):
54 | weapon = await real_wiki.weapon("Nonexistent Weapon")
55 | assert weapon is None
56 |
57 |
58 | @pytest.mark.skip(reason="Takes too long to run")
59 | @pytest.mark.asyncio
60 | async def test_all_weapons(real_wiki):
61 | matches = 0
62 | for weapon_name in real_wiki.weapon_lookup.weapon_lookup.keys():
63 | try:
64 | weapon = await real_wiki.weapon(weapon_name)
65 | except Exception:
66 | print(f"Failed to get weapon: {weapon_name}") # yareli
67 | assert weapon is not None
68 | match = real_wiki.weapon_lookup._normalize_weapon_name(weapon.name).startswith(
69 | weapon_name
70 | )
71 | matches += int(match)
72 | print(f"Matched {matches} out of {len(real_wiki.weapon_lookup)} weapons")
73 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: publish_docker
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | build-and-push-image:
10 | runs-on: [ubuntu-latest]
11 | permissions:
12 | contents: write
13 | packages: write
14 | # This is used to complete the identity challenge
15 | # with sigstore/fulcio when running outside of PRs.
16 | id-token: write
17 | security-events: write
18 | steps:
19 | - name: Checkout repository
20 | uses: actions/checkout@v4
21 |
22 | - name: Set up QEMU
23 | uses: docker/setup-qemu-action@v3
24 |
25 | - name: Initialize Docker Buildx
26 | uses: docker/setup-buildx-action@v3
27 | with:
28 | install: true
29 |
30 | - name: Inject slug/short variables
31 | uses: rlespinasse/github-slug-action@v4.5.0
32 |
33 | - name: Login to GitHub Container Registry
34 | if: github.event_name != 'pull_request'
35 | uses: docker/login-action@v3
36 | with:
37 | registry: ghcr.io
38 | username: ${{ github.actor }}
39 | password: ${{ secrets.GITHUB_TOKEN }}
40 |
41 | - name: Extract metadata (tags, labels) for Docker
42 | id: meta-cpu
43 | uses: docker/metadata-action@v5
44 | with:
45 | images: |
46 | ghcr.io/${{env.GITHUB_REPOSITORY_OWNER_PART}}/${{env.GITHUB_REPOSITORY_NAME_PART}}
47 | flavor: |
48 | latest=false
49 | tags: |
50 | type=semver,pattern={{version}}
51 | type=semver,pattern={{major}}.{{minor}}
52 | type=raw,value=latest
53 | type=raw,value=sha-${{ env.GITHUB_SHA_SHORT }}
54 | - name: Build and push Docker image
55 | id: build-and-push-cpu
56 | uses: docker/build-push-action@v6
57 | with:
58 | context: .
59 | file: Dockerfile
60 | push: ${{ github.event_name != 'pull_request' }}
61 | platforms: 'linux/amd64,linux/arm64'
62 | tags: ${{ steps.meta-cpu.outputs.tags }}
63 | labels: ${{ steps.meta-cpu.outputs.labels }}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cephalon Jericho - Your Warframe Guild Discord Bot
2 |
3 |
4 |

5 |
6 |
7 | Utilize an authorization tool for guild members, multiple tools for admins and functionalities for users. This bot was initially written for the Golden Tenno Clan, but can easily be adjusted to be used for your clan as well.
8 |
9 | ## Installation
10 |
11 | Cephalon Jericho utilizes [Docker](https://docs.docker.com/get-docker/), for easy set up with minimal configuration. `docker-compose` is recommended, and an exemplary config can be found [here](./docker-compose.yaml).
12 |
13 | ### Run it with Docker Compose
14 | 1. Start the bot using Docker Compose:
15 | ```bash
16 | docker-compose up -d
17 | ```
18 |
19 | 3. Stop the bot:
20 | ```
21 | docker-compose down
22 | ```
23 |
24 | ## Core Functionalities
25 |
26 | While Cephalon Jericho offers a few [commands](./docs.md), its core functionality lies in being able to link a warframe users masteraccount (the pc account) to their discord user name, assigning a role tied to it and further guild specific utilities, such as an absence form and for moderators/administration an archival form. This also includes a slimmed down riven functionality, which is based on the work done in [44bananas riven excel](https://docs.google.com/spreadsheets/d/1zbaeJBuBn44cbVKzJins_E3hTDpnmvOk8heYN-G8yy8/edit?gid=1687910063#gid=1687910063).
27 |
28 | ## Settings and Environment
29 |
30 | | Environment Variable | Description | Example Value |
31 | |---------------------|------------------------------|------------------------|
32 | | `DISCORD_TOKEN` | Your bot's Discord token | `YOUR_DISCORD_TOKEN` |
33 | | `GUILD_ID` | The ID of your Discord guild | `YOUR_GUILD_ID` |
34 | | `CLAN_NAME` | Your Warframe clan's name | `YOUR_GUILD_NAME` |
35 | | `REPORT_CHANNEL_ID` | Channel ID for bot reports | `YOUR_CHANNEL_ID` |
36 | | `MEMBER_ROLE_ID` | Role ID for guild members | `YOUR_MEMBER_ROLE_ID` |
37 | | `GUEST_ROLE_ID` | Role ID for guest members | `YOUR_GUEST_ROLE_ID` |
38 | | `MAINTENANCE_ROLE_ID` | Role ID for maintenance users | `YOUR_MAINTENANCE_ROLE_ID` |
39 | | `MESSAGE_PROVIDER_URL` | URL for the message provider, defaults to [jericho_text](https://docs.google.com/spreadsheets/d/1iIcJkWBY898qGPhkQ3GcLlj1KOkgjlWxWkmiHkzDuzk/edit) | `YOUR_MESSAGE_PROVIDER_URL` |
40 |
--------------------------------------------------------------------------------
/src/message_provider.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from jinja2 import Environment
3 | import random
4 | import csv
5 | import httpx
6 |
7 |
8 | @dataclass
9 | class MessageEntry:
10 | message: str
11 | weight: int
12 |
13 |
14 | class MessageProvider:
15 | entries: dict[str, list[MessageEntry]]
16 |
17 | def __init__(self) -> None:
18 | self.entries = {}
19 | self.env = Environment()
20 |
21 | @classmethod
22 | def from_csv(cls, path: str) -> "MessageProvider":
23 | provider = cls()
24 | with open(path, "r") as f:
25 | reader = csv.reader(f, delimiter="|")
26 | next(reader)
27 | for row in reader:
28 | key = row[0]
29 | message = row[1]
30 | weight = int(row[2])
31 | provider.add(key, MessageEntry(message, weight))
32 |
33 | return provider
34 |
35 | @classmethod
36 | def from_gsheets(cls, url: str) -> "MessageProvider":
37 | csv_url = url.replace("/edit", "/export?format=csv")
38 | response = httpx.get(csv_url, follow_redirects=True)
39 |
40 | if response.status_code != 200:
41 | raise Exception(f"Failed to fetch CSV data: {response.status_code}")
42 |
43 | csv_content = response.text.splitlines()
44 | reader = csv.reader(csv_content)
45 | provider = cls()
46 |
47 | next(reader)
48 | for row in reader:
49 | key = row[0]
50 | message = row[1]
51 | weight = int(row[2])
52 | # I swear to god linebreaks
53 | message_with_linebreaks = message.replace(r"\n", "\n")
54 | provider.add(key, MessageEntry(message_with_linebreaks, weight))
55 |
56 | return provider
57 |
58 | def add(self, key: str, entry: MessageEntry):
59 | if key in self.entries:
60 | self.entries[key].append(entry)
61 | else:
62 | self.entries[key] = [entry]
63 |
64 | def __call__(self, key: str, **kwargs) -> str:
65 | if key not in self.entries:
66 | return f"Message-Key `{key}` is not defined!"
67 |
68 | entries = self.entries[key]
69 | entry_to_render = None
70 | if len(entries) == 1:
71 | entry_to_render = entries[0]
72 | else:
73 | weights = [entry.weight for entry in entries]
74 | entry_to_render = random.choices(entries, weights=weights, k=1)[0]
75 |
76 | return self.env.from_string(entry_to_render.message).render(**kwargs)
77 |
--------------------------------------------------------------------------------
/test/riven_grader_test.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from src.riven_grader import RivenGrader
3 | from src.model import Riven, RivenEffect
4 | from src.sources import WarframeWiki
5 | import pytest_asyncio
6 |
7 |
8 | @pytest_asyncio.fixture(scope="session", autouse=True)
9 | async def real_wiki():
10 | wiki = WarframeWiki()
11 | await wiki.refresh()
12 | return wiki
13 |
14 |
15 | def test_is_constructable():
16 | wiki = WarframeWiki()
17 | riven_grader = RivenGrader(wiki)
18 | assert riven_grader is not None
19 |
20 |
21 | @pytest.mark.parametrize(
22 | "riven",
23 | [
24 | Riven(
25 | name="Laetum",
26 | weapon="Laetum",
27 | positives=[(RivenEffect.MS, 63.8), (RivenEffect.DMG, 104.7)],
28 | negatives=[],
29 | ), # 2 positives
30 | Riven(
31 | name="Torid",
32 | weapon="Torid",
33 | positives=[
34 | (RivenEffect.ELEC, 92.2),
35 | (RivenEffect.MS, 86.1),
36 | (RivenEffect.PFS, 90.8),
37 | ],
38 | negatives=[],
39 | ), # 3 positives
40 | Riven(
41 | name="Nami Solo",
42 | weapon="Nami Solo",
43 | positives=[(RivenEffect.SC, 147.5), (RivenEffect.FR, 89.6)],
44 | negatives=[(RivenEffect.SLASH, -83.5)],
45 | ), # 2 positives 1 negative
46 | Riven(
47 | name="Strun",
48 | weapon="Strun",
49 | positives=[
50 | (RivenEffect.DMG, 214.9),
51 | (RivenEffect.CC, 120.3),
52 | (RivenEffect.RLS, 67.1),
53 | ],
54 | negatives=[(RivenEffect.SC, -89.7)],
55 | ), # 3 positives 1 negative
56 | Riven(
57 | name="Strun",
58 | weapon="Strun",
59 | positives=[(RivenEffect.ELEC, 167.4), (RivenEffect.MS, 220.7)],
60 | negatives=[(RivenEffect.REC, 63.9)],
61 | ), # neg recoil
62 | Riven(
63 | name="Dual Toxocyst",
64 | weapon="Dual Toxocyst",
65 | positives=[
66 | (RivenEffect.REC, -93.1),
67 | (RivenEffect.CD, 84.1),
68 | (RivenEffect.SC, 97.2),
69 | ],
70 | negatives=[],
71 | ), # pos recoil
72 | ],
73 | )
74 | @pytest.mark.asyncio
75 | async def test_can_valdiate_valid_riven(riven, real_wiki):
76 | riven_grader = RivenGrader(real_wiki)
77 | result, error = await riven_grader.valdiate(riven)
78 | assert result
79 |
--------------------------------------------------------------------------------
/src/riven_grader.py:
--------------------------------------------------------------------------------
1 | from model.rivens import Riven, RivenEffect
2 | from model.weapon import WeaponModType
3 | from sources import WarframeWiki
4 | from typing import Optional
5 |
6 |
7 | class RivenGrader:
8 | def __init__(self, wiki: WarframeWiki):
9 | self.wiki = wiki
10 |
11 | async def valdiate(self, riven: Riven) -> tuple[bool, Optional[str]]:
12 | """
13 | Check if the provided riven can exist and report any issues.
14 | """
15 |
16 | try:
17 | # check if the riven has a valide amount of positives / negatives
18 | riven_type = riven.riven_type
19 | except ValueError as e:
20 | return False, str(e)
21 |
22 | # get the weapon from the wiki
23 | weapon = await self.wiki.weapon(riven.weapon)
24 | if weapon is None:
25 | return False, f"Could not find weapon `{riven.weapon}`"
26 |
27 | # check if the weapon type can have a riven (Mainly filters out Railjack weapons)
28 | if weapon.mod_type == WeaponModType.Misc:
29 | return (
30 | False,
31 | f"Weapon `{riven.weapon}` can't have a riven because it's mod type is `{weapon.mod_type.value}`!",
32 | )
33 |
34 | stat_errors = []
35 | for (riven_effect, value), is_pos in riven.all_stats:
36 | riven_effect: RivenEffect = riven_effect
37 | value: float = value
38 | # Check if this stat is valid for the weapon type
39 | if not riven_effect.valid_on_mod_type(weapon.mod_type.value):
40 | stat_errors.append(
41 | f"Stat `{riven_effect.value}` is not valid for weapon `{riven.weapon}` of type `{weapon.mod_type.value}`"
42 | )
43 | continue
44 |
45 | compensated_value = value
46 | # Check if the value is within the valid range
47 | min_value, max_value = riven_effect.calculate_range(
48 | weapon.riven_disposition.disposition,
49 | weapon.mod_type,
50 | riven_type,
51 | is_negative=not is_pos,
52 | )
53 | if compensated_value < min_value or compensated_value > max_value:
54 | stat_errors.append(
55 | f"Stat `{riven_effect.value}` with value `{value}` is out of range for weapon `{riven.weapon}` of type `{weapon.mod_type.value}`. Expected range: {min_value} - {max_value}"
56 | )
57 |
58 | if len(stat_errors) > 0:
59 | return False, "\n".join(stat_errors)
60 |
61 | return True, None
62 |
--------------------------------------------------------------------------------
/test/message_provider_test.py:
--------------------------------------------------------------------------------
1 | from src.message_provider import MessageProvider, MessageEntry
2 | import random
3 |
4 |
5 | def test_is_constructable():
6 | provider = MessageProvider()
7 | assert provider is not None
8 | assert len(provider.entries) == 0
9 |
10 |
11 | def test_can_add():
12 | provider = MessageProvider()
13 | provider.add("TEST", MessageEntry("FOOBAR", 1))
14 | assert len(provider.entries) == 1
15 |
16 |
17 | def test_can_add_twice():
18 | provider = MessageProvider()
19 | provider.add("TEST", MessageEntry("FOOBAR", 1))
20 | provider.add("TEST", MessageEntry("FOOBAR2", 1))
21 | assert len(provider.entries) == 1
22 |
23 |
24 | def test_reports_missing_key():
25 | provider = MessageProvider()
26 | message = provider(key="test")
27 | assert message is not None
28 | assert message == "Message-Key `test` is not defined!"
29 |
30 |
31 | def test_renders_entry():
32 | provider = MessageProvider()
33 | provider.add("TEST", MessageEntry("Hello {{ user }}", 1))
34 | message = provider(key="TEST", user="Rynn")
35 | assert message == "Hello Rynn"
36 |
37 |
38 | def test_samples_entries():
39 | provider = MessageProvider()
40 | provider.add("TEST", MessageEntry("1", 1))
41 | provider.add("TEST", MessageEntry("2", 1))
42 | random.seed(42)
43 | messages = [provider("TEST") for i in range(4)]
44 | assert messages == ["2", "1", "1", "1"]
45 |
46 |
47 | def test_samples_weighted_entries():
48 | provider = MessageProvider()
49 | provider.add("TEST", MessageEntry("1", 1))
50 | provider.add("TEST", MessageEntry("2", 100_000))
51 | random.seed(42)
52 | messages = [provider("TEST") for i in range(4)]
53 | assert messages == ["2", "2", "2", "2"]
54 |
55 |
56 | def test_is_constructable_from_csv():
57 | provider = MessageProvider.from_csv("messages.csv")
58 | assert provider is not None
59 | assert len(provider.entries) == 2
60 | message = provider(key="TEST", user="Rynn")
61 | assert message == "Hello Opperator Rynn"
62 |
63 |
64 | def test_is_constructable_from_gsheet():
65 | provider = MessageProvider.from_gsheets(
66 | "https://docs.google.com/spreadsheets/d/1iIcJkWBY898qGPhkQ3GcLlj1KOkgjlWxWkmiHkzDuzk/edit"
67 | )
68 | # assert provider is not None
69 | assert provider is not None, (
70 | "Provider could not be constructed from the given sheet."
71 | )
72 | assert provider.entries, "No entries were loaded from the sheet."
73 |
74 |
75 | # assert len(provider.entries) >= 2
76 | # message = provider(key = "TEST", user = "Rynn")
77 | # assert message == "This is a test message for Rynn."
78 |
--------------------------------------------------------------------------------
/src/riven_grader_v1.py:
--------------------------------------------------------------------------------
1 | class RivenGrader:
2 | def grade_riven(
3 | self,
4 | stats: list,
5 | best_stats: list,
6 | desired_stats: list,
7 | harmless_negatives: list,
8 | ) -> int:
9 | """Grade the riven based on its stats."""
10 |
11 | if len(stats) < 2 or len(stats) > 4:
12 | return 0 # Invalid riven due to too few or too many stats
13 |
14 | # Classify stats
15 | best_matches = [stat for stat in stats if stat in best_stats]
16 | desired_matches = [stat for stat in stats if stat in desired_stats]
17 | negative_stats = [stat for stat in stats if stat.startswith("-")]
18 |
19 | harmful_negatives = [
20 | stat
21 | for stat in negative_stats
22 | if stat[1:] in best_stats or stat[1:] in desired_stats
23 | ]
24 | harmless_negatives_in_stats = [
25 | stat for stat in negative_stats if stat[1:] in harmless_negatives
26 | ]
27 | neutral_negatives = [
28 | stat
29 | for stat in negative_stats
30 | if stat[1:] not in best_stats
31 | and stat[1:] not in desired_stats
32 | and stat[1:] not in harmless_negatives
33 | ]
34 |
35 | # 5 = Perfect: At least one best stat, no harmful negatives, and at least one harmless negative
36 | if (
37 | len(best_matches) >= 1
38 | and len(harmless_negatives_in_stats) == 1
39 | and len(desired_matches) == len(stats) - 1 - len(best_matches)
40 | ):
41 | return 5 # Perfect if at least one best stat, no harmful negatives, and one harmless negative
42 |
43 | # 4 = Prestigious: All desired stats, or a combination of best and desired stats, may have harmless or neutral negative
44 | if (
45 | len(best_matches) + len(desired_matches) == len(stats)
46 | and len(harmful_negatives) == 0
47 | ):
48 | return 4 # Prestigious if all desired stats with no harmful negative
49 |
50 | if len(best_matches) + len(desired_matches) > 0 and len(harmful_negatives) == 0:
51 | if len(stats) == len(best_matches) + len(desired_matches) + len(
52 | harmless_negatives_in_stats
53 | ) + len(neutral_negatives):
54 | return 4 # Prestigious if combination of best and desired stats with harmless/neutral negatives
55 |
56 | # 3 = Decent: At least one best or desired stat, the rest neutral or harmless negative, or no negative
57 | if (
58 | len(best_matches) == 1
59 | or len(desired_matches) + len(harmless_negatives_in_stats) >= len(stats) / 2
60 | ):
61 | if len(harmful_negatives) == 0:
62 | return 3 # Decent
63 |
64 | # 2 = Neutral: No best or desired stats, but no harmful negative
65 | if len(harmful_negatives) == 0:
66 | return 2 # Neutral
67 |
68 | # 1 = Unusable: Harmful negative present
69 | return 1 # Unusable
70 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.csv
2 | .env*
3 | state.json
4 | jericho_service.json
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | wheels/
27 | share/python-wheels/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 | MANIFEST
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 | cover/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | .pybuilder/
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | # For a library or package, you might want to ignore these files since the code is
91 | # intended to run in multiple environments; otherwise, check them in:
92 | # .python-version
93 |
94 | # pipenv
95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
98 | # install all needed dependencies.
99 | #Pipfile.lock
100 |
101 | # poetry
102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
103 | # This is especially recommended for binary packages to ensure reproducibility, and is more
104 | # commonly ignored for libraries.
105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
106 | #poetry.lock
107 |
108 | # pdm
109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
110 | #pdm.lock
111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
112 | # in version control.
113 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
114 | .pdm.toml
115 | .pdm-python
116 | .pdm-build/
117 |
118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
119 | __pypackages__/
120 |
121 | # Celery stuff
122 | celerybeat-schedule
123 | celerybeat.pid
124 |
125 | # SageMath parsed files
126 | *.sage.py
127 |
128 | # Environments
129 | .env
130 | .venv
131 | env/
132 | venv/
133 | ENV/
134 | env.bak/
135 | venv.bak/
136 |
137 | # Spyder project settings
138 | .spyderproject
139 | .spyproject
140 |
141 | # Rope project settings
142 | .ropeproject
143 |
144 | # mkdocs documentation
145 | /site
146 |
147 | # mypy
148 | .mypy_cache/
149 | .dmypy.json
150 | dmypy.json
151 |
152 | # Pyre type checker
153 | .pyre/
154 |
155 | # pytype static type analyzer
156 | .pytype/
157 |
158 | # Cython debug symbols
159 | cython_debug/
160 |
161 | # PyCharm
162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
164 | # and can be added to the global gitignore or merged into this file. For a more nuclear
165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
166 | #.idea/
167 |
--------------------------------------------------------------------------------
/src/sources/warframe_wiki.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from utils.http import HardenedHttpClient, DEFAULT_SUCCESS_CODES
3 | from model.weapon import Weapon, RivenDisposition, WeaponModType
4 | from bs4 import BeautifulSoup
5 | from typing import Optional
6 | import re
7 | from .weapon_lookup import WeaponLookup
8 |
9 |
10 | class WarframeWiki:
11 | """
12 | A class to interact with the Warframe Wiki
13 | """
14 |
15 | def __init__(
16 | self, weapon_lookup: WeaponLookup = WeaponLookup(), timeout: int = 10_000
17 | ):
18 | self.base_url = "https://wiki.warframe.com"
19 | self.client = HardenedHttpClient(
20 | httpx.AsyncClient(timeout=timeout), success_codes=DEFAULT_SUCCESS_CODES
21 | ) # Initialize the HTTP client
22 | self.weapon_lookup = weapon_lookup
23 |
24 | async def weapon(self, weapon_name: str) -> Weapon:
25 | """
26 | Get the wiki page for a weapon
27 | """
28 | if weapon_name not in self.weapon_lookup:
29 | return None
30 |
31 | url = self.base_url + self.weapon_lookup[weapon_name].wiki_url
32 | response = await self.client.get(url)
33 | response.raise_for_status()
34 |
35 | soup = BeautifulSoup(response.text, features="html.parser")
36 |
37 | header_contanier = soup.find("h1", id="firstHeading").find("span")
38 | name = header_contanier.text
39 |
40 | image_span = soup.find("span", class_="main-image")
41 | image_link = None
42 | if image_span:
43 | image_container = image_span.find("img")
44 | image_link = (
45 | self.base_url + image_container.attrs["src"]
46 | if image_container and "src" in image_container.attrs
47 | else None
48 | )
49 |
50 | def extract_data(data_source: str) -> Optional[str]:
51 | link_a = soup.find("a", string=data_source)
52 | if link_a:
53 | row = link_a.find_parent("div").find_parent("div")
54 | value_column = row.find("div", class_="value")
55 | if value_column:
56 | return value_column.text
57 | return None
58 |
59 | raw_disposition = extract_data("Disposition")
60 | match = re.search(r"([●○]+)\s\(([\d\.]+)x\)", raw_disposition)
61 | if match:
62 | disposition_symbol = match.group(1)
63 | disposition_value = float(match.group(2))
64 | disposition = RivenDisposition(
65 | disposition=disposition_value, symbol=disposition_symbol
66 | )
67 | else:
68 | disposition = RivenDisposition()
69 | weapon_type = extract_data("Type")
70 | slot = extract_data("Slot")
71 | raw_mastery = extract_data("Mastery Rank Requirement")
72 | if raw_mastery:
73 | mastery = int(raw_mastery)
74 | else:
75 | mastery = None
76 | mod_type = WeaponModType.from_raw_data(slot, weapon_type)
77 |
78 | return Weapon(
79 | name=name,
80 | url=url,
81 | image=image_link,
82 | riven_disposition=disposition,
83 | mr=mastery,
84 | weapon_type=weapon_type,
85 | slot=slot,
86 | mod_type=mod_type,
87 | )
88 |
89 | def mark_riven_capable(self, weapon_name: str):
90 | """
91 | Mark a weapon as riven capable
92 | """
93 | if weapon_name in self.weapon_lookup:
94 | self.weapon_lookup[weapon_name].can_have_rivens = True
95 |
96 | async def refresh(self):
97 | """
98 | Refresh the wiki data
99 | """
100 | weapon_base_url = f"{self.base_url}/w/Weapons#Primary"
101 | response = await self.client.get(weapon_base_url)
102 | response.raise_for_status()
103 | soup = BeautifulSoup(response.text, features="html.parser")
104 | table = soup.find("div", class_="tabbertab")
105 | if table:
106 | weapons = table.find_all("span", style="border-bottom:2px dotted; color:;")
107 | for weapon in weapons:
108 | link = weapon.find_parent("a")
109 | if link and "href" in link.attrs:
110 | self.weapon_lookup.add(
111 | weapon.get_text().replace("\xa0", " "), link["href"]
112 | )
113 |
114 | # Manually add the kitgun chambers since they are not in the weapon list
115 | self.weapon_lookup.add("Catchmoon", "/w/Catchmoon")
116 | self.weapon_lookup.add("Gaze", "/w/Gaze")
117 | self.weapon_lookup.add("Rattleguts", "/w/Rattleguts")
118 | self.weapon_lookup.add("Sporelacer", "/w/Sporelacer")
119 | self.weapon_lookup.add("Tombfinger", "/w/Tombfinger")
120 | self.weapon_lookup.add("Vermisplicer", "/w/Vermisplicer")
121 | # Ok no idea why this isnt in the weapon list but we need to add it
122 | self.weapon_lookup.add("Dark Split-Sword", "/w/Dark_Split-Sword")
123 |
--------------------------------------------------------------------------------
/src/sources/weapon_lookup.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 | import difflib
3 | from model.rivens import RivenEffect
4 | from typing import Optional
5 |
6 |
7 | class WantedRivenStats(BaseModel):
8 | best: Optional[list[RivenEffect]]
9 | wanted: Optional[list[RivenEffect]]
10 | wanted_negatives: Optional[list[RivenEffect]]
11 |
12 |
13 | class RivenRecommendations(BaseModel):
14 | weapon: str
15 | comment: Optional[str]
16 | stats: list[WantedRivenStats]
17 |
18 |
19 | class WeaponLookupEntry(BaseModel):
20 | display_name: str
21 | wiki_url: str
22 | normalized_name: str
23 | riven_recommendations: Optional[RivenRecommendations] = None
24 | median_plat_price: Optional[float] = None
25 | weapon_variants: Optional[list[str]] = None
26 | base_weapon: Optional[str] = None
27 |
28 | @property
29 | def is_base_weapon(self):
30 | return self.base_weapon is None
31 |
32 | @property
33 | def can_have_rivens(self):
34 | return self.riven_recommendations is not None
35 |
36 | def get_market_auction_url(self):
37 | """
38 | Return the URL for the weapon's riven market auctions
39 | """
40 | if not self.can_have_rivens:
41 | return None
42 |
43 | wf_market_weapon_name = (
44 | self.display_name.replace(" ", "_").replace("&", "and").lower().strip()
45 | )
46 | return f"https://warframe.market/auctions/search?type=riven&weapon_url_name={wf_market_weapon_name}&polarity=any&sort_by=price_asc"
47 |
48 |
49 | class WeaponLookup:
50 | """
51 | A unified weapon lokup class to collect displaynames and URLs for weapons
52 | """
53 |
54 | def __init__(self):
55 | self.weapon_lookup: dict[str, WeaponLookupEntry] = {}
56 |
57 | def _normalize_weapon_name(self, weapon_name: str) -> str:
58 | return weapon_name.replace(" ", "_").lower()
59 |
60 | def add(self, weapon_name: str, url: str):
61 | normalized_name = self._normalize_weapon_name(weapon_name)
62 | self.weapon_lookup[normalized_name] = WeaponLookupEntry(
63 | display_name=weapon_name, wiki_url=url, normalized_name=normalized_name
64 | )
65 |
66 | def __getitem__(self, key: str) -> WeaponLookupEntry:
67 | normalized = self._normalize_weapon_name(key)
68 | return self.weapon_lookup[normalized]
69 |
70 | def __contains__(self, key: str) -> bool:
71 | normalized = self._normalize_weapon_name(key)
72 | return normalized in self.weapon_lookup
73 |
74 | def __len__(self):
75 | return len(self.weapon_lookup)
76 |
77 | def fuzzy_search(
78 | self, weapon_name: str, n: int = 20, cutoff=0.35, can_have_rivens: bool = False
79 | ) -> list[WeaponLookupEntry]:
80 | if can_have_rivens:
81 | weapon_names = [
82 | w.display_name for w in self.weapon_lookup.values() if w.can_have_rivens
83 | ]
84 | else:
85 | weapon_names = [w.display_name for w in self.weapon_lookup.values()]
86 | matches = difflib.get_close_matches(
87 | weapon_name, weapon_names, n=n, cutoff=cutoff
88 | )
89 | if len(matches) > 0:
90 | return [self[match] for match in matches]
91 | return []
92 |
93 | def rebuild_weapon_relations(self):
94 | """
95 | Rebuild the weapon relations for all weapons in the lookup
96 | """
97 | for weapon in self.weapon_lookup.values():
98 | weapon_name = weapon.normalized_name
99 | for splitter in ["_", "-"]:
100 | without_prefix = splitter.join(weapon_name.split(splitter)[1:])
101 | without_postfix = splitter.join(weapon_name.split(splitter)[:-1])
102 |
103 | for potential_base in [without_prefix, without_postfix]:
104 | # Handle weapons without prefixes or postfixes
105 | if potential_base == weapon_name:
106 | continue
107 |
108 | # check if the potential_base is in the lookup
109 | if potential_base in self.weapon_lookup:
110 | base_weapon = self.weapon_lookup[potential_base]
111 | # link the weapon to the base weapon
112 | weapon.base_weapon = base_weapon.normalized_name
113 |
114 | # set the riven recommendations of the weapon if the base weapon has riven recommendations
115 | if base_weapon.riven_recommendations is not None:
116 | weapon.riven_recommendations = (
117 | base_weapon.riven_recommendations
118 | )
119 |
120 | # add the weapon variant to the base weapons variants
121 | if base_weapon.weapon_variants is None:
122 | base_weapon.weapon_variants = []
123 | base_weapon.weapon_variants.append(weapon.normalized_name)
124 |
--------------------------------------------------------------------------------
/src/sources/riven_provider.py:
--------------------------------------------------------------------------------
1 | import csv
2 | import httpx
3 | from utils.http import HardenedHttpClient
4 | from model.rivens import RivenEffect
5 | from sources.weapon_lookup import WantedRivenStats, RivenRecommendations, WeaponLookup
6 | from pathlib import Path
7 | import asyncio
8 | import logging
9 | from typing import Optional
10 |
11 |
12 | class RivenRecommendationProvider:
13 | def __init__(self, path: str = "./riven_data") -> None:
14 | self.base_url = "https://docs.google.com/spreadsheets/d/1zbaeJBuBn44cbVKzJins_E3hTDpnmvOk8heYN-G8yy8/export?format=csv&gid="
15 | self.sheets = {
16 | "Primary": "0",
17 | "Secondary": "1505239276",
18 | "Melee": "1413904270",
19 | "Archgun": "289737427",
20 | "Robotic": "965095749",
21 | }
22 | self.client = HardenedHttpClient(httpx.AsyncClient(follow_redirects=True))
23 | self.directory = Path(path)
24 | if not self.directory.exists():
25 | self.directory.mkdir(parents=True, exist_ok=True)
26 |
27 | def parse_stats(self, raw_stats: str) -> Optional[list[RivenEffect]]:
28 | if raw_stats == "":
29 | return None
30 |
31 | stats = []
32 | for stat in raw_stats.split("/"):
33 | try:
34 | stat = stat.strip()
35 | if stat.startswith("-"):
36 | stat = stat[1:]
37 | if stat == "ELEMENT":
38 | stats.append(RivenEffect.ELEC)
39 | stats.append(RivenEffect.TOX)
40 | stats.append(RivenEffect.HEAT)
41 | stats.append(RivenEffect.COLD)
42 | elif stat == "RECOIL":
43 | stats.append(RivenEffect.REC)
44 | elif stat == "AS":
45 | stats.append(RivenEffect.FR)
46 | else:
47 | stats.append(RivenEffect.try_parse(stat))
48 | except ValueError:
49 | logging.error(f"Failed to parse stat: {stat}")
50 | return stats if len(stats) > 0 else None
51 |
52 | def normalize_sheet(
53 | self, sheet_name: str, input_file: str, weapon_lookup: WeaponLookup
54 | ):
55 | """
56 | Normalize the given sheet, ensuring rows are consistent and extracting best stats.
57 | This function dynamically adjusts to column positions based on the header row.
58 | """
59 | with open(input_file, "r", encoding="utf-8") as infile:
60 | reader = csv.reader(infile)
61 | data = list(reader)
62 |
63 | # The first row should contain headers, so we inspect it
64 | headers = data[0]
65 | try:
66 | # Dynamically find the column indices for each important field
67 | weapon_col = headers.index(
68 | "WEAPON"
69 | ) # Adjust to correct header name if needed
70 | best_stats_col = headers.index("POSITIVE STATS:") # Adjust as needed
71 | negative_stats_col = headers.index("NEGATIVE STATS:")
72 | comment_col = headers.index("Notes:")
73 |
74 | except ValueError as e:
75 | raise Exception(f"Missing expected columns in sheet {sheet_name}: {e}")
76 |
77 | # Process all rows after the header
78 | for row in data[1:]:
79 | weapon = row[weapon_col].upper()
80 | raw_positive_stats = row[best_stats_col].upper()
81 | raw_negative_stats = row[
82 | negative_stats_col
83 | ].upper() # Default to empty list if no negative stats column is found
84 |
85 | comment = row[comment_col]
86 | comment = comment if comment != "" else None
87 | if comment:
88 | comment = comment.strip().replace("(NOTE: ", "").replace(")", "")
89 |
90 | negatives = self.parse_stats(raw_negative_stats)
91 |
92 | parsed_stats = []
93 | for positive_slices in raw_positive_stats.split(" OR "):
94 | try:
95 | splits = positive_slices.split(" ")
96 | raw_best = "/".join(splits[:-1])
97 | raw_desired = splits[-1]
98 | best = self.parse_stats(raw_best)
99 | desired = self.parse_stats(raw_desired)
100 |
101 | parsed_stats.append(
102 | WantedRivenStats(
103 | best=best, wanted=desired, wanted_negatives=negatives
104 | )
105 | )
106 | except Exception as e:
107 | logging.error(f"Failed to parse stats for {weapon}: {e}")
108 | continue
109 |
110 | if weapon not in weapon_lookup:
111 | logging.error(f"Unknown weapon `{weapon}` in sheet {sheet_name}")
112 | continue
113 |
114 | weapon_lookup[weapon].riven_recommendations = RivenRecommendations(
115 | weapon=weapon, comment=comment, stats=parsed_stats
116 | )
117 |
118 | async def refresh(self, weapon_lookup: WeaponLookup, force_download: bool = False):
119 | """
120 | Download and normalize all sheets from the Google Sheets URL.
121 | """
122 |
123 | async def download_sheet(sheet_name, gid) -> Path:
124 | """
125 | Download the sheet with the given name and GID.
126 | Supports local caching to avoid redundant downloads.
127 | """
128 | file_path = self.directory / f"{sheet_name}.csv"
129 | if file_path.exists() and not force_download:
130 | logging.info(f"Skipping download of {sheet_name}.csv")
131 | return file_path
132 | url = f"{self.base_url}{gid}"
133 | response = await self.client.get(url)
134 | response.raise_for_status()
135 | with open(file_path, "w", newline="", encoding="utf-8") as file:
136 | file.write(response.text)
137 | return file_path
138 |
139 | tasks = [
140 | download_sheet(sheet_name, gid) for sheet_name, gid in self.sheets.items()
141 | ]
142 | paths = await asyncio.gather(*tasks)
143 |
144 | for path in paths:
145 | sheet_name = path.stem
146 | self.normalize_sheet(sheet_name, path, weapon_lookup)
147 |
--------------------------------------------------------------------------------
/docs.md:
--------------------------------------------------------------------------------
1 | # Table of Contents
2 |
3 | - [Configuration](#configuration)
4 | - [Usage](#usage)
5 | - [Commands](#commands)
6 | - [License](#license)
7 |
8 | ## Configuration
9 |
10 | Cephalon Jericho uses environment variables for configuration which are required.
11 |
12 | | Variable Name | Description | Example Value |
13 | |---------------------|------------------------------|------------------------|
14 | | `DISCORD_TOKEN` | Your bot's Discord token | `YOUR_DISCORD_TOKEN` |
15 | | `GUILD_ID` | The ID of your Discord guild | `YOUR_GUILD_ID` |
16 | | `CLAN_NAME` | Your Warframe clan's name | `YOUR_CLAN_NAME` |
17 | | `REPORT_CHANNEL_ID` | Channel ID for bot reports | `YOUR_CHANNEL_ID` |
18 | | `MEMBER_ROLE_ID` | Role ID for guild members | `YOUR_MEMBER_ROLE_ID` |
19 | | `GUEST_ROLE_ID` | Role ID for guest members | `YOUR_GUEST_ROLE_ID` |
20 | | `MAINTENANCE_ROLE_ID` | Role ID for maintenance users | `YOUR_MAINTENANCE_ROLE_ID` |
21 | | `MESSAGE_PROVIDER_URL` | URL for the message provider, defaults to [jericho_text](https://docs.google.com/spreadsheets/d/1iIcJkWBY898qGPhkQ3GcLlj1KOkgjlWxWkmiHkzDuzk/edit) | `YOUR_MESSAGE_PROVIDER_URL` |
22 |
23 | ## Usage
24 |
25 | Once the bot is running, you can interact with it in the following ways:
26 |
27 | 1. **Command for role assign and verification**:
28 |
29 | Use `/role` to open a modal to enter a Warframe username. It is then checked in the Warframe API if it’s part of the entered Guild Name and assigns a corresponding role. If a user is not a member, they have the option to choose a guest role. This is also logged in the backend.
30 |
31 | 2. **Absence report**:
32 |
33 | To self-report an absence to prevent kicking or to inform administration/moderators, users can fill out the form pulled up by the `/absence` command. Similar to the Admin tool `/archive`, the input is then sent to the `REPORT_CHANNEL_ID` report text channel.
34 |
35 | 3. **Rivens**:
36 |
37 | To find details about what stats one should aim for with a riven, users can search a weapon with `/riven_weapon_stats` which offers suggested weapons while typing. With the `/riven_grade` command a user can first enter a weapon and then stats to recieve a corresponding grade from 5. `/riven_help` provides explanations to stat abriviations, the weapon stats and the way jericho grades rivens. Grading is done based on the work of [44bananas](https://docs.google.com/spreadsheets/d/1zbaeJBuBn44cbVKzJins_E3hTDpnmvOk8heYN-G8yy8/edit?gid=1687910063#gid=1687910063). For grading, the variation in attributes is not considered.
38 | The 5 grades by which Jericho grades:
39 | *Perfect*
40 | A perfect riven has at least one of the best stats for the weapon and the rest are either best or desired stats, with a harmless negative. This is what you would often consider a god roll, so count yourself lucky Operator, if you find a roll such as this.
41 |
42 | *Prestigious*
43 | A prestigious riven is a roll that just barely isn't a god roll by traditional standards. It only has desired or best stats and may or may not have a harmless or non detrimental negative, with no irrelevant stat on it. These are rivens that will evelate whatever build you put it in and push a weapon to levels that could only be bested by a god roll.
44 |
45 | *Decent*
46 | A decent riven has at least one best or desired stat, the rest may be stats that are not in that category for the weapon and it may have either a harmless or non detrimental negative or no negative at all. This is still a good roll that can fit many builds. Hah, you could say it is decent, Operator.
47 |
48 | *Neutral*
49 | A neutral riven has no desired or best stats, but also no harmful negative. They may be useful for a niche build, or for your build in particular, but the majority of the times you want to roll this riven some more.
50 |
51 | *Unuseable*
52 | An unusuable riven has a harmful negative - simpel as that. Harmful is any negative that would be a desired or best stat if it wasn't the negative and therefore ruins many builds. You'd be adviced to reroll such a riven, unless you have a specific use for it in mind.
53 |
54 | 4. **Other User features**:
55 |
56 | For engagement, Cephalon Jericho offers a simple `/hello` function, as well as a Koumei-inspired dice roller with `/koumei`. For usage of the Warframe API, users can query Warframe profiles with `/profile` as well, which also reports if a user is part of Golden Tenno. Upon popular request, users can interact with Jericho outside of the `/judge_jericho` function with `/smooch` to give their favorite Cephalon a little kiss.
57 |
58 | 5. **Admin features**:
59 |
60 | `/archive` pulls up a modal that allows you to enter a title and report summary, which is then pushed into a corresponding report channel defined with the `REPORT_CHANNEL_ID` in the `.env` file. With `/text_maintenance` and `/riven_maintenance` users with the corresponding maintenance role can refresh the CSVs made from the google sheets.
61 |
62 | 6. **Logging**:
63 |
64 | To ensure error catching and user issues, most functionalities that require the Warframe API or cause a change in Discord/redirect messages are logged in the backend.
65 |
66 |
67 | ## Commands
68 |
69 | The current list of commands contains the following functions:
70 |
71 | - `/absence`: A self reporting absence form.
72 | - `/archive`: A self-archiving form for note-taking and records.
73 | - `/hello`: Says hello to Cephalon Jericho.
74 | - `/judge_jericho`: Inform Cephalon Jericho on whether or whether not he was a good bot - with resulting consequences.
75 | - `koumei`: A simple d6 dice bot, with custom outputs for a jackpot and a snake eyes result.
76 | - `/profile`: A form to check warframe user data, including warframe name, mastery rank and clan.
77 | - `/role`: Allows users to either choose member or guest roles. To verify as a member, users must enter their Warframe username, which is checked against the guild name. After verification, the corresponding role is automatically assigned.
78 | - `/smooch`: Allows the user to give Cephalon Jericho a little kiss.
79 | - `/maintenance_text`: Allows users with the maintenance role to refresh currently loaded google sheet for text lines
80 | -`/maintenance_sync_commands`: Allows users with the maintenance role to refresh currently loaded commands to remove duplicats and re-add missing commands while server is live.
81 | - `/maintenance_riven`: Allows users with the maintenance role to refresh the currently loaded google sheet for rivens
82 | - `/riven_weapon_stats`: An autosuggesting weapon query for best, desired and harmless negative stats corresponding to the weapon
83 | - `/riven_grade`: Grades a riven based on provided weapon and stats by scores based on 5 overall grades. This is solely based on attributes, and not the individual attribute variation roll.
84 | - `/tough_love`: A social command providing harsh, but true advice.
85 | - `/feeling_lost`: A social command meant to cheer up and motivate.
86 | - `/trivia`: A social command providing a random fact about warframe.
87 | - `/rate_outfit`: A social command asking Jericho to rate your current outfit.
88 |
89 | ## License
90 |
91 | Cephalon Jericho is licensed under the [MIT License](LICENSE).
--------------------------------------------------------------------------------
/src/ui/role.py:
--------------------------------------------------------------------------------
1 | from discord import User, Interaction, TextStyle, SelectOption, ButtonStyle, Forbidden
2 | from discord.ui import Button, TextInput, Select, View, Modal
3 | from settings import Clan, Role
4 | from constants import SETTINGS, MESSAGE_PROVIDER
5 |
6 |
7 | class ErrorHandlingButton(Button):
8 | async def _callback(self, interaction: Interaction):
9 | raise NotImplementedError
10 |
11 | async def callback(self, interaction: Interaction):
12 | try:
13 | await self._callback(interaction)
14 | except Exception as e:
15 | await interaction.edit_original_response(
16 | content=MESSAGE_PROVIDER("ROLE_ERROR", error=e), view=None
17 | )
18 |
19 |
20 | class RoleDeclineButton(ErrorHandlingButton):
21 | def __init__(self, user: User, clan: Clan, wf_name: str, dm_failed: bool = False):
22 | super().__init__(label="Decline")
23 | self.user = user
24 | self.clan = clan
25 | self.wf_name = wf_name
26 | self.dm_failed = dm_failed # Store DM failure status
27 |
28 | async def _callback(self, interaction: Interaction):
29 | await interaction.response.defer(thinking=False)
30 |
31 |
32 | try:
33 | await self.user.send(content=MESSAGE_PROVIDER("ROLE_DECLINE_USER"))
34 | except Forbidden:
35 | pass
36 |
37 |
38 | await interaction.edit_original_response(
39 | content=MESSAGE_PROVIDER(
40 | "ROLE_DECLINE_BACKEND",
41 | user=self.user.mention,
42 | clan=self.clan.name,
43 | wfname=self.wf_name,
44 | interactionuser=interaction.user.mention,
45 | ),
46 | view=None,
47 | )
48 |
49 |
50 | class RoleAssignButton(ErrorHandlingButton):
51 | def __init__(self, user: User, role: Role, clan: Clan, wf_name: str, dm_failed: bool = False):
52 | super().__init__(label=role.name, style=ButtonStyle.primary)
53 | self.user = user
54 | self.role = role
55 | self.clan = clan
56 | self.wf_name = wf_name
57 | self.dm_failed = dm_failed # Store DM failure status
58 |
59 | async def _callback(self, interaction: Interaction):
60 | await interaction.response.defer(thinking=False)
61 | guild = interaction.guild
62 | member = guild.get_member(self.user.id)
63 |
64 | if not member:
65 | await interaction.followup.send(
66 | MESSAGE_PROVIDER("ROLE_ASSIGN_FAILED", user=self.user.mention),
67 | ephemeral=True,
68 | )
69 | return
70 |
71 | # Assign new role(s)
72 | for role_id in self.role.ids:
73 | guild_role = guild.get_role(role_id)
74 | if guild_role:
75 | try:
76 | await member.add_roles(guild_role)
77 | except Forbidden:
78 | await interaction.followup.send(
79 | MESSAGE_PROVIDER("ROLE_ASSIGN_FAILED", user=self.user.mention),
80 | ephemeral=True,
81 | )
82 | return
83 |
84 | # Remove the guest role
85 | guest_role = guild.get_role(SETTINGS.GUEST_ROLE_ID)
86 | if guest_role and guest_role in member.roles:
87 | try:
88 | await member.remove_roles(guest_role)
89 | except Forbidden:
90 | await interaction.followup.send(
91 | MESSAGE_PROVIDER("ROLE_REMOVE_FAILED", user=self.user.mention),
92 | ephemeral=True,
93 | )
94 |
95 | # Send DM confirmation
96 | try:
97 | await member.send(
98 | MESSAGE_PROVIDER(
99 | "ROLE_ACCEPT_USER",
100 | role=self.role.name,
101 | clan=self.clan.name,
102 | wfname=self.wf_name,
103 | )
104 | )
105 | except Forbidden:
106 | pass #DM failed, catching 403
107 |
108 | # Edit the original message
109 | await interaction.edit_original_response(
110 | content=MESSAGE_PROVIDER(
111 | "ROLE_ACCEPT_BACKEND",
112 | role=self.role.name,
113 | clan=self.clan.name,
114 | wfname=self.wf_name,
115 | user=self.user.mention,
116 | interactionuser=interaction.user.mention,
117 | ),
118 | view=None,
119 | )
120 |
121 |
122 | class AssignRoleView(View):
123 | def __init__(self, user: User, clan: Clan, wf_name: str, dm_failed: bool):
124 | super().__init__(timeout=None)
125 | self.assign_buttons = []
126 | for role in clan.roles:
127 | button = RoleAssignButton(user, role, clan, wf_name=wf_name, dm_failed=dm_failed)
128 | self.assign_buttons.append(button)
129 | self.add_item(button)
130 |
131 | self.decline = RoleDeclineButton(user, clan, wf_name=wf_name, dm_failed=dm_failed)
132 | self.add_item(self.decline)
133 |
134 |
135 | class ProfileModal(Modal, title="Confirm Clan Membership"):
136 | def __init__(self, clan: Clan):
137 | super().__init__(title="Confirm Clan Membership")
138 | self.clan = clan
139 | self.title_input = TextInput(
140 | label="Warframe Username",
141 | style=TextStyle.short,
142 | placeholder="Input Warframe username here.",
143 | )
144 | self.add_item(self.title_input)
145 |
146 | async def on_submit(self, interaction: Interaction):
147 | await interaction.response.defer(ephemeral=True)
148 | wf_name = self.title_input.value.strip()
149 | guild = interaction.guild
150 | channel = guild.get_channel(self.clan.channel)
151 | member = interaction.user
152 |
153 | if not wf_name:
154 | await interaction.edit_original_response(
155 | content=MESSAGE_PROVIDER("ROLE_NOT_FOUND", user=wf_name),
156 | view=None,
157 | )
158 | return
159 |
160 | dm_failed = False
161 | try:
162 | await interaction.user.send(MESSAGE_PROVIDER("ROLE_REGISTERED", user=wf_name))
163 | except Forbidden:
164 | dm_failed = True # Track that DM failed
165 | print(f"User {member.display_name} has DMs disabled.")
166 |
167 | await channel.send(
168 | content=MESSAGE_PROVIDER(
169 | "ROLE_CLAIM_DM_FAILED" if dm_failed else "ROLE_CLAIM",
170 | member=member.mention,
171 | clan=self.clan.name,
172 | wfname=wf_name,
173 | ),
174 | view=AssignRoleView(user=member, clan=self.clan, wf_name=wf_name, dm_failed=dm_failed),
175 | )
176 |
177 | # Assign guest role in the meantime
178 | role = guild.get_role(SETTINGS.GUEST_ROLE_ID)
179 | await member.add_roles(role)
180 |
181 | await interaction.edit_original_response(
182 | content=MESSAGE_PROVIDER("ROLE_REGISTERED", user=wf_name),
183 | view=None
184 | )
185 |
186 | async def on_error(self, interaction: Interaction, error: Exception):
187 | print(f"Unexpected error in ProfileModal: {repr(error)}")
188 | if interaction.response.is_done():
189 | await interaction.followup.send(
190 | MESSAGE_PROVIDER("ROLE_ERROR", error=error), ephemeral=True
191 | )
192 | else:
193 | await interaction.edit_original_response(
194 | content=MESSAGE_PROVIDER("ROLE_ERROR", error=error), view=None
195 | )
196 |
197 | class ClanDropdown(Select):
198 | def __init__(self):
199 | options = []
200 | for clan in SETTINGS.CLANS:
201 | options.append(
202 | SelectOption(
203 | label=clan.name, value=clan.name, description=clan.description
204 | )
205 | )
206 | options.append(
207 | SelectOption(
208 | label=SETTINGS.GUEST_NAME,
209 | value=SETTINGS.GUEST_NAME,
210 | description=MESSAGE_PROVIDER("ROLE_JOIN_GUEST"),
211 | )
212 | )
213 | super().__init__(
214 | placeholder="Choose your Clan", options=options, min_values=1, max_values=1
215 | )
216 |
217 | async def callback(self, interaction: Interaction):
218 | try:
219 | selection = self.values[0]
220 | if selection == SETTINGS.GUEST_NAME:
221 | guild = interaction.guild
222 | role = guild.get_role(SETTINGS.GUEST_ROLE_ID)
223 | member = interaction.user
224 | await member.add_roles(role)
225 | await interaction.response.edit_message(
226 | content=MESSAGE_PROVIDER("ROLE_GUEST"), view=None
227 | )
228 | else:
229 | clan = next(c for c in SETTINGS.CLANS if c.name == selection)
230 | await interaction.response.send_modal(ProfileModal(clan))
231 | except Exception as e:
232 | await interaction.response.send_message(
233 | MESSAGE_PROVIDER("ROLE_ERROR", error=e), ephemeral=True
234 | )
235 |
236 |
237 | class RoleView(View):
238 | def __init__(self):
239 | super().__init__()
240 | self.add_item(ClanDropdown())
--------------------------------------------------------------------------------
/src/model/rivens.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from pydantic import BaseModel
3 | from .weapon import WeaponModType
4 | from typing import NamedTuple, Optional
5 |
6 |
7 | class RivenType(str, Enum):
8 | _2Pos0Neg = "2Pos0Neg"
9 | _2Pos1Neg = "2Pos1Neg"
10 | _3Pos0Neg = "3Pos0Neg"
11 | _3Pos1Neg = "3Pos1Neg"
12 |
13 | def bonus(self) -> float:
14 | match self:
15 | case RivenType._2Pos0Neg:
16 | return 0.99
17 | case RivenType._2Pos1Neg:
18 | return 1.2375
19 | case RivenType._3Pos0Neg:
20 | return 0.75
21 | case RivenType._3Pos1Neg:
22 | return 0.9375
23 |
24 | def malus(self) -> float:
25 | match self:
26 | case RivenType._2Pos1Neg:
27 | return -0.495
28 | case RivenType._3Pos1Neg:
29 | return -0.75
30 | case _:
31 | return 0
32 |
33 |
34 | class RivenEffect(str, Enum):
35 | CD = "Critical Damage"
36 | CC = "Critical Chance"
37 | DMG = "Damage / Melee Damage"
38 | MS = "Multishot"
39 | FR = "Fire Rate / Attack Speed"
40 | RLS = "Reload Speed"
41 | TOX = "Toxin Damage"
42 | DTC = "Damage to Corpus"
43 | DTI = "Damage to Infested"
44 | DTG = "Damage to Grineer"
45 | PUNC = "Puncture"
46 | IMP = "Impact"
47 | MAG = "Magazine Capacity"
48 | REC = "Recoil"
49 | SC = "Status Chance"
50 | PT = "Punch Through"
51 | PFS = "Projectile Speed / Projectile Flight Speed"
52 | IC = "Initial Combo"
53 | EFF = "Heavy Attack Efficiency"
54 | SLIDE = "Critical Chance on Slide Attack"
55 | FIN = "Finisher Damage"
56 | ELEC = "Electricity Damage"
57 | SD = "Status Duration"
58 |
59 | # Additional abbreviations not in the original legend:
60 | ZOOM = "Zoom"
61 | RANGE = "Range"
62 | SLASH = "Slash Damage"
63 | HEAT = "Heat Damage"
64 | COLD = "Cold Damage"
65 | CDUR = "Combo Duration"
66 | CNCC = "Chance Not to Gain Combo Count"
67 | AMMO = "Ammo Maximum"
68 | ACCC = "Additional Combo Count Chance"
69 |
70 | @classmethod
71 | def try_parse(cls, value: str) -> "RivenEffect":
72 | upper_value = value.upper().strip()
73 | for effect in cls:
74 | if effect.name == upper_value:
75 | return effect
76 | raise ValueError(f"Invalid RivenEffect: {value}")
77 |
78 | def render(self, weapon_type: WeaponModType) -> str:
79 | """
80 | Render the effect as a string for the given weapon type
81 | """
82 | if self == RivenEffect.DMG:
83 | return "Melee Damage" if weapon_type == WeaponModType.Melee else "Damage"
84 |
85 | if self == RivenEffect.FR:
86 | return "Attack Speed" if weapon_type == WeaponModType.Melee else "Fire Rate"
87 |
88 | return self.value
89 |
90 | def get_stat(self, stat: float, is_negative: bool) -> float:
91 | """
92 | Apply inversion and nagative denominator to the stat
93 | """
94 |
95 | if self.is_inverted():
96 | stat = stat * -1
97 |
98 | if is_negative:
99 | stat = stat * -1
100 |
101 | return stat
102 |
103 | def is_inverted(self) -> bool:
104 | if self in [RivenEffect.REC]:
105 | return True
106 | return False
107 |
108 | def valid_on_mod_type(self, mod_type: WeaponModType) -> bool:
109 | return RIVEN_EFFECT_LOOKUP[self].values[mod_type] is not None
110 |
111 | def calculate_range(
112 | self,
113 | disposition: float,
114 | mod_type: WeaponModType,
115 | riven_type: RivenType,
116 | is_negative: bool,
117 | ) -> tuple[float, float]:
118 | base = RIVEN_EFFECT_LOOKUP[self].values[mod_type]
119 | if base is None:
120 | return ValueError(
121 | f"Stat `{self.value}` is not valid for weapon type `{mod_type.value}`"
122 | )
123 |
124 | if is_negative:
125 | base *= riven_type.malus()
126 | else:
127 | base *= riven_type.bonus()
128 |
129 | with_disposition = base * disposition
130 |
131 | if self.is_inverted():
132 | with_disposition = with_disposition * -1
133 |
134 | if with_disposition < 0:
135 | # Invert the range for negative stats
136 | return with_disposition * 1.1, with_disposition * 0.9
137 | else:
138 | return with_disposition * 0.9, with_disposition * 1.1
139 |
140 |
141 | class RivenAttribute(BaseModel):
142 | effect: RivenEffect
143 | prefix: str
144 | suffix: str
145 | values: dict[WeaponModType, Optional[float]]
146 |
147 |
148 | class RivenStat(NamedTuple):
149 | effect: RivenEffect
150 | value: float
151 |
152 | def __str__(self) -> str:
153 | return f"{self.effect.value}: {self.value}"
154 |
155 |
156 | class Riven(BaseModel):
157 | name: str
158 | weapon: str
159 | positives: list[RivenStat]
160 | negatives: list[RivenStat]
161 |
162 | @property
163 | def all_stats(self) -> list[tuple[RivenStat, bool]]:
164 | return list(
165 | zip(
166 | self.positives + self.negatives,
167 | [True] * len(self.positives) + [False] * len(self.negatives),
168 | )
169 | )
170 |
171 | @property
172 | def riven_type(self) -> RivenType:
173 | """
174 | Try to determine the type of riven based on the number of positives and negatives.
175 | """
176 | match len(self.positives):
177 | case 2:
178 | match len(self.negatives):
179 | case 0:
180 | return RivenType._2Pos0Neg
181 | case 1:
182 | return RivenType._2Pos1Neg
183 | case 3:
184 | match len(self.negatives):
185 | case 0:
186 | return RivenType._3Pos0Neg
187 | case 1:
188 | return RivenType._3Pos1Neg
189 |
190 | raise ValueError(
191 | "Riven with `{}` positives and `{}` negatives can't exist!".format(
192 | len(self.positives), len(self.negatives)
193 | )
194 | )
195 |
196 |
197 | RIVEN_EFFECT_LOOKUP: dict[RivenEffect, RivenAttribute] = {
198 | # 1) Zoom
199 | RivenEffect.ZOOM: RivenAttribute(
200 | effect=RivenEffect.ZOOM,
201 | prefix="Hera",
202 | suffix="Lis",
203 | values={
204 | WeaponModType.Rifle: 59.99,
205 | WeaponModType.Shotgun: None,
206 | WeaponModType.Pistol: 80.1,
207 | WeaponModType.Archgun: 59.99,
208 | WeaponModType.Melee: None,
209 | },
210 | ),
211 | # 2) Status Duration
212 | RivenEffect.SD: RivenAttribute(
213 | effect=RivenEffect.SD,
214 | prefix="Deci",
215 | suffix="Des",
216 | values={
217 | WeaponModType.Rifle: 99.99,
218 | WeaponModType.Shotgun: 99.0,
219 | WeaponModType.Pistol: 99.99,
220 | WeaponModType.Archgun: 99.99,
221 | WeaponModType.Melee: 99.0,
222 | },
223 | ),
224 | # 3) Status Chance
225 | RivenEffect.SC: RivenAttribute(
226 | effect=RivenEffect.SC,
227 | prefix="Hexa",
228 | suffix="Dex",
229 | values={
230 | WeaponModType.Rifle: 90.0,
231 | WeaponModType.Shotgun: 90.0,
232 | WeaponModType.Pistol: 90.0,
233 | WeaponModType.Archgun: 60.3,
234 | WeaponModType.Melee: 90.0,
235 | },
236 | ),
237 | # 4) Reload Speed
238 | RivenEffect.RLS: RivenAttribute(
239 | effect=RivenEffect.RLS,
240 | prefix="Feva",
241 | suffix="Tak",
242 | values={
243 | WeaponModType.Rifle: 50.0,
244 | WeaponModType.Shotgun: 49.45,
245 | WeaponModType.Pistol: 50.0,
246 | WeaponModType.Archgun: 99.9,
247 | WeaponModType.Melee: None,
248 | },
249 | ),
250 | # 5) Recoil
251 | RivenEffect.REC: RivenAttribute(
252 | effect=RivenEffect.REC,
253 | prefix="Zeti",
254 | suffix="Mag",
255 | values={
256 | WeaponModType.Rifle: 90.0,
257 | WeaponModType.Shotgun: 90.0,
258 | WeaponModType.Pistol: 90.0,
259 | WeaponModType.Archgun: 90.0,
260 | WeaponModType.Melee: None,
261 | },
262 | ),
263 | # 6) Range (melee only)
264 | RivenEffect.RANGE: RivenAttribute(
265 | effect=RivenEffect.RANGE,
266 | prefix="Locti",
267 | suffix="Tor",
268 | values={
269 | WeaponModType.Rifle: None,
270 | WeaponModType.Shotgun: None,
271 | WeaponModType.Pistol: None,
272 | WeaponModType.Archgun: None,
273 | WeaponModType.Melee: 1.94,
274 | },
275 | ),
276 | # 7) Punch Through
277 | RivenEffect.PT: RivenAttribute(
278 | effect=RivenEffect.PT,
279 | prefix="Lexi",
280 | suffix="Nok",
281 | values={
282 | WeaponModType.Rifle: 2.7,
283 | WeaponModType.Shotgun: 2.7,
284 | WeaponModType.Pistol: 2.7,
285 | WeaponModType.Archgun: 2.7,
286 | WeaponModType.Melee: None,
287 | },
288 | ),
289 | # 8) Projectile Speed
290 | RivenEffect.PFS: RivenAttribute(
291 | effect=RivenEffect.PFS,
292 | prefix="Conci",
293 | suffix="Nak",
294 | values={
295 | WeaponModType.Rifle: 90.0,
296 | WeaponModType.Shotgun: 89.1,
297 | WeaponModType.Pistol: 90.0,
298 | WeaponModType.Archgun: None,
299 | WeaponModType.Melee: None,
300 | },
301 | ),
302 | # 9) Multishot
303 | RivenEffect.MS: RivenAttribute(
304 | effect=RivenEffect.MS,
305 | prefix="Sati",
306 | suffix="Can",
307 | values={
308 | WeaponModType.Rifle: 90.0,
309 | WeaponModType.Shotgun: 119.7,
310 | WeaponModType.Pistol: 119.7,
311 | WeaponModType.Archgun: 60.3,
312 | WeaponModType.Melee: None,
313 | },
314 | ),
315 | # 10) Damage / Melee Damage (also referred to as Base Damage in the table)
316 | RivenEffect.DMG: RivenAttribute(
317 | effect=RivenEffect.DMG,
318 | prefix="Visi",
319 | suffix="Ata",
320 | values={
321 | WeaponModType.Rifle: 165.0,
322 | WeaponModType.Shotgun: 164.7,
323 | WeaponModType.Pistol: 219.6,
324 | WeaponModType.Archgun: 99.9,
325 | WeaponModType.Melee: 164.7,
326 | },
327 | ),
328 | # 11) Magazine Capacity
329 | RivenEffect.MAG: RivenAttribute(
330 | effect=RivenEffect.MAG,
331 | prefix="Arma",
332 | suffix="Tin",
333 | values={
334 | WeaponModType.Rifle: 50.0,
335 | WeaponModType.Shotgun: 50.0,
336 | WeaponModType.Pistol: 50.0,
337 | WeaponModType.Archgun: 60.3,
338 | WeaponModType.Melee: None,
339 | },
340 | ),
341 | # 12) Initial Combo
342 | RivenEffect.IC: RivenAttribute(
343 | effect=RivenEffect.IC,
344 | prefix="Para",
345 | suffix="Um",
346 | values={
347 | WeaponModType.Rifle: None,
348 | WeaponModType.Shotgun: None,
349 | WeaponModType.Pistol: None,
350 | WeaponModType.Archgun: None,
351 | WeaponModType.Melee: 24.5,
352 | },
353 | ),
354 | # 13) Heavy Attack Efficiency
355 | RivenEffect.EFF: RivenAttribute(
356 | effect=RivenEffect.EFF,
357 | prefix="Forti",
358 | suffix="Us",
359 | values={
360 | WeaponModType.Rifle: None,
361 | WeaponModType.Shotgun: None,
362 | WeaponModType.Pistol: None,
363 | WeaponModType.Archgun: None,
364 | WeaponModType.Melee: 73.44,
365 | },
366 | ),
367 | # 14) Fire Rate / Attack Speed
368 | RivenEffect.FR: RivenAttribute(
369 | effect=RivenEffect.FR,
370 | prefix="Croni",
371 | suffix="Dra",
372 | values={
373 | WeaponModType.Rifle: 60.03,
374 | WeaponModType.Shotgun: 89.1,
375 | WeaponModType.Pistol: 74.7,
376 | WeaponModType.Archgun: 60.03,
377 | WeaponModType.Melee: 54.9,
378 | },
379 | ),
380 | # 15) Finisher Damage
381 | RivenEffect.FIN: RivenAttribute(
382 | effect=RivenEffect.FIN,
383 | prefix="Exi",
384 | suffix="Cta",
385 | values={
386 | WeaponModType.Rifle: None,
387 | WeaponModType.Shotgun: None,
388 | WeaponModType.Pistol: None,
389 | WeaponModType.Archgun: None,
390 | WeaponModType.Melee: 119.7,
391 | },
392 | ),
393 | # 16) Toxin Damage
394 | RivenEffect.TOX: RivenAttribute(
395 | effect=RivenEffect.TOX,
396 | prefix="Toxi",
397 | suffix="Tox",
398 | values={
399 | WeaponModType.Rifle: 90.0,
400 | WeaponModType.Shotgun: 90.0,
401 | WeaponModType.Pistol: 90.0,
402 | WeaponModType.Archgun: 119.7,
403 | WeaponModType.Melee: 90.0,
404 | },
405 | ),
406 | # 17) Slash Damage
407 | RivenEffect.SLASH: RivenAttribute(
408 | effect=RivenEffect.SLASH,
409 | prefix="Sci",
410 | suffix="Sus",
411 | values={
412 | WeaponModType.Rifle: 119.97,
413 | WeaponModType.Shotgun: 119.97,
414 | WeaponModType.Pistol: 119.97,
415 | WeaponModType.Archgun: 90.0,
416 | WeaponModType.Melee: 119.7,
417 | },
418 | ),
419 | # 18) Puncture
420 | RivenEffect.PUNC: RivenAttribute(
421 | effect=RivenEffect.PUNC,
422 | prefix="Insi",
423 | suffix="Cak",
424 | values={
425 | WeaponModType.Rifle: 119.97,
426 | WeaponModType.Shotgun: 119.97,
427 | WeaponModType.Pistol: 119.97,
428 | WeaponModType.Archgun: 90.0,
429 | WeaponModType.Melee: 119.7,
430 | },
431 | ),
432 | # 19) Impact
433 | RivenEffect.IMP: RivenAttribute(
434 | effect=RivenEffect.IMP,
435 | prefix="Magna",
436 | suffix="Ton",
437 | values={
438 | WeaponModType.Rifle: 119.97,
439 | WeaponModType.Shotgun: 119.97,
440 | WeaponModType.Pistol: 119.97,
441 | WeaponModType.Archgun: 90.0,
442 | WeaponModType.Melee: 119.7,
443 | },
444 | ),
445 | # 20) Heat Damage
446 | RivenEffect.HEAT: RivenAttribute(
447 | effect=RivenEffect.HEAT,
448 | prefix="Igni",
449 | suffix="Pha",
450 | values={
451 | WeaponModType.Rifle: 90.0,
452 | WeaponModType.Shotgun: 90.0,
453 | WeaponModType.Pistol: 90.0,
454 | WeaponModType.Archgun: 119.7,
455 | WeaponModType.Melee: 90.0,
456 | },
457 | ),
458 | # 21) Electricity Damage
459 | RivenEffect.ELEC: RivenAttribute(
460 | effect=RivenEffect.ELEC,
461 | prefix="Vexi",
462 | suffix="Tio",
463 | values={
464 | WeaponModType.Rifle: 90.0,
465 | WeaponModType.Shotgun: 90.0,
466 | WeaponModType.Pistol: 90.0,
467 | WeaponModType.Archgun: 119.7,
468 | WeaponModType.Melee: 90.0,
469 | },
470 | ),
471 | # 22) Cold Damage
472 | RivenEffect.COLD: RivenAttribute(
473 | effect=RivenEffect.COLD,
474 | prefix="Geli",
475 | suffix="Do",
476 | values={
477 | WeaponModType.Rifle: 90.0,
478 | WeaponModType.Shotgun: 90.0,
479 | WeaponModType.Pistol: 90.0,
480 | WeaponModType.Archgun: 119.7,
481 | WeaponModType.Melee: 90.0,
482 | },
483 | ),
484 | # 23) Damage to Infested
485 | RivenEffect.DTI: RivenAttribute(
486 | effect=RivenEffect.DTI,
487 | prefix="Pura",
488 | suffix="Ada",
489 | values={
490 | WeaponModType.Rifle: 45.0,
491 | WeaponModType.Shotgun: 45.0,
492 | WeaponModType.Pistol: 45.0,
493 | WeaponModType.Archgun: 45.0,
494 | WeaponModType.Melee: 45.0,
495 | },
496 | ),
497 | # 24) Damage to Grineer
498 | RivenEffect.DTG: RivenAttribute(
499 | effect=RivenEffect.DTG,
500 | prefix="Argi",
501 | suffix="Con",
502 | values={
503 | WeaponModType.Rifle: 45.0,
504 | WeaponModType.Shotgun: 45.0,
505 | WeaponModType.Pistol: 45.0,
506 | WeaponModType.Archgun: 45.0,
507 | WeaponModType.Melee: 45.0,
508 | },
509 | ),
510 | # 25) Damage to Corpus
511 | RivenEffect.DTC: RivenAttribute(
512 | effect=RivenEffect.DTC,
513 | prefix="Manti",
514 | suffix="Tron",
515 | values={
516 | WeaponModType.Rifle: 45.0,
517 | WeaponModType.Shotgun: 45.0,
518 | WeaponModType.Pistol: 45.0,
519 | WeaponModType.Archgun: 45.0,
520 | WeaponModType.Melee: 45.0,
521 | },
522 | ),
523 | # 26) Critical Damage
524 | RivenEffect.CD: RivenAttribute(
525 | effect=RivenEffect.CD,
526 | prefix="Acri",
527 | suffix="Tis",
528 | values={
529 | WeaponModType.Rifle: 120.0,
530 | WeaponModType.Shotgun: 90.0,
531 | WeaponModType.Pistol: 90.0,
532 | WeaponModType.Archgun: 80.1,
533 | WeaponModType.Melee: 90.0,
534 | },
535 | ),
536 | # 27) Critical Chance on Slide Attack
537 | RivenEffect.SLIDE: RivenAttribute(
538 | effect=RivenEffect.SLIDE,
539 | prefix="Pleci",
540 | suffix="Nent",
541 | values={
542 | WeaponModType.Rifle: None,
543 | WeaponModType.Shotgun: None,
544 | WeaponModType.Pistol: None,
545 | WeaponModType.Archgun: None,
546 | WeaponModType.Melee: 120.0,
547 | },
548 | ),
549 | # 28) Critical Chance
550 | RivenEffect.CC: RivenAttribute(
551 | effect=RivenEffect.CC,
552 | prefix="Crita",
553 | suffix="Cron",
554 | values={
555 | WeaponModType.Rifle: 149.99,
556 | WeaponModType.Shotgun: 90.0,
557 | WeaponModType.Pistol: 149.99,
558 | WeaponModType.Archgun: 99.9,
559 | WeaponModType.Melee: 180.0,
560 | },
561 | ),
562 | # 29) Combo Duration
563 | RivenEffect.CDUR: RivenAttribute(
564 | effect=RivenEffect.CDUR,
565 | prefix="Tempi",
566 | suffix="Nem",
567 | values={
568 | WeaponModType.Rifle: None,
569 | WeaponModType.Shotgun: None,
570 | WeaponModType.Pistol: None,
571 | WeaponModType.Archgun: None,
572 | WeaponModType.Melee: 8.1,
573 | },
574 | ),
575 | # 30) Chance Not to Gain Combo Count
576 | RivenEffect.CNCC: RivenAttribute(
577 | effect=RivenEffect.CNCC,
578 | prefix="?",
579 | suffix="?",
580 | values={
581 | WeaponModType.Rifle: None,
582 | WeaponModType.Shotgun: None,
583 | WeaponModType.Pistol: None,
584 | WeaponModType.Archgun: None,
585 | WeaponModType.Melee: 104.85,
586 | },
587 | ),
588 | # 31) Ammo Maximum
589 | RivenEffect.AMMO: RivenAttribute(
590 | effect=RivenEffect.AMMO,
591 | prefix="Ampi",
592 | suffix="Bin",
593 | values={
594 | WeaponModType.Rifle: 49.95,
595 | WeaponModType.Shotgun: 90.0,
596 | WeaponModType.Pistol: 90.0,
597 | WeaponModType.Archgun: 99.9,
598 | WeaponModType.Melee: None,
599 | },
600 | ),
601 | # 32) Additional Combo Count Chance
602 | RivenEffect.ACCC: RivenAttribute(
603 | effect=RivenEffect.ACCC,
604 | prefix="Laci",
605 | suffix="Nus",
606 | values={
607 | WeaponModType.Rifle: None,
608 | WeaponModType.Shotgun: None,
609 | WeaponModType.Pistol: None,
610 | WeaponModType.Archgun: None,
611 | WeaponModType.Melee: 58.77,
612 | },
613 | ),
614 | }
615 |
--------------------------------------------------------------------------------
/src/jericho.py:
--------------------------------------------------------------------------------
1 | from constants import MESSAGE_PROVIDER, STATE, SETTINGS
2 |
3 | import discord
4 | import time
5 | from discord import app_commands
6 | from discord import ui
7 | from discord import ButtonStyle
8 | from discord.ui import View
9 | from discord.app_commands import Choice
10 | from discord import Interaction
11 | from discord.utils import get
12 | import random
13 | from warframe import WarframeAPI
14 | from logging import info
15 | from message_provider import MessageProvider
16 | from pet_counter import update_pet_count
17 | from sources import WeaponLookup, WarframeWiki, RivenRecommendationProvider
18 |
19 | from ui import RoleView
20 |
21 | discord.utils.setup_logging()
22 |
23 | WARFRAME_API = WarframeAPI()
24 | WEAPON_LOOKUP = WeaponLookup()
25 | WARFRAME_WIKI = WarframeWiki(weapon_lookup=WEAPON_LOOKUP)
26 | RIVEN_PROVIDER = RivenRecommendationProvider()
27 |
28 |
29 | pet_cooldowns = {}
30 | COOLDOWN_TIME = 10
31 |
32 | info(f"Starting {STATE.deathcounter} iteration of Cephalon Jericho")
33 |
34 | intents = discord.Intents.default()
35 | intents.members = True
36 | client = discord.Client(intents=intents)
37 | tree = app_commands.CommandTree(client)
38 |
39 |
40 | async def refresh():
41 | global WEAPON_LOOKUP
42 | global WARFRAME_WIKI
43 | global RIVEN_PROVIDER
44 |
45 | info("Refreshing Data...")
46 | WEAPON_LOOKUP = WeaponLookup()
47 | WARFRAME_WIKI = WarframeWiki(weapon_lookup=WEAPON_LOOKUP)
48 | await WARFRAME_WIKI.refresh()
49 | RIVEN_PROVIDER = RivenRecommendationProvider()
50 | await RIVEN_PROVIDER.refresh(WEAPON_LOOKUP, force_download=True)
51 | await WARFRAME_API.get_median_prices(WEAPON_LOOKUP)
52 | WEAPON_LOOKUP.rebuild_weapon_relations()
53 | info("Data Refreshed!")
54 |
55 |
56 | @client.event
57 | async def on_ready():
58 | await tree.sync(guild=discord.Object(id=SETTINGS.GUILD_ID))
59 | info(f"Logged in as {client.user}!")
60 | await refresh()
61 |
62 |
63 | async def weapon_autocomplete(
64 | interaction: Interaction, current: str, can_have_rivens: bool = False
65 | ):
66 | matches = WEAPON_LOOKUP.fuzzy_search(current, n=25, can_have_rivens=can_have_rivens)
67 | choices = [
68 | Choice(name=weapon.display_name, value=weapon.display_name)
69 | for weapon in matches
70 | ]
71 | return choices
72 |
73 |
74 | @tree.command(
75 | name="maintenance_sync_commands",
76 | description=MESSAGE_PROVIDER("MAINTENANCE_SYNC_DESC"),
77 | guild=discord.Object(SETTINGS.GUILD_ID),
78 | )
79 | async def sync_commands(interaction: discord.Interaction):
80 | # Acknowledge the interaction quickly with a reply
81 | if any(role.id == SETTINGS.MAINTENANCE_ROLE_ID for role in interaction.user.roles):
82 | await interaction.response.send_message(
83 | MESSAGE_PROVIDER("MAINTENANCE_SYNC_INI"), ephemeral=True
84 | )
85 |
86 | try:
87 | # Perform the sync after acknowledging the interaction
88 | await tree.sync(guild=discord.Object(id=SETTINGS.GUILD_ID))
89 | await interaction.followup.send(
90 | MESSAGE_PROVIDER("MAINTENANCE_SYNC_SUCCESS"), ephemeral=True
91 | ) # Send follow-up response after sync
92 | except Exception as e:
93 | # Handle any potential errors
94 | await interaction.followup.send(
95 | MESSAGE_PROVIDER("MAINTENANCE_SYNC_ERROR", error={str(e)}),
96 | ephemeral=True,
97 | )
98 | else:
99 | await interaction.response.send_message(
100 | MESSAGE_PROVIDER(
101 | "MAINTENANCE_SYNC_DENIED", user=interaction.user.display_name
102 | ),
103 | ephemeral=True,
104 | )
105 |
106 |
107 | @tree.command(
108 | name="hello",
109 | description=MESSAGE_PROVIDER("HELLO_DESC"),
110 | guild=discord.Object(SETTINGS.GUILD_ID),
111 | )
112 | async def hello(ctx):
113 | await ctx.response.send_message(
114 | MESSAGE_PROVIDER("HELLO", user=ctx.user.display_name)
115 | )
116 |
117 |
118 | @tree.command(
119 | name="feeling_lost",
120 | description=MESSAGE_PROVIDER("LOST_DESC"),
121 | guild=discord.Object(SETTINGS.GUILD_ID),
122 | )
123 | async def feeling_lost(ctx):
124 | await ctx.response.send_message(
125 | MESSAGE_PROVIDER("LOST", user=ctx.user.display_name)
126 | )
127 |
128 |
129 | @tree.command(
130 | name="trivia",
131 | description=MESSAGE_PROVIDER("TRIVIA_DESC"),
132 | guild=discord.Object(SETTINGS.GUILD_ID),
133 | )
134 | async def trivia(ctx):
135 | await ctx.response.send_message(
136 | MESSAGE_PROVIDER("TRIVIA", user=ctx.user.display_name)
137 | )
138 |
139 |
140 | @tree.command(
141 | name="rate_outfit",
142 | description=MESSAGE_PROVIDER("RATE_OUTFIT_DESC"),
143 | guild=discord.Object(SETTINGS.GUILD_ID),
144 | )
145 | async def rate_outfit(ctx):
146 | await ctx.response.send_message(
147 | MESSAGE_PROVIDER("RATE_OUTFIT", user=ctx.user.display_name)
148 | )
149 |
150 |
151 | @tree.command(
152 | name="pet_jericho",
153 | description=MESSAGE_PROVIDER("PET_JERICHO_DESC"),
154 | guild=discord.Object(SETTINGS.GUILD_ID),
155 | )
156 | async def pet_jericho(interaction: discord.Interaction):
157 | user_id = interaction.user.id
158 | current_time = time.time()
159 |
160 | await interaction.response.defer()
161 |
162 | # Cooldown Check
163 | if user_id in pet_cooldowns:
164 | elapsed_time = current_time - pet_cooldowns[user_id]
165 | if elapsed_time < COOLDOWN_TIME:
166 | remaining_time = int(COOLDOWN_TIME - elapsed_time)
167 | return await interaction.followup.send(
168 | content=MESSAGE_PROVIDER(
169 | "PET_JERICHO_TIMEOUT",
170 | remainingtime=remaining_time,
171 | user=interaction.user.display_name,
172 | ),
173 | ephemeral=True,
174 | )
175 |
176 | pet_cooldowns[user_id] = current_time
177 |
178 | # Update pet counters
179 | user_pets, global_pets = update_pet_count(user_id)
180 |
181 | # Check for milestone messages using MESSAGE_PROVIDER
182 | personal_message = (
183 | MESSAGE_PROVIDER(
184 | f"PET_JERICHO_PERSONAL_{user_pets}", user=interaction.user.display_name
185 | )
186 | if user_pets in SETTINGS.PERSONAL_MILESTONES
187 | else ""
188 | )
189 |
190 | global_message = (
191 | MESSAGE_PROVIDER(f"PET_JERICHO_GLOBAL_{global_pets}", global_pets=global_pets)
192 | if global_pets in SETTINGS.GLOBAL_MILESTONES
193 | else ""
194 | )
195 |
196 | # Compile messages
197 | milestone_message = "\n\n".join(filter(None, [personal_message, global_message]))
198 |
199 | # Send response with counters and milestone messages
200 | gif_path = "images/Jericho_Pet.gif"
201 | file = discord.File(gif_path, filename="Jericho_Pet.gif")
202 |
203 | await interaction.followup.send(
204 | content=MESSAGE_PROVIDER(
205 | "PET_JERICHO",
206 | user=interaction.user.display_name,
207 | user_pets=user_pets,
208 | global_pets=global_pets,
209 | )
210 | + f"\n\n{milestone_message}",
211 | file=file,
212 | )
213 |
214 |
215 | @tree.command(
216 | name="koumei",
217 | description=MESSAGE_PROVIDER("KOUMEI_DESC"),
218 | guild=discord.Object(SETTINGS.GUILD_ID),
219 | )
220 | async def koumei(ctx):
221 | random_number = random.randint(1, 6)
222 | if random_number == 6:
223 | await ctx.response.send_message(
224 | MESSAGE_PROVIDER(
225 | "KOUMEI_JACKPOT", user=ctx.user.display_name, number=random_number
226 | )
227 | )
228 | if random_number == 1:
229 | await ctx.response.send_message(
230 | MESSAGE_PROVIDER(
231 | "KOUMEI_SNAKE", user=ctx.user.display_name, number=random_number
232 | )
233 | )
234 | else:
235 | await ctx.response.send_message(
236 | MESSAGE_PROVIDER(
237 | "KOUMEI_NEUTRAL", user=ctx.user.display_name, number=random_number
238 | )
239 | )
240 |
241 |
242 | # Writing a report Modal
243 | class ReportModal(ui.Modal, title="Record and Archive Notes"):
244 | # unlike what i originally had, i need to set input windows woopsies
245 | def __init__(self):
246 | super().__init__(title="Record and Archive Notes")
247 | self.title_input = ui.TextInput(
248 | label="Title",
249 | style=discord.TextStyle.short,
250 | placeholder="Input title here",
251 | )
252 | self.message_input = ui.TextInput(
253 | label="Notes",
254 | style=discord.TextStyle.paragraph,
255 | placeholder="Input text here",
256 | )
257 | # and assign them to self, so that i can use them in the submit
258 | self.add_item(self.title_input)
259 | self.add_item(self.message_input)
260 |
261 | async def on_submit(self, interaction: discord.Interaction):
262 | channel = interaction.guild.get_channel(SETTINGS.REPORT_CHANNEL_ID)
263 | report_title = self.title_input.value
264 | report_summary = self.message_input.value
265 | info(
266 | f"User {interaction.user.name} submitted a report {report_title} containing {report_summary}"
267 | )
268 | embed = discord.Embed(
269 | title=report_title,
270 | description=report_summary,
271 | colour=discord.Colour.yellow(),
272 | )
273 | embed.set_author(
274 | name=interaction.user.display_name, icon_url=interaction.user.avatar.url
275 | )
276 | await channel.send(embed=embed)
277 |
278 | await interaction.response.send_message(
279 | MESSAGE_PROVIDER("ARCHIVE_SUCCESS"), ephemeral=True
280 | )
281 |
282 | async def on_error(self, interaction: discord.Interaction):
283 | await interaction.response.send_message(
284 | MESSAGE_PROVIDER("ARCHIVE_FAILURE"), ephemeral=True
285 | )
286 |
287 |
288 | @tree.command(
289 | name="archive",
290 | description=MESSAGE_PROVIDER("ARCHIVE_DESC"),
291 | guild=discord.Object(SETTINGS.GUILD_ID),
292 | )
293 | async def feedback_command(interaction: discord.Interaction):
294 | modal = ReportModal()
295 | await interaction.response.send_modal(modal)
296 |
297 |
298 | class AbsenceModal(ui.Modal, title="Submit and Confirm Absences"):
299 | def __init__(self):
300 | super().__init__(title="Submit and Confirm Absences")
301 | self.title_input = ui.TextInput(
302 | label="Time frame",
303 | style=discord.TextStyle.short,
304 | placeholder="Input time frame here",
305 | )
306 | self.message_input = ui.TextInput(
307 | label="Additional Notes",
308 | style=discord.TextStyle.paragraph,
309 | required=False,
310 | placeholder="Input additional notes here, they are optional",
311 | )
312 | # and assign them to self, so that i can use them in the submit
313 | self.add_item(self.title_input)
314 | self.add_item(self.message_input)
315 |
316 | async def on_submit(self, interaction: discord.Interaction):
317 | channel = interaction.guild.get_channel(SETTINGS.REPORT_CHANNEL_ID)
318 | absence_title = self.title_input.value
319 | absence_summary = self.message_input.value
320 | info(
321 | f"User {interaction.user.name} submitted a absence {absence_title} containing {absence_summary}"
322 | )
323 | embed = discord.Embed(
324 | title=absence_title,
325 | description=absence_summary,
326 | colour=discord.Colour.blurple(),
327 | )
328 | embed.set_author(
329 | name=interaction.user.display_name, icon_url=interaction.user.avatar.url
330 | )
331 | await channel.send(embed=embed)
332 |
333 | await interaction.response.send_message(
334 | MESSAGE_PROVIDER("ABSENCE_SUCCESS"), ephemeral=True
335 | )
336 |
337 | async def on_error(self, interaction: discord.Interaction):
338 | await interaction.response.send_message(
339 | MESSAGE_PROVIDER("ABSENCE_FAIL"), ephemeral=True
340 | )
341 |
342 |
343 | @tree.command(
344 | name="absence",
345 | description=MESSAGE_PROVIDER("ABSENCE_DESC"),
346 | guild=discord.Object(SETTINGS.GUILD_ID),
347 | )
348 | async def absence_command(interaction: discord.Interaction):
349 | modal = AbsenceModal()
350 | await interaction.response.send_modal(modal)
351 |
352 |
353 | @tree.command(
354 | name="role",
355 | description=MESSAGE_PROVIDER("ROLE_DESC"),
356 | guild=discord.Object(SETTINGS.GUILD_ID),
357 | )
358 | async def role(interaction: discord.Interaction):
359 | view = RoleView()
360 | await interaction.response.send_message(
361 | MESSAGE_PROVIDER("ROLE_INIT"),
362 | view=view,
363 | ephemeral=True,
364 | )
365 |
366 |
367 | class JudgeJerichoView(View):
368 | def __init__(self, *, timeout=180):
369 | super().__init__(timeout=timeout)
370 |
371 | @discord.ui.button(label="Yes", style=ButtonStyle.primary)
372 | async def affirm_jericho(
373 | self, interaction: discord.Interaction, button: discord.ui.Button
374 | ):
375 | global STATE
376 | await interaction.response.send_message(
377 | MESSAGE_PROVIDER("AFFIRM_YES", user=interaction.user.name)
378 | )
379 |
380 | @discord.ui.button(label="No", style=ButtonStyle.secondary)
381 | async def take_him_to_the_farm(
382 | self, interaction: discord.Interaction, button: discord.ui.Button
383 | ):
384 | global STATE
385 | STATE.deathcounter += 1
386 | STATE.save()
387 | await interaction.response.send_message(
388 | MESSAGE_PROVIDER("AFFIRM_NO", deathcounter=STATE.deathcounter - 1)
389 | )
390 |
391 |
392 | @tree.command(
393 | name="judge_jericho",
394 | description=MESSAGE_PROVIDER("JUDGE_JERICHO_DESC"),
395 | guild=discord.Object(SETTINGS.GUILD_ID),
396 | )
397 | async def judge_jericho(interaction: discord.Interaction):
398 | view = JudgeJerichoView()
399 | await interaction.response.send_message(MESSAGE_PROVIDER("AFFIRM"), view=view)
400 |
401 |
402 | class SmoochView(View):
403 | def __init__(self, *, timeout=180):
404 | super().__init__(timeout=timeout)
405 |
406 | @discord.ui.button(label="Yes", style=ButtonStyle.primary)
407 | async def smooch_jericho(
408 | self, interaction: discord.Interaction, button: discord.ui.Button
409 | ):
410 | global STATE
411 | await interaction.response.send_message(MESSAGE_PROVIDER("SMOOCH_YES"))
412 |
413 | @discord.ui.button(label="YES!!", style=ButtonStyle.secondary)
414 | async def smooch_jericho_harder(
415 | self, interaction: discord.Interaction, button: discord.ui.Button
416 | ):
417 | global STATE
418 | await interaction.response.send_message(MESSAGE_PROVIDER("SMOOCH_YES"))
419 |
420 |
421 | @tree.command(
422 | name="smooch",
423 | description=MESSAGE_PROVIDER("SMOOCH_DESC"),
424 | guild=discord.Object(SETTINGS.GUILD_ID),
425 | )
426 | async def smooch(interaction: discord.Interaction):
427 | view = SmoochView()
428 | await interaction.response.send_message(MESSAGE_PROVIDER("SMOOCH"), view=view)
429 |
430 |
431 | @tree.command(
432 | name="riven_weapon_stats",
433 | description=MESSAGE_PROVIDER("WEAPON_QUERY_DESC"),
434 | guild=discord.Object(SETTINGS.GUILD_ID),
435 | )
436 | async def weapon_look_up(interaction: discord.Interaction, weapon_name: str):
437 | """Look up riven stats for a given weapon."""
438 | if weapon_name not in WEAPON_LOOKUP:
439 | await interaction.response.send_message(
440 | MESSAGE_PROVIDER("WEAPON_NOT_FOUND", weaponname=weapon_name), ephemeral=True
441 | )
442 | return
443 |
444 | weapon = WEAPON_LOOKUP[weapon_name]
445 | if not weapon.riven_recommendations:
446 | await interaction.response.send_message(
447 | MESSAGE_PROVIDER("WEAPON_NO_RIVEN", weaponname=weapon.display_name),
448 | ephemeral=True,
449 | )
450 | return
451 |
452 | wiki_data = await WARFRAME_WIKI.weapon(weapon.normalized_name)
453 | if not wiki_data:
454 | await interaction.response.send_message(
455 | MESSAGE_PROVIDER("WEAPON_NO_WIKI", weaponname=weapon.display_name),
456 | ephemeral=True,
457 | )
458 | return
459 |
460 | base_weapon = weapon if weapon.is_base_weapon else WEAPON_LOOKUP[weapon.base_weapon]
461 |
462 | weapon_variants = []
463 | if base_weapon.weapon_variants:
464 | weapon_variants = [WEAPON_LOOKUP[v] for v in base_weapon.weapon_variants] + [
465 | base_weapon
466 | ]
467 |
468 | weapon_variants = sorted(
469 | weapon_variants, key=lambda w: (len(w.display_name), w.display_name)
470 | )
471 |
472 | embed = discord.Embed()
473 | embed.title = weapon.display_name
474 | embed.url = wiki_data.url
475 | description = f"**Disposition**: {wiki_data.riven_disposition.symbol} ({wiki_data.riven_disposition.disposition}x)"
476 | if base_weapon.median_plat_price:
477 | emoji = get(interaction.guild.emojis, name="plat")
478 | description += f"\n**Median Price**: {base_weapon.median_plat_price} {emoji if emoji else 'Platinum'}"
479 | embed.description = description
480 | embed.set_thumbnail(url=wiki_data.image)
481 |
482 | embed.add_field(
483 | name="Riven Stats",
484 | value="",
485 | inline=False,
486 | )
487 |
488 | for i, recommendation in enumerate(base_weapon.riven_recommendations.stats):
489 | if len(base_weapon.riven_recommendations.stats) > 1:
490 | embed.add_field(
491 | name=f"Recommendation {i + 1}",
492 | value="",
493 | inline=False,
494 | )
495 |
496 | if recommendation.best:
497 | best_stats = ", ".join(
498 | [effect.render(wiki_data.mod_type) for effect in recommendation.best]
499 | )
500 | embed.add_field(name="Best", value=best_stats, inline=True)
501 |
502 | if recommendation.wanted:
503 | desired_stats = ", ".join(
504 | [effect.render(wiki_data.mod_type) for effect in recommendation.wanted]
505 | )
506 | embed.add_field(name="Desired", value=desired_stats, inline=True)
507 |
508 | if recommendation.wanted_negatives:
509 | negative_stats = ", ".join(
510 | [
511 | effect.render(wiki_data.mod_type)
512 | for effect in recommendation.wanted_negatives
513 | ]
514 | )
515 | embed.add_field(
516 | name="Harmless Negatives", value=negative_stats, inline=True
517 | )
518 |
519 | if len(weapon_variants) > 1:
520 | weapon_variants_text = ""
521 | for w in weapon_variants:
522 | weapon_variants_text += (
523 | f"- [{w.display_name}]({WARFRAME_WIKI.base_url + w.wiki_url})\n"
524 | )
525 |
526 | embed.add_field(
527 | name=f"Weapon Family: {base_weapon.display_name}",
528 | value=weapon_variants_text,
529 | inline=False,
530 | )
531 |
532 | embed.add_field(
533 | name="",
534 | value=f"[See on Warframe Market]({base_weapon.get_market_auction_url()})",
535 | inline=False,
536 | )
537 |
538 | await interaction.response.send_message(embed=embed)
539 |
540 |
541 | @weapon_look_up.autocomplete("weapon_name")
542 | async def autocomplete_weapon_name(interaction: Interaction, current: str):
543 | return await weapon_autocomplete(interaction, current, can_have_rivens=True)
544 |
545 |
546 | # @tree.command(
547 | # name="riven_grade",
548 | # description=MESSAGE_PROVIDER("RIVEN_GRADE_DESC"),
549 | # guild=discord.Object(SETTINGS.GUILD_ID),
550 | # )
551 | # async def riven_grade(interaction: discord.Interaction, weapon: str, *, stats: str):
552 | # # Convert the string of stats into a list
553 | # stats_list = stats.split()
554 |
555 | # weapon = weapon.strip().lower()
556 |
557 | # # Search for the weapon in the RIVEN_PROVIDER data
558 | # weapon_stats = None
559 | # for row in RIVEN_PROVIDER.normalized_data:
560 | # if row["WEAPON"].strip().lower() == weapon:
561 | # weapon_stats = row
562 | # break
563 |
564 | # # Validate if the weapon exists
565 | # if not weapon_stats:
566 | # await interaction.response.send_message(
567 | # MESSAGE_PROVIDER("INVALID_RIVEN", weaponname=weapon, stats=stats, user=interaction.user.display_name),
568 | # )
569 | # return
570 |
571 | # # Get weapon stats
572 | # weapon_stats = RIVEN_PROVIDER.get_weapon_stats(weapon)
573 | # best_stats = weapon_stats["BEST STATS"]
574 | # desired_stats = weapon_stats["DESIRED STATS"]
575 | # harmless_negatives = weapon_stats["NEGATIVE STATS"]
576 |
577 | # # Validate input
578 | # if not stats_list:
579 | # await interaction.response.send_message(
580 | # MESSAGE_PROVIDER("INVALID_RIVEN", weapon=weapon, stats=stats),
581 | # ephemeral=True,
582 | # )
583 | # return
584 |
585 | # # Call the grade_riven function with the stats list
586 | # riven_grade = RIVEN_GRADER.grade_riven(
587 | # stats_list, best_stats, desired_stats, harmless_negatives
588 | # )
589 |
590 | # # Determine the response based on the grade
591 | # if riven_grade == 5:
592 | # response = MESSAGE_PROVIDER(
593 | # "PERFECT_RIVEN",
594 | # user=interaction.user.display_name,
595 | # stats=stats,
596 | # weapon=weapon,
597 | # )
598 | # elif riven_grade == 4:
599 | # response = MESSAGE_PROVIDER(
600 | # "PRESTIGIOUS_RIVEN",
601 | # user=interaction.user.display_name,
602 | # stats=stats,
603 | # weapon=weapon,
604 | # )
605 | # elif riven_grade == 3:
606 | # response = MESSAGE_PROVIDER(
607 | # "DECENT_RIVEN",
608 | # user=interaction.user.display_name,
609 | # stats=stats,
610 | # weapon=weapon,
611 | # )
612 | # elif riven_grade == 2:
613 | # response = MESSAGE_PROVIDER(
614 | # "NEUTRAL_RIVEN",
615 | # user=interaction.user.display_name,
616 | # stats=stats,
617 | # weapon=weapon,
618 | # )
619 | # elif riven_grade == 1:
620 | # response = MESSAGE_PROVIDER(
621 | # "UNUSABLE_RIVEN",
622 | # user=interaction.user.display_name,
623 | # stats=stats,
624 | # weapon=weapon,
625 | # )
626 | # else:
627 | # response = MESSAGE_PROVIDER(
628 | # "INVALID_RIVEN_GRADE",
629 | # user=interaction.user.display_name,
630 | # stats=stats,
631 | # weapon=weapon,
632 | # )
633 |
634 | # # Send the final response
635 | # await interaction.response.send_message(response)
636 |
637 |
638 | # @riven_grade.autocomplete("weapon")
639 | # async def autocomplete_weapon_name_for_riven_grade(
640 | # interaction: Interaction, current: str
641 | # ):
642 | # return await weapon_autocomplete(interaction, current)
643 |
644 |
645 | class RivenHelpView(View):
646 | def __init__(self, *, timeout=180):
647 | super().__init__(timeout=timeout)
648 |
649 | @discord.ui.button(label="Stats", style=ButtonStyle.primary)
650 | async def riven_help_stats(
651 | self, interaction: discord.Interaction, button: discord.ui.Button
652 | ):
653 | await interaction.response.send_message(
654 | MESSAGE_PROVIDER("RIVEN_HELP_STATS"), ephemeral=True
655 | )
656 |
657 | @discord.ui.button(label="Weapons", style=ButtonStyle.secondary)
658 | async def riven_help_weapons(
659 | self, interaction: discord.Interaction, button: discord.ui.Button
660 | ):
661 | await interaction.response.send_message(
662 | MESSAGE_PROVIDER("RIVEN_HELP_WEAPONS"), ephemeral=True
663 | )
664 |
665 |
666 | @tree.command(
667 | name="riven_help",
668 | description=MESSAGE_PROVIDER("RIVEN_HELP_DESC"),
669 | guild=discord.Object(SETTINGS.GUILD_ID),
670 | )
671 | async def riven_help(interaction: discord.Interaction):
672 | view = RivenHelpView()
673 | await interaction.response.send_message(
674 | MESSAGE_PROVIDER("RIVEN_HELP_INITIAL"), view=view, ephemeral=True
675 | )
676 |
677 |
678 | @tree.command(
679 | name="maintenance_text",
680 | description=MESSAGE_PROVIDER("MAINTENANCE_TEXT_DESC"),
681 | guild=discord.Object(SETTINGS.GUILD_ID),
682 | )
683 | async def text_maintenance(interaction: discord.Interaction):
684 | if any(role.id == SETTINGS.MAINTENANCE_ROLE_ID for role in interaction.user.roles):
685 | try:
686 | global MESSAGE_PROVIDER
687 | MESSAGE_PROVIDER = MessageProvider.from_gsheets(
688 | SETTINGS.MESSAGE_PROVIDER_URL
689 | )
690 | info(f"User {interaction.user.name} attempted to refresh google sheet data")
691 | await interaction.response.send_message(
692 | MESSAGE_PROVIDER("MAINTENANCE_INI"), ephemeral=True
693 | )
694 | await interaction.followup.send(
695 | MESSAGE_PROVIDER("MAINTENANCE_SUCCESS"), ephemeral=True
696 | )
697 | except Exception as e:
698 | info(f"Refresh failed with error: {e}")
699 | await interaction.followup.send(
700 | MESSAGE_PROVIDER("MAINTENANCE_ERROR", error=e), ephemeral=True
701 | )
702 | else:
703 | await interaction.response.send_message(
704 | MESSAGE_PROVIDER(
705 | "MAINTENANCE_DENIED",
706 | user=interaction.user.display_name,
707 | ),
708 | ephemeral=True,
709 | )
710 |
711 |
712 | @tree.command(
713 | name="maintenance_riven",
714 | description=MESSAGE_PROVIDER("MAINTENANCE_RIVEN_DESC"),
715 | guild=discord.Object(SETTINGS.GUILD_ID),
716 | )
717 | async def riven_maintenance(interaction: discord.Interaction):
718 | if any(role.id == SETTINGS.MAINTENANCE_ROLE_ID for role in interaction.user.roles):
719 | try:
720 | await interaction.response.defer(ephemeral=True)
721 |
722 | maintenance_message = await interaction.followup.send(
723 | MESSAGE_PROVIDER("MAINTENANCE_RIVEN_INI"), ephemeral=True
724 | )
725 | info(f"Started riven update for user {interaction.user.name}")
726 |
727 | if maintenance_message:
728 | info("Maintenance message sent successfully.")
729 | else:
730 | info("Failed to send maintenance message.")
731 | return
732 |
733 | # Perform the update
734 | await refresh()
735 |
736 | info("Riven update completed successfully.")
737 | await maintenance_message.edit(
738 | content=MESSAGE_PROVIDER("MAINTENANCE_RIVEN_SUCCESS")
739 | )
740 |
741 | except discord.errors.NotFound as e:
742 | info(f"Failed to send the maintenance message: {e}")
743 | await interaction.followup.send(
744 | "An error occurred while trying to send the maintenance message.",
745 | ephemeral=True,
746 | )
747 |
748 | except Exception as e:
749 | info(f"Refresh failed with error: {e}")
750 | if maintenance_message:
751 | info("Editing the maintenance message to indicate failure.")
752 | await maintenance_message.edit(
753 | content=MESSAGE_PROVIDER("MAINTENANCE_RIVEN_ERROR", error=e)
754 | )
755 | else:
756 | info("Failed to retrieve the maintenance message for error handling.")
757 | else:
758 | await interaction.response.send_message(
759 | MESSAGE_PROVIDER(
760 | "MAINTENANCE_RIVEN_DENIED", user=interaction.user.display_name
761 | ),
762 | ephemeral=True,
763 | )
764 |
765 |
766 | client.run(SETTINGS.DISCORD_TOKEN)
767 |
--------------------------------------------------------------------------------