├── 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 | Cephalon Jericho Logo 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 | --------------------------------------------------------------------------------