Skip to content

Commit 7356328

Browse files
committed
✨More restructure
1 parent 3a4b828 commit 7356328

File tree

5 files changed

+160
-159
lines changed

5 files changed

+160
-159
lines changed

src/modules/public/dota_rp_flow/component.py

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@
1717

1818
from config import config
1919
from core import IrePublicComponent, ireloop
20-
from utils import const, errors, fmt, guards
21-
from utils.dota import constants as dota_constants, utils as dota_utils
20+
from utils import errors, fmt, guards
2221

2322
from .enums import LobbyParam0, ScoreCategory, Status
24-
from .tools import extract_hero_index
23+
from .tools import SteamUserConverter, extract_hero_index, is_allowed_to_add_notable, rank_medal_display_name
2524

2625
if TYPE_CHECKING:
2726
from collections.abc import Callable, Coroutine
@@ -71,7 +70,10 @@ class Score:
7170

7271
@dataclass(slots=True)
7372
class Activity:
74-
"""Activity.
73+
"""A base class for Activities.
74+
75+
Activity is a somehow umbrella-term. The purpose of this term is to group Steam Rich Presence statuses
76+
into certain categories that make sense from bot's view.
7577
7678
Dev Note
7779
--------
@@ -85,7 +87,11 @@ def __str__(self) -> str:
8587

8688

8789
@dataclass(slots=True)
88-
class SomethingIsOff(Activity): ...
90+
class NotInDota(Activity): ...
91+
92+
93+
@dataclass(slots=True)
94+
class Transition(Activity): ...
8995

9096

9197
@dataclass(slots=True)
@@ -159,7 +165,7 @@ def __init__(self, bot: IreBot, steam_user: Dota2SteamUser) -> None:
159165
self.steam_user: Dota2SteamUser = steam_user
160166
self.rich_presence: RichPresence = RichPresence(steam_user.rich_presence)
161167
self.active_match: PlayMatch | WatchMatch | UnsupportedMatch | None = None
162-
self.activity: Activity = SomethingIsOff()
168+
self.activity: Activity = Transition()
163169

164170
@override
165171
def __repr__(self) -> str:
@@ -204,12 +210,16 @@ async def create(cls, bot: IreBot, account_id: int, player_slot: int) -> Player:
204210
friend_id=account_id,
205211
player_slot=player_slot,
206212
lifetime_games=profile_card.lifetime_games,
207-
medal=dota_utils.rank_medal_display_name(profile_card),
213+
medal=rank_medal_display_name(profile_card),
208214
)
209215

210216
@property
211217
def color(self) -> str:
212-
return dota_constants.PLAYER_COLORS[self.player_slot]
218+
colors = ["Blue", "Teal", "Purple", "Yellow", "Orange", "Pink", "Olive", "LightBlue", "DarkGreen", "Brown"]
219+
try:
220+
return colors[self.player_slot]
221+
except IndexError:
222+
return "Colorless"
213223

214224

215225
def format_match_response(func: Callable[..., Coroutine[Any, Any, str]]) -> Callable[..., Coroutine[Any, Any, str]]:
@@ -479,7 +489,13 @@ class WatchMatch(Match):
479489
def __init__(self, bot: IreBot, watching_server: str) -> None:
480490
super().__init__(bot, tag="Spectating")
481491
self.watching_server: str = watching_server
482-
self.server_steam_id: int = dota_utils.convert_id3_to_id64(watching_server)
492+
# Steam Web API uses Steam IDs for servers in id64 format, but Rich Presence provides them in id3.
493+
# Example: id3: [A:1:3513917470:30261] -> id64: 90201966066671646.
494+
steam_id = steam.ID.from_id3(watching_server)
495+
if steam_id is None:
496+
msg = "Failed to get steam ID from id3."
497+
raise errors.PlaceholderError(msg)
498+
self.server_steam_id: int = steam_id.id64
483499

484500
self.update_data.start()
485501

@@ -528,41 +544,6 @@ def __init__(self, bot: IreBot, tag: str = "") -> None:
528544
super().__init__(bot, tag)
529545

530546

531-
class SteamUserNotFound(commands.BadArgument):
532-
"""For when a matching user cannot be found."""
533-
534-
def __init__(self, argument: str) -> None:
535-
self.argument = argument
536-
super().__init__(f"User {argument!r} not found.", value=argument)
537-
538-
539-
class SteamUserConverter(commands.Converter[Dota2User]):
540-
"""Simple Steam User converter."""
541-
542-
@override
543-
async def convert(self, ctx: IreContext, argument: str) -> Dota2User:
544-
try:
545-
return await ctx.bot.dota.fetch_user(steam.utils.parse_id64(argument))
546-
except steam.InvalidID:
547-
id64 = await steam.utils.id64_from_url(argument)
548-
if id64 is None:
549-
raise SteamUserNotFound(argument) from None
550-
return await ctx.bot.dota.fetch_user(id64)
551-
except TimeoutError:
552-
raise SteamUserNotFound(argument) from None
553-
554-
555-
def is_allowed_to_add_notable() -> Any:
556-
"""Allow !np add to only be invoked by certain people."""
557-
558-
def predicate(ctx: IreContext) -> bool:
559-
# Maybe we will edit this to be some proper dynamic database thing;
560-
allowed_ids = (const.UserID.Irene, const.UserID.Aluerie, const.UserID.Xas)
561-
return ctx.chatter.id in allowed_ids
562-
563-
return commands.guard(predicate)
564-
565-
566547
class Dota2RichPresenceFlow(IrePublicComponent):
567548
"""Component with all 9kmmrbot-like Dota 2 features.
568549
@@ -643,6 +624,7 @@ async def get_activity(self, friend: Friend) -> Activity:
643624
Status.Playing,
644625
Status.CustomGameProgress,
645626
Status.CustomGameProgress,
627+
Status.Coaching,
646628
}:
647629
if (watchable_game_id := rp.raw.get("WatchableGameID")) is None:
648630
# something is off
@@ -651,13 +633,13 @@ async def get_activity(self, friend: Friend) -> Activity:
651633
LobbyParam0.DemoMode: DemoMode(),
652634
LobbyParam0.BotMatch: BotMatch(),
653635
}
654-
return lobby_map.get(lobby_param0, SomethingIsOff())
636+
return lobby_map.get(lobby_param0, Transition())
655637

656638
if watchable_game_id == "0":
657639
# something is off again
658640
# usually this happens when a player has just quit the match into the main menu
659641
# the status flickers for a few seconds to be `watchable_game_id=0`
660-
return SomethingIsOff()
642+
return Transition()
661643
return Playing(watchable_game_id)
662644

663645
# Watching
@@ -680,7 +662,7 @@ async def get_activity(self, friend: Friend) -> Activity:
680662

681663
if rp.status == Status.NoStatus:
682664
# usually this happens in exact moment when the player closes Dota
683-
return SomethingIsOff()
665+
return Transition()
684666

685667
# Unrecognized
686668
text = (
@@ -693,7 +675,7 @@ async def get_activity(self, friend: Friend) -> Activity:
693675
log.warning(text)
694676
await self.bot.error_webhook.send(content=self.bot.error_ping + "\n" + text)
695677

696-
return SomethingIsOff()
678+
return Transition()
697679

698680
async def analyze_rich_presence(self, friend: Friend) -> None:
699681
"""Analyze Rich Presence.
@@ -718,6 +700,7 @@ async def analyze_rich_presence(self, friend: Friend) -> None:
718700
await self.bot.pool.execute(query, datetime.datetime.now(datetime.UTC), friend.steam_user.id)
719701
else:
720702
# not interested if not playing Dota 2
703+
friend.activity = NotInDota()
721704
await self.conclude_friend_match(friend)
722705
return
723706

@@ -758,7 +741,7 @@ async def analyze_rich_presence(self, friend: Friend) -> None:
758741
self.bot,
759742
tag="Custom Games Lobbies (in draft stage) are not supported - wait for the game to start.",
760743
)
761-
case SomethingIsOff():
744+
case Transition():
762745
# Wait for confirmed statuses
763746
return
764747
case _:
@@ -1320,7 +1303,7 @@ async def mmr(self, ctx: IreContext) -> None:
13201303
mmr: int = await self.bot.pool.fetchval(query, friend.steam_user.id)
13211304

13221305
profile_card = await friend.steam_user.dota2_profile_card()
1323-
response = f"Medal: {dota_utils.rank_medal_display_name(profile_card)} \N{BULLET} Database tracked MMR: {mmr}"
1306+
response = f"Medal: {rank_medal_display_name(profile_card)} \N{BULLET} Database tracked MMR: {mmr}"
13241307
await ctx.send(response)
13251308

13261309
@commands.is_broadcaster()

src/modules/public/dota_rp_flow/tools.py

Lines changed: 127 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,53 @@
11
from __future__ import annotations
22

3-
from steam.ext.dota2 import Hero
3+
from typing import TYPE_CHECKING, Any, override
44

5-
from utils import errors, fuzzy
5+
import steam
6+
from steam.ext.dota2 import Hero, User as Dota2User
7+
from twitchio.ext import commands
8+
9+
try:
10+
from utils import const, errors, fuzzy
11+
except ModuleNotFoundError:
12+
import sys
13+
14+
# Just for lazy testing (in the end of this file);
15+
sys.path.append("D:/CODE/IreBot/src")
16+
17+
from utils import const, errors, fuzzy
18+
19+
if TYPE_CHECKING:
20+
from steam.ext.dota2 import ProfileCard
21+
22+
from core import IreContext
23+
24+
25+
__all__ = (
26+
"SteamUserConverter",
27+
"SteamUserNotFound",
28+
"extract_hero_index",
29+
"rank_medal_display_name",
30+
)
631

7-
__all__ = ("extract_hero_index",)
832

933
# /* cSpell:disable */
1034
HERO_ALIASES = {
35+
# HERO ALIASES.
36+
#
37+
# The list mainly for !profile/!items command so people can just write "!items CM"
38+
# and the bot will send Crystal Maiden's items.
39+
#
40+
# The list includes mostly
41+
# * abbreviations, i.e. "cm";
42+
# * persona names i.e. "wei";
43+
# * dota 1 names , i.e. "traxex"
44+
# * official names, i.e. "beastmaster";
45+
# * short forms of any from above, i.e. "cent";
46+
# * just nicknames, aliases or common names that people sometimes *actually* use for Dota 2 heroes ;
47+
#
48+
# It doesn't include aliases that people don't use
49+
# i.e. nobody calls techies as "Squee, Spleen & Spoon"
50+
#
1151
Hero.Abaddon: ["abaddon", "aba"],
1252
Hero.Alchemist: ["alch", "alchemist"],
1353
Hero.AncientApparition: ["aa", "apparition", "ancient apparition"],
@@ -153,6 +193,51 @@
153193
}
154194

155195

196+
class SteamUserNotFound(commands.BadArgument):
197+
"""For when a matching user cannot be found."""
198+
199+
def __init__(self, argument: str) -> None:
200+
self.argument = argument
201+
super().__init__(f"User {argument!r} not found.", value=argument)
202+
203+
204+
class SteamUserConverter(commands.Converter[Dota2User]):
205+
"""Simple Steam User converter."""
206+
207+
@override
208+
async def convert(self, ctx: IreContext, argument: str) -> Dota2User:
209+
try:
210+
return await ctx.bot.dota.fetch_user(steam.utils.parse_id64(argument))
211+
except steam.InvalidID:
212+
id64 = await steam.utils.id64_from_url(argument)
213+
if id64 is None:
214+
raise SteamUserNotFound(argument) from None
215+
return await ctx.bot.dota.fetch_user(id64)
216+
except TimeoutError:
217+
raise SteamUserNotFound(argument) from None
218+
219+
220+
def rank_medal_display_name(profile_card: ProfileCard) -> str:
221+
"""Get human-readable rank medal string out of player's Dota 2 Profile Card."""
222+
display_name = profile_card.rank_tier.division
223+
if stars := profile_card.rank_tier.stars:
224+
display_name += f" \N{BLACK STAR}{stars}"
225+
if number_rank := profile_card.leaderboard_rank:
226+
display_name += f" #{number_rank}"
227+
return display_name
228+
229+
230+
def is_allowed_to_add_notable() -> Any:
231+
"""Allow !npm add/remove/rename to only be invoked by certain people."""
232+
233+
def predicate(ctx: IreContext) -> bool:
234+
# Maybe we will edit this to be some proper dynamic database thing;
235+
allowed_ids = (const.UserID.Irene, const.UserID.Aluerie, const.UserID.Xas)
236+
return ctx.chatter.id in allowed_ids
237+
238+
return commands.guard(predicate)
239+
240+
156241
def extract_hero_index(argument: str, heroes: list[Hero]) -> tuple[Hero, int]:
157242
"""Convert command argument provided by user (twitch chatter) into a player_slot in the match.
158243
@@ -169,49 +254,53 @@ def extract_hero_index(argument: str, heroes: list[Hero]) -> tuple[Hero, int]:
169254
Matched hero as well as its index in the provided `heroes` list.
170255
This is because usually when this function is called, the `player slot` is also of a big interest.
171256
"""
172-
if argument.isdigit():
257+
if argument.isnumeric():
173258
# then the user typed only a number and our life is easy because it is a player slot
174259
# let's consider users normal: they start enumerating slots from 1 instead of 0.
175-
player_slot = int(argument) - 1
176-
if not 0 <= player_slot <= 9:
177-
msg = "Sorry, player_slot can only be of 1-10 values."
260+
index = int(argument) - 1
261+
if index < 0:
262+
msg = f'Detected numeric input "{argument}" but player slot cannot be a negative number.'
178263
raise errors.RespondWithError(msg)
179-
return heroes[player_slot], player_slot
264+
265+
try:
266+
return heroes[index], index
267+
except IndexError:
268+
msg = f"Detected numeric input for player slot #{argument} but there are {len(heroes)} players in this match."
269+
raise errors.RespondWithError(msg) from None
180270

181271
# Otherwise - we have to use the fuzzy search
272+
result: tuple[Hero | None, int] = (None, 0)
182273

183-
# Step 1. Colors;
184-
player_slot_choice = (None, 0)
274+
# Step 1. Color aliases;
185275
for player_slot, color_aliases in COLOR_ALIASES.items():
186276
find = fuzzy.extract_one(argument, color_aliases, scorer=fuzzy.quick_token_sort_ratio, score_cutoff=49)
187-
if find and find[1] > player_slot_choice[1]:
188-
player_slot_choice = (player_slot, find[1])
277+
if find and find[1] > result[1]:
278+
try:
279+
result = (heroes[player_slot], find[1])
280+
except ValueError:
281+
continue
189282

190-
# Step 2. let's see if hero aliases can beat official
191-
hero_slot_choice = (None, 0)
192-
# Sort the hero list so heroes in the match come first (i.e. so "es" alias triggers on a hero in the match)
283+
# Step 2. Hero aliases
284+
# Sort the hero list so heroes in the match come first (i.e. so "es" alias triggers on a hero in the match first)
193285
for hero, hero_aliases in sorted(HERO_ALIASES.items(), key=lambda x: x[0] in heroes, reverse=True):
194286
find = fuzzy.extract_one(argument, hero_aliases, scorer=fuzzy.quick_token_sort_ratio, score_cutoff=49)
195-
if find and find[1] > hero_slot_choice[1]:
196-
hero_slot_choice = (hero, find[1])
197-
198-
error_message = 'Sorry, didn\'t understand your query. Try something like "PA / 7 / Phantom Assassin / Blue".'
199-
if player_slot_choice[1] > hero_slot_choice[1]:
200-
# then color matched better
201-
player_slot = player_slot_choice[0]
202-
if player_slot is None:
203-
raise errors.RespondWithError(error_message)
204-
return heroes[player_slot], player_slot
205-
206-
# Else: hero aliases matched better;
207-
hero = hero_slot_choice[0]
208-
if hero is None:
209-
raise errors.RespondWithError(error_message)
210-
211-
try:
212-
player_slot = heroes.index(hero)
213-
except ValueError:
214-
msg = f"Hero {hero} is not present in the match."
215-
raise errors.RespondWithError(msg) from None
216-
217-
return hero, player_slot
287+
if find and find[1] > result[1]:
288+
result = (hero, find[1])
289+
290+
if result[0] is None:
291+
msg = 'Sorry, didn\'t understand your query. Try something like "PA / 7 / Phantom Assassin / Blue".'
292+
raise errors.RespondWithError(msg)
293+
if result[0] not in heroes:
294+
msg = f"Hero {result[0]} is not present in the match."
295+
raise errors.RespondWithError(msg)
296+
297+
return result
298+
299+
300+
if __name__ == "__main__":
301+
# A little test.
302+
argument = "PA"
303+
self_heroes = [Hero.PhantomAssassin, Hero.Kez, Hero.KeeperOfTheLight, Hero.Io]
304+
305+
res = extract_hero_index(argument, self_heroes)
306+
print(res) # noqa: T201

src/utils/dota/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
from .dota2client import *
2-
from .utils import *

0 commit comments

Comments
 (0)