├── .gitignore ├── requirements.txt ├── Dockerfile ├── .env.example ├── tox.ini ├── docker-compose.yml ├── osubot ├── __init__.py ├── pp.py ├── diff.py ├── beatmap_search.py ├── server.py ├── scrape.py ├── consts.py ├── utils.py ├── context.py └── markdown.py ├── LICENSE ├── README.md ├── bin ├── monitor.py └── video_links.py └── test └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | *.egg-info 4 | *.log 5 | .coverage 6 | .env 7 | pkg.zip 8 | /build/ 9 | /.tox/ 10 | /venv/ 11 | /bin/oppai-ng/ 12 | .history -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.3 2 | boto3==1.34.139 3 | markdown-strings==3.4.0 4 | osuapi==0.0.43 5 | praw==7.7.1 6 | pylev==1.4.0 7 | requests_cache==1.2.1 8 | rosu-pp-py==1.0.1 9 | python-dotenv==1.0.1 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | ENV PYTHONPATH /root 3 | ENV FLASK_APP osubot.server 4 | ENV FLASK_RUN_HOST 0.0.0.0 5 | ENV FLASK_RUN_PORT 5000 6 | COPY requirements.txt /tmp/requirements.txt 7 | RUN pip install -r /tmp/requirements.txt 8 | COPY bin/ /root/bin 9 | COPY osubot /root/osubot 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DEBUG_LOGS=False 2 | OSU_API_KEY= 3 | REDDIT_CLIENT_ID= 4 | REDDIT_CLIENT_SECRET= 5 | REDDIT_PASSWORD= 6 | REDDIT_USER= 7 | TILLERINO_API_KEY= 8 | YOUTUBE_KEY= 9 | OSU_BOT_SUB= 10 | OSU_BOT_USER= 11 | FLASK_RUN_PORT=5000 12 | REDDIT_FLAIR_ID= 13 | REDDIT_FLAIR_NAME= 14 | API_HOST=http://localhost -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = py36, lint 4 | 5 | [flake8] 6 | exclude = .git,.tox,__pycache__,build,bin/oppai-ng 7 | 8 | [testenv:py36] 9 | passenv = *_KEY REDDIT_* NOSE_* USE_* 10 | deps = nose 11 | coverage 12 | -Ur{toxinidir}/requirements.txt 13 | commands = nosetests --with-coverage --cover-package=osubot 14 | 15 | [testenv:lint] 16 | deps = flake8 17 | commands = flake8 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | monitor: 4 | build: . 5 | restart: unless-stopped 6 | env_file: .env 7 | command: python /root/bin/monitor.py --auto 8 | videos: 9 | build: . 10 | restart: unless-stopped 11 | env_file: .env 12 | command: python /root/bin/video_links.py 13 | server: 14 | build: . 15 | restart: unless-stopped 16 | env_file: .env 17 | command: flask run 18 | -------------------------------------------------------------------------------- /osubot/__init__.py: -------------------------------------------------------------------------------- 1 | from . import consts, context, markdown 2 | 3 | 4 | def scorepost(title): 5 | """Generate a reply to a score post from a title.""" 6 | print("Post title: %s" % title) 7 | 8 | if not consts.title_re.match(title): 9 | print("Not a score post") 10 | return None 11 | 12 | ctx = context.from_score_post(title) 13 | 14 | if not ctx.player and not ctx.beatmap: 15 | print("Both player and beatmap are missing") 16 | return None 17 | 18 | return ctx, markdown.build_comment(ctx) 19 | -------------------------------------------------------------------------------- /osubot/pp.py: -------------------------------------------------------------------------------- 1 | import rosu_pp_py as rosu 2 | 3 | from . import consts, scrape 4 | 5 | 6 | def pp_val(ctx, acc, modded=True): 7 | """Get pp earned for a play with given acc.""" 8 | if ctx.mode is None: 9 | return None 10 | path = scrape.download_beatmap(ctx) 11 | if path is None: 12 | return None 13 | bm = rosu.Beatmap(path=path) 14 | bm.convert(consts.int2rosumode[ctx.mode]) 15 | mods = ctx.mods if modded else consts.nomod 16 | perf = rosu.Performance(mods=mods, accuracy=acc) 17 | return perf.calculate(bm).pp 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2021 Chris de Graaf 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osu!bot 2 | 3 | **[osu!bot](https://reddit.com/u/osu-bot) is a Reddit bot that posts beatmap and player information to [/r/osugame](https://reddit.com/r/osugame) score posts.** 4 | 5 | This is its third iteration, which replaces the original spaghetti-tier Ruby implementation and the "Wow I love multiple dispatch so let's write a combinatorial explosion of methods with excessively fine-grained signatures" Julia implementation. They can be found in separate branches as historical artifacts. 6 | 7 | Also, the code is absolutely awful for all iterations. 8 | 9 | ### Formatting Score Posts 10 | 11 | The bot depends on you to properly format your title! The beginning of your post title should look something like this: 12 | 13 | ``` 14 | Player Name | Song Artist - Song Title [Diff Name] +Mods 15 | ``` 16 | 17 | For example: 18 | 19 | ``` 20 | Cookiezi | xi - FREEDOM DiVE [FOUR DIMENSIONS] +HDHR 99.83% FC 800pp *NEW PP RECORD* 21 | ``` 22 | 23 | In general, anything following the [official criteria](https://reddit.com/r/osugame/wiki/scoreposting) should work. 24 | 25 | There's one notable exception which doesn't work, which is mods separated by spaces: "HD HR" and "HD, HR" both get parsed as HD only. 26 | Additionally, prefixing the mods with "+" makes parsing much more consistent, for example "+HDHR". 27 | 28 | ### Contact 29 | 30 | Send any problems, questions, or suggestions to the Reddit users mentioned as developers in the bot's comments. 31 | 32 | ### Acknowledgements 33 | 34 | Thanks to [Franc[e]sco](https://github.com/Francesco149) and [khazhyk](https://github.com/khazhyk) for [oppai](https://github.com/Francesco149/oppai-ng) and [osuapi](https://github.com/khazhyk/osuapi) respectively, both of which have saved me much time and effort. 35 | -------------------------------------------------------------------------------- /osubot/diff.py: -------------------------------------------------------------------------------- 1 | import rosu_pp_py as rosu 2 | 3 | from . import consts, scrape 4 | from .utils import changes_diff, is_ignored 5 | 6 | 7 | def diff_vals(ctx, modded=True): 8 | """Get the difficulty values of a map.""" 9 | if not ctx.beatmap: 10 | return None 11 | if not modded: 12 | return diff_nomod(ctx) 13 | if modded and is_ignored(ctx.mods): 14 | return None 15 | return diff_modded(ctx) 16 | 17 | 18 | def diff_nomod(ctx): 19 | """Get the unmodded difficulty values of a map.""" 20 | return { 21 | "cs": ctx.beatmap.diff_size, 22 | "ar": ctx.beatmap.diff_approach, 23 | "od": ctx.beatmap.diff_overall, 24 | "hp": ctx.beatmap.diff_drain, 25 | "sr": ctx.beatmap.difficultyrating, 26 | "bpm": ctx.beatmap.bpm, 27 | "length": ctx.beatmap.total_length, 28 | } 29 | 30 | 31 | def diff_modded(ctx): 32 | """Get the modded difficulty values of a map.""" 33 | if is_ignored(ctx.mods): 34 | return None 35 | path = scrape.download_beatmap(ctx) 36 | if path is None: 37 | return None 38 | bm = rosu.Beatmap(path=path) 39 | diff = rosu.Difficulty(mods=ctx.mods) 40 | result = diff.calculate(bm) 41 | if ctx.mods & consts.mods2int["DT"]: # This catches NC too. 42 | scalar = 1.5 43 | elif ctx.mods & consts.mods2int["HT"]: 44 | scalar = 0.75 45 | else: 46 | scalar = 1 47 | bpm = ctx.beatmap.bpm * scalar 48 | length = round(ctx.beatmap.total_length / scalar) 49 | if changes_diff(ctx.mods): 50 | stars = result.stars 51 | else: 52 | stars = ctx.beatmap.difficultyrating 53 | # https://redd.it/6phntt 54 | cs = ctx.beatmap.diff_size 55 | if ctx.mods & consts.mods2int["HR"]: 56 | cs *= 1.3 57 | elif ctx.mods & consts.mods2int["EZ"]: 58 | cs /= 2 59 | return { 60 | "cs": cs, 61 | "ar": result.ar, 62 | "od": result.od, 63 | "hp": result.hp, 64 | "sr": stars, 65 | "bpm": bpm, 66 | "length": length, 67 | } 68 | -------------------------------------------------------------------------------- /bin/monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import logging 5 | import os 6 | import praw 7 | import re 8 | import requests 9 | import sys 10 | from dotenv import load_dotenv 11 | 12 | load_dotenv() 13 | 14 | sys.stdout = sys.stderr 15 | auto = "--auto" in sys.argv 16 | nofilter = "--no-filter" in sys.argv 17 | test = "--test" in sys.argv 18 | score_re = re.compile(".+[\|丨].+-.+\[.+\]") 19 | user = os.environ.get("OSU_BOT_USER", "osu-bot") 20 | sub = os.environ.get("OSU_BOT_SUB", "osugame") 21 | api = f'{os.environ.get("API_HOST")}:{os.environ.get("FLASK_RUN_PORT", 5000)}/scorepost' 22 | logger = logging.getLogger() 23 | logging.basicConfig(format="%(asctime)s: %(message)s", level=logging.INFO) 24 | 25 | def monitor(): 26 | reddit = praw.Reddit( 27 | client_id=os.environ["REDDIT_CLIENT_ID"], 28 | client_secret=os.environ["REDDIT_CLIENT_SECRET"], 29 | password=os.environ["REDDIT_PASSWORD"], 30 | user_agent=user, 31 | username=user, 32 | ) 33 | subreddit = reddit.subreddit(sub) 34 | 35 | for post in subreddit.stream.submissions(): 36 | if not nofilter and not score_re.match(post.title): 37 | logger.info("Skipping '%s' - '%s'" % (post.id, post.title)) 38 | continue 39 | if not test and post.saved: 40 | logger.info("Skipping '%s' - '%s'" % (post.id, post.title)) 41 | continue 42 | 43 | post_api(post.id) 44 | 45 | print("\n====================================\n") 46 | if not auto: 47 | input("Press enter to proceed to the next post: ") 48 | print() 49 | 50 | 51 | def post_api(p_id): 52 | url = "%s?id=%s" % (api, p_id) 53 | if test: 54 | url += "&test=true" 55 | logger.info("Posting to %s" % url) 56 | resp = requests.post(url) 57 | # data = resp.json() 58 | # print(data) 59 | logger.info(f"Post success, got status {resp.status_code}") 60 | return resp.status_code == 200 61 | 62 | 63 | if __name__ == "__main__": 64 | if "REDDIT_PASSWORD" not in os.environ: 65 | print("Missing Reddit environment variables") 66 | exit(1) 67 | 68 | logger.info("auto = %s" % auto) 69 | logger.info("nofilter = %s" % nofilter) 70 | logger.info("test = %s" % test) 71 | 72 | while True: 73 | try: 74 | monitor() 75 | except KeyboardInterrupt: 76 | print("\nExiting") 77 | break 78 | except Exception as e: 79 | logger.info("Exception: %s" % e) 80 | -------------------------------------------------------------------------------- /osubot/beatmap_search.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from . import consts 4 | from .utils import compare, map_str, request, safe_call 5 | 6 | 7 | def search(player, beatmap, logs=[]): 8 | """Search for beatmap with player.""" 9 | if player: 10 | result = search_events(player, beatmap) 11 | if result: 12 | logs.append("Beatmap: Found in events") 13 | return result 14 | result = search_best(player, beatmap) 15 | if result: 16 | logs.append("Beatmap: Found in best") 17 | return result 18 | result = search_recent(player, beatmap) 19 | if result: 20 | logs.append("Beatmap: Found in recent") 21 | return result 22 | 23 | logs.append("Beatmap '%s': Not found" % beatmap) 24 | return None 25 | 26 | 27 | def search_events(player, beatmap, mode=False, b_id=None): 28 | """ 29 | Search player's recent events for beatmap. 30 | If mode is False, returns the beatmap. 31 | Otherwise, returns the game mode of the event. 32 | """ 33 | for event in player.events: 34 | match = consts.event_re.search(event.display_html) 35 | if not match: 36 | continue 37 | if (b_id is not None and event.beatmap_id == b_id) or compare( 38 | match.group(1), beatmap 39 | ): 40 | if mode: 41 | return consts.eventstr2mode.get(match.group(2), None) 42 | b_id = event.beatmap_id 43 | beatmaps = safe_call(consts.osu_api.get_beatmaps, beatmap_id=b_id) 44 | if beatmaps: 45 | return beatmaps[0] 46 | 47 | return None 48 | 49 | 50 | def search_best(player, beatmap): 51 | """Search player's best plays for beatmap.""" 52 | best = safe_call(consts.osu_api.get_user_best, player.user_id, limit=100) 53 | if not best: 54 | return None 55 | 56 | today = datetime.datetime.today() 57 | threshold = datetime.timedelta(weeks=1) 58 | for score in filter(lambda s: today - s.date < threshold, best): 59 | beatmaps = safe_call(consts.osu_api.get_beatmaps, beatmap_id=score.beatmap_id,) 60 | if not beatmaps: 61 | continue 62 | bmap = beatmaps[0] 63 | 64 | if compare(map_str(bmap), beatmap): 65 | return bmap 66 | 67 | return None 68 | 69 | 70 | def search_recent(player, beatmap): 71 | """Search player's recent plays for beatmap.""" 72 | recent = safe_call(consts.osu_api.get_user_recent, player.user_id, limit=50) # noqa 73 | if not recent: 74 | return None 75 | 76 | ids = [] 77 | for score in recent: 78 | if score.beatmap_id in ids: 79 | continue 80 | ids.append(score.beatmap_id) 81 | 82 | beatmaps = safe_call(consts.osu_api.get_beatmaps, beatmap_id=score.beatmap_id,) 83 | if not beatmaps: 84 | continue 85 | bmap = beatmaps[0] 86 | 87 | if compare(map_str(bmap), beatmap): 88 | return bmap 89 | return None 90 | -------------------------------------------------------------------------------- /osubot/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import traceback 5 | 6 | import praw 7 | 8 | from . import consts, scorepost 9 | 10 | from flask import Flask, request 11 | from dotenv import load_dotenv 12 | 13 | load_dotenv() 14 | 15 | app = Flask(__name__) 16 | 17 | testrun = False 18 | gameplay_flair = [os.environ.get("REDDIT_FLAIR_ID"), os.environ.get("REDDIT_FLAIR_NAME")] 19 | 20 | 21 | @app.route("/scorepost", methods=["POST"]) 22 | def handler(): 23 | p_id = request.args.get("id") 24 | if p_id is None: 25 | return finish(status=400, error="Missing id parameter") 26 | print("Processing post ID: %s" % p_id) 27 | global testrun 28 | testrun = request.args.get("test") == "true" 29 | reddit = reddit_login(consts.reddit_user) 30 | post = praw.models.Submission(reddit, p_id) 31 | try: 32 | if post_is_saved(post): 33 | return finish(error="Post is already saved") 34 | except Exception as e: # Post likely doesn't exist. 35 | return finish(status=400, error=str(e)) 36 | if not consts.title_re.match(post.title): 37 | return finish(error="Not a score post") 38 | try: 39 | result = scorepost(post.title) 40 | if result is None: 41 | return finish(status=500, error="Comment generation failed") 42 | ctx, reply = result 43 | except Exception as e: 44 | traceback.print_exc(file=sys.stdout) 45 | return finish(status=500, error=str(e)) 46 | if not reply: 47 | return finish(status=500, error="Reply is empty") 48 | if post_has_reply(post, consts.reddit_user): 49 | return finish(error="Post already has a reply") 50 | ctx_d = ctx.to_dict() 51 | err = post_reply(post, reply, sticky=True, flair=gameplay_flair) 52 | if err: 53 | return finish( 54 | status=500, 55 | context=ctx_d, 56 | comment=reply, 57 | error=err, 58 | ) 59 | print(f'Processed post {ctx}') 60 | if os.environ.get("DEBUG_LOGS") == "True": print("[DEBUG] Commented:\n%s" % reply) 61 | return finish(context=ctx_d, comment=reply) 62 | 63 | 64 | def finish(status=200, error=None, **kwargs): 65 | if error: 66 | print(error) 67 | return {"error": error, **kwargs}, status 68 | 69 | 70 | def reddit_login(username): 71 | """Log into Reddit.""" 72 | return praw.Reddit( 73 | client_id=os.environ["REDDIT_CLIENT_ID"], 74 | client_secret=os.environ["REDDIT_CLIENT_SECRET"], 75 | password=os.environ["REDDIT_PASSWORD"], 76 | user_agent=username, 77 | username=username, 78 | ) 79 | 80 | 81 | def post_has_reply(post, username): 82 | """Check if post has a top-level reply by username.""" 83 | return not testrun and any( 84 | c.author.name == username if c.author else False 85 | for c in post.comments 86 | ) 87 | 88 | 89 | def post_reply(post, text, sticky=False, flair=[]): 90 | """ 91 | Reply to, save, and upvote a post, optionally flair it, 92 | and optionally sticky the comment. 93 | Returns None on success, the error in string form otherwise. 94 | """ 95 | if testrun: 96 | return None 97 | 98 | try: 99 | c = post.reply(text) 100 | if sticky: 101 | c.mod.distinguish(sticky=True) 102 | post.save() 103 | post.upvote() 104 | if flair: 105 | post.flair.select(*flair) 106 | except Exception as e: 107 | print("Reddit exception: %s" % e) 108 | return str(e) 109 | 110 | return None 111 | 112 | 113 | def post_is_saved(post): 114 | """Check whether the post is saved.""" 115 | return not testrun and post.saved 116 | -------------------------------------------------------------------------------- /bin/video_links.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import os 5 | import praw 6 | import re 7 | import requests 8 | import sys 9 | import time 10 | from dotenv import load_dotenv 11 | 12 | load_dotenv() 13 | 14 | sys.stdout = sys.stderr 15 | test = "--test" in sys.argv 16 | user = os.environ.get("OSU_BOT_USER", "osu-bot") 17 | sub = os.environ.get("OSU_BOT_SUB", "osugame") 18 | yt_re = re.compile("https?://(?:www\.)?(?:youtu\.be/|youtube\.com/watch\?v=)([\w-]+)") # noqa 19 | yt_key = os.environ.get("YOUTUBE_KEY") 20 | video_header = "YouTube links:" 21 | time_threshold = 30 # Seconds. 22 | yt_api = "https://www.googleapis.com/youtube/v3/videos" 23 | logger = logging.getLogger() 24 | logging.basicConfig(format="%(asctime)s: %(message)s", level=logging.INFO) 25 | 26 | reddit = None 27 | 28 | 29 | def reddit_login(): 30 | """Log in to Reddit.""" 31 | global reddit 32 | reddit = praw.Reddit( 33 | client_id=os.environ["REDDIT_CLIENT_ID"], 34 | client_secret=os.environ["REDDIT_CLIENT_SECRET"], 35 | password=os.environ["REDDIT_PASSWORD"], 36 | user_agent=user, 37 | username=user, 38 | ) 39 | 40 | 41 | def process_comment(comment): 42 | if not comment.is_root or comment.saved: 43 | return 44 | 45 | match = yt_re.search(comment.body) 46 | if not match: 47 | return 48 | 49 | bot_comment = find_bot_comment(comment) 50 | if bot_comment is None: 51 | return 52 | 53 | if edit_bot_comment(bot_comment, match.group(1)): 54 | comment.save() 55 | 56 | 57 | def process_backlog(): 58 | """Process the 100 most recent comments.""" 59 | for comment in reddit.subreddit(sub).comments(): 60 | process_comment(comment) 61 | 62 | 63 | def process_stream(): 64 | """Process comments as they arrive.""" 65 | for comment in reddit.subreddit(sub).stream.comments(): 66 | process_comment(comment) 67 | 68 | 69 | def find_bot_comment(other): 70 | """Look for a comment by the bot on the post that other replied to.""" 71 | submission = other.submission 72 | 73 | # Sometimes video comments get made before the bot comment is posted. 74 | # This could be made a bit less conservative by comparing the current time 75 | # rather than the post creation time, but the Reddit timestamps seem off 76 | # relative to normal UTC (but at least they're consistent with each other). 77 | if other.created_utc - submission.created_utc < time_threshold: 78 | logger.info("Sleeping") 79 | time.sleep(time_threshold) 80 | # To refresh the comments, we're stuck using this private method 81 | # or creating a new instance. 82 | submission._fetch() 83 | 84 | logger.info("Searching post %s" % submission.id) 85 | 86 | for comment in submission.comments: 87 | if comment.author.name == user and comment.is_root: 88 | if os.environ.get("DEBUG_LOGS") == "True": logger.info("Found comment:\n%s\n" % comment.body) 89 | return comment 90 | 91 | logger.info("No bot comment found on post %s" % submission.id) 92 | return None 93 | 94 | def get_youtube_data(yt_id): 95 | """Get the title and creator of a YouTube video.""" 96 | params = {"id": yt_id, "part": "snippet", "key": yt_key} 97 | try: 98 | resp = requests.get(yt_api, params=params) 99 | except Exception as e: 100 | logger.info("Request exception: %s" % e) 101 | return None, None 102 | 103 | if resp.status_code != 200: 104 | logger.info("YouTube API returned %d" % resp.status_code) 105 | return None, None 106 | 107 | try: 108 | data = resp.json()["items"][0]["snippet"] 109 | except Exception as e: 110 | logger.info("JSON error: %s" % e) 111 | return None, None 112 | 113 | return data.get("title"), data.get("channelTitle") 114 | 115 | 116 | def edit_bot_comment(comment, yt_id): 117 | """Add a new video link to a bot comment.""" 118 | if yt_id in comment.body: 119 | logger.info("Video is already linked") 120 | return False 121 | lines = comment.body.split("\n") 122 | 123 | for idx, line in enumerate(lines): 124 | if line.startswith(video_header): 125 | break 126 | if line == "***": 127 | lines.insert(idx, video_header) 128 | lines.insert(idx + 1, "") 129 | break 130 | else: 131 | logger.info("Couldn't find a place to insert video link.") 132 | return False 133 | 134 | n = lines[idx].count("https://youtu.be") + 1 135 | url = "https://youtu.be/%s" % yt_id 136 | 137 | title, channel = get_youtube_data(yt_id) 138 | title = title.replace(")", "\)") 139 | channel = channel.replace(")", "\)") 140 | if bool(title) and bool(channel): 141 | url += " \"'%s' by '%s'\"" % (title, channel) 142 | 143 | lines[idx] += " [[%d]](%s)" % (n, url) 144 | body = "\n".join(lines) 145 | 146 | if not test: 147 | comment.edit(body) 148 | 149 | if os.environ.get("DEBUG_LOGS") == "True": 150 | logger.info("[DEBUG] Edited comment contents:\n%s\n" % body) 151 | else: 152 | logger.info("Edited comment") 153 | return True 154 | 155 | if __name__ == "__main__": 156 | if "REDDIT_PASSWORD" not in os.environ: 157 | print("Missing Reddit environment variables") 158 | exit(1) 159 | if "YOUTUBE_KEY" not in os.environ: 160 | print("Missing YouTube environment variables") 161 | exit(1) 162 | 163 | reddit_login() 164 | logger.info("test = %s" % test) 165 | 166 | try: 167 | process_backlog() 168 | except Exception as e: 169 | print("Backlog exception: %s" % e) 170 | 171 | while True: 172 | try: 173 | process_stream() 174 | except KeyboardInterrupt: 175 | print("\nExiting") 176 | break 177 | except Exception as e: 178 | logger.info("Stream exception: %s" % e) 179 | -------------------------------------------------------------------------------- /osubot/scrape.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from . import consts 4 | from .utils import request, s3_zipped_download, s3_zipped_upload, safe_call 5 | 6 | tillerino_api = "https://api.tillerino.org" 7 | 8 | 9 | def download_beatmap(ctx): 10 | """Download a .osu file.""" 11 | if not ctx.beatmap: 12 | return None 13 | 14 | osu_path = "/tmp/%s.osu" % ctx.beatmap.file_md5 15 | if os.path.isfile(osu_path): 16 | return osu_path 17 | 18 | s3_key = "osu/%s.zip" % ctx.beatmap.file_md5 19 | if s3_zipped_download(s3_key) and os.path.isfile(osu_path): 20 | ctx.logs.append(".osu: Downloaded from S3") 21 | return osu_path 22 | 23 | text = request("%s/osu/%d" % (consts.old_url, ctx.beatmap.beatmap_id)) 24 | if text: 25 | ctx.logs.append(".osu: Downloaded from osu!web") 26 | else: 27 | text = request( 28 | "%s/beatmaps/byHash/%s/file?k=%s" 29 | % (tillerino_api, ctx.beatmap.file_md5, consts.tillerino_key), 30 | ) 31 | if text: 32 | ctx.logs.append(".osu: Downloaded from Tillerino") 33 | if not text: 34 | ctx.logs.append(".osu: Not downloaded") 35 | return None 36 | 37 | text = consts.osu_file_begin_re.sub("osu file format", text) 38 | 39 | with open(osu_path, "w") as f: 40 | f.write(text) 41 | 42 | # Store the beatmap for next time. 43 | if s3_zipped_upload(s3_key, os.path.basename(osu_path), text): 44 | ctx.logs.append(".osu: Uploaded to S3") 45 | 46 | return osu_path 47 | 48 | 49 | def mapper_id(ctx): 50 | """Get the mapper ID of a beatmap.""" 51 | text = request("%s/b/%d" % (consts.old_url, ctx.beatmap.beatmap_id)) 52 | if not text: 53 | return None 54 | 55 | match = consts.mapper_id_re.search(text) 56 | if not match: 57 | ctx.logs.append("Mapper ID: No regex match") 58 | return None 59 | return int(match.group(1)) 60 | 61 | 62 | def player_old_username(ctx): 63 | """Get a player's old username from their profile, if applicable.""" 64 | if not ctx.player: 65 | return None 66 | 67 | text = request("%s/u/%d" % (consts.old_url, ctx.player.user_id)) 68 | if not text: 69 | return None 70 | 71 | match = consts.old_username_re.search(text) 72 | return match.group(1) if match else None 73 | 74 | 75 | def playstyle(ctx): 76 | """Try to find the player's playstyle on their userpage.""" 77 | if not ctx.player: 78 | return None 79 | 80 | website = request("%s/u/%d" % (consts.old_url, ctx.player.user_id)) 81 | if not website: 82 | return None 83 | 84 | mouse = "M" if consts.playstyle_m_re.search(website) else None 85 | tablet = "TB" if consts.playstyle_tb_re.search(website) else None 86 | touch = "TD" if consts.playstyle_td_re.search(website) else None 87 | keyboard = "KB" if consts.playstyle_kb_re.search(website) else None 88 | 89 | joined = "+".join(filter(bool, [mouse, tablet, touch, keyboard])) 90 | 91 | return None if not joined else joined 92 | 93 | 94 | def max_combo(ctx): 95 | """Try to find the max combo of a beatmap.""" 96 | if ctx.beatmap.max_combo is not None and ctx.beatmap.mode.value == ctx.mode: # noqa 97 | return ctx.beatmap.max_combo 98 | 99 | combo = api_max_combo(ctx) 100 | if combo is not None: 101 | ctx.logs.append("Max combo: Found via API") 102 | return combo 103 | 104 | # Taiko is the only mode where the number of hitobject lines in 105 | # the .osu file corresponds exactly to the max combo. 106 | if ctx.mode == consts.taiko: 107 | nobjs = map_objects(ctx) 108 | if nobjs is not None: 109 | ctx.logs.append("Max combo: Computed manually") 110 | return nobjs[0] + nobjs[1] 111 | 112 | combo = web_max_combo(ctx) # This might not be accurate for mania. 113 | if combo is not None: 114 | ctx.logs.append("Max combo: Found via osu!web") 115 | return combo 116 | return None 117 | 118 | 119 | def api_max_combo(ctx): 120 | """Try to find the max combo from a score with the "perfect" bit set.""" 121 | scores = safe_call( 122 | consts.osu_api.get_scores, 123 | ctx.beatmap.beatmap_id, 124 | mode=consts.int2osuapimode.get(ctx.mode), 125 | limit=100, 126 | ) 127 | 128 | for score in scores: 129 | if score.perfect: 130 | return int(score.maxcombo) 131 | 132 | return None 133 | 134 | 135 | def web_max_combo(ctx): 136 | """Try to find the max combo from the top rank on the leaderboard.""" 137 | # TODO: We could look at all the scores on the leaderboard. 138 | text = request("%s/b/%d" % (consts.old_url, ctx.beatmap.beatmap_id)) 139 | if not text: 140 | return None 141 | 142 | if ctx.mode == consts.mania: 143 | misses_re = consts.mania_misses_re 144 | else: 145 | misses_re = consts.misses_re 146 | 147 | match = consts.combo_re.search(text) 148 | if not match: 149 | ctx.logs.append("combo_re: No match") 150 | return None 151 | combo = match.group(1) 152 | match = misses_re.search(text) 153 | if not match: 154 | ctx.logs.append("misses_re: No match") 155 | return None 156 | 157 | return int(combo) if match.group(1) == "0" else None 158 | 159 | 160 | def map_objects(ctx): 161 | """Get the number of regular hitobjects and sliders in a map, or None.""" 162 | path = download_beatmap(ctx) 163 | if path is None: 164 | return None 165 | with open(path) as f: 166 | lines = f.read().split() 167 | 168 | for i, line in enumerate(lines): 169 | if "[HitObjects]" in line: 170 | break 171 | else: 172 | return None 173 | 174 | regulars, sliders = 0, 0 175 | for line in lines[(i + 1) :]: 176 | if "|" in line: 177 | sliders += 1 178 | elif line: 179 | regulars += 1 180 | 181 | return regulars, sliders 182 | -------------------------------------------------------------------------------- /osubot/consts.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import os 3 | import osuapi 4 | import re 5 | import requests_cache 6 | import rosu_pp_py as rosu 7 | 8 | # Web stuff 9 | sess = requests_cache.CachedSession(backend="memory", expire_after=300,) # 5 minutes. 10 | osu_key = os.environ["OSU_API_KEY"] 11 | osu_api = osuapi.OsuApi(osu_key, connector=osuapi.ReqConnector(sess=sess)) 12 | tillerino_key = os.environ["TILLERINO_API_KEY"] 13 | osu_url = "https://osu.ppy.sh" 14 | old_url = "https://old.ppy.sh" 15 | s3_bucket = boto3.resource("s3").Bucket("osu-bot") 16 | 17 | # Reddit stuff 18 | reddit_user = os.environ.get("REDDIT_USER", "osu-bot") 19 | reddit_password = os.environ["REDDIT_PASSWORD"] 20 | reddit_client_id = os.environ["REDDIT_CLIENT_ID"] 21 | reddit_client_secret = os.environ["REDDIT_CLIENT_SECRET"] 22 | 23 | # Regex stuff 24 | title_re = re.compile(".+[\|丨].+-.+\[.+\]") 25 | map_re = re.compile(".+[\|丨](.+-.+\[.+\])") 26 | map_pieces_re = re.compile("(.+) - (.+?)\[(.+)\]") 27 | map_double_brackets_re = re.compile("(.+) - (.+?\[.+?\]) \[(.+)\]") 28 | player_re = re.compile("(.+)[\|丨].+-.+\[.+\]") 29 | event_re = re.compile( 30 | "(.+ - .+ \[.+\]) \((.+)\)" 31 | ) # noqa 32 | acc_re = re.compile("(\d{1,3}(?:[\.,]\d+)?)%") 33 | tail_re = re.compile(".+[\|丨].+-.+\[.+\](.+)") 34 | scorev2_re = re.compile("SV2|SCOREV2") 35 | paren_re = re.compile("\((.+?)\)") 36 | bracket_re = re.compile("\[(.+?)\]") 37 | mapper_id_re = re.compile( 38 | "Creator:" 42 | ) # noqa 43 | combo_re = re.compile("Max Combo([0-9]+)") 44 | misses_re = re.compile("Misses([0-9]+)") 45 | mania_misses_re = re.compile( 46 | "100 / 50 / Misses\d+ / \d+ / ([0-9]+)" 47 | ) # noqa 48 | playstyle_m_re = re.compile("
") # noqa 49 | playstyle_kb_re = re.compile( 50 | "
" 51 | ) # noqa 52 | playstyle_tb_re = re.compile( 53 | "
" 54 | ) # noqa 55 | playstyle_td_re = re.compile( 56 | "
" 57 | ) # noqa 58 | osu_file_begin_re = re.compile("\A.*osu file format") 59 | 60 | # Game stuff 61 | std, taiko, ctb, mania = range(0, 4) 62 | mode2str = { 63 | std: "osu!standard", 64 | taiko: "osu!taiko", 65 | ctb: "osu!catch", 66 | mania: "osu!mania", 67 | } 68 | int2osuapimode = { 69 | std: osuapi.OsuMode.osu, 70 | taiko: osuapi.OsuMode.taiko, 71 | ctb: osuapi.OsuMode.ctb, 72 | mania: osuapi.OsuMode.mania, 73 | } 74 | int2rosumode = { 75 | std: rosu.GameMode.Osu, 76 | taiko: rosu.GameMode.Taiko, 77 | ctb: rosu.GameMode.Catch, 78 | mania: rosu.GameMode.Mania, 79 | } 80 | eventstr2mode = { 81 | "osu!": std, 82 | "Taiko": taiko, 83 | "Catch the Beat": ctb, 84 | "osu!mania": mania, 85 | } 86 | mode_annots = { 87 | "STANDARD": std, 88 | "STD": std, 89 | "OSU!": std, 90 | "O!STD": std, 91 | "OSU!STD": std, 92 | "OSU!STANDARD": std, 93 | "TAIKO": taiko, 94 | "OSU!TAIKO": taiko, 95 | "O!TAIKO": taiko, 96 | "CTB": ctb, 97 | "O!CATCH": ctb, 98 | "OSU!CATCH": ctb, 99 | "CATCH": ctb, 100 | "OSU!CTB": ctb, 101 | "O!CTB": ctb, 102 | "MANIA": mania, 103 | "O!MANIA": mania, 104 | "OSU!MANIA": mania, 105 | "OSU!M": mania, 106 | "O!M": mania, 107 | } 108 | int2status = { 109 | -2: "Unranked", 110 | -1: "Unranked", 111 | 0: "Unranked", 112 | 1: "Ranked", 113 | 2: "Ranked", 114 | 3: "Qualified", 115 | 4: "Loved", 116 | } 117 | status2str = {v: k for k, v in int2status.items()} 118 | mods2int = { 119 | "": 1 >> 1, 120 | "NF": 1 << 0, 121 | "EZ": 1 << 1, 122 | "TD": 1 << 2, 123 | "HD": 1 << 3, 124 | "HR": 1 << 4, 125 | "SD": 1 << 5, 126 | "DT": 1 << 6, 127 | "RX": 1 << 7, 128 | "HT": 1 << 8, 129 | "NC": 1 << 6 | 1 << 9, # DT is always set along with NC. 130 | "FL": 1 << 10, 131 | "AT": 1 << 11, 132 | "SO": 1 << 12, 133 | "AP": 1 << 13, 134 | "PF": 1 << 5 | 1 << 14, # SD is always set along with PF. 135 | "V2": 1 << 29, 136 | # TODO: Unranked Mania mods, maybe. 137 | } 138 | int2mods = {v: k for k, v in mods2int.items()} 139 | nomod = mods2int[""] 140 | mod_order = [ 141 | "EZ", 142 | "HD", 143 | "HT", 144 | "DT", 145 | "NC", 146 | "HR", 147 | "FL", 148 | "NF", 149 | "SD", 150 | "PF", 151 | "RX", 152 | "AP", 153 | "SO", 154 | "AT", 155 | "V2", 156 | "TD", 157 | ] 158 | ignore_mods = [mods2int[m] for m in ["SD", "PF", "RX", "AT", "AP", "V2"]] 159 | samediffmods = [mods2int[m] for m in ["TD", "HD", "FL", "NF"]] 160 | 161 | # Markdown/HTML stuff 162 | bar = "|" # Vertical bar. 163 | spc = " " # Non-breaking space. 164 | hyp = "‑" # Non-breaking hyphen. 165 | 166 | # Misc stuff 167 | promo_rate = 1 / 3 168 | oppai_bin = os.environ.get("OPPAI_BIN", "oppai") 169 | title_ignores = [ 170 | "UNNOTICED", 171 | "UNNOTICED?", 172 | "RIPPLE", 173 | "GATARI", 174 | "UNSUBMITTED", 175 | "OFFLINE", 176 | "RESTRICTED", 177 | "BANNED", 178 | "UNRANKED", 179 | "LOVED", 180 | ] 181 | me = "https://reddit.com/u/PM_ME_DOG_PICS_PLS" 182 | new_dev = "https://reddit.com/u/MasterIO02" 183 | repo_url = "https://github.com/christopher-dG/osu-bot" 184 | unnoticed = "https://github.com/christopher-dG/unnoticed/wiki" 185 | memes = [ 186 | "pls enjoy gaem", 187 | "play more", 188 | "Ye XD", 189 | "imperial dead bicycle lol", 190 | "nice pass ecks dee", 191 | "kirito is legit", 192 | "can just shut up", 193 | "thank mr monstrata", 194 | "fc cry thunder and say that me again", 195 | "omg kappadar big fan", 196 | "reese get the camera", 197 | "cookiezi hdhr when", 198 | "hello there", 199 | "rrtyui :(", 200 | "0 pp if unranked", 201 | "these movements are from an algorithm designed in java", 202 | 203 | # suggested by u/Lettalosudroid 204 | "quit w", 205 | "permazoomer", 206 | "Cookiezi did it in 1485", 207 | "if fc (if ranked (if submitted))", 208 | "Blame top left", 209 | "Should have doubletapped", 210 | "Welcome to the new area", 211 | "When you see it", 212 | 213 | # suggested by u/Comfortable-Chip-740 214 | "what oh my god 😱 it's a stop sign 🛑 finding nemo 🐡 gold fish 🐠 Dory 🐟 NATIONAL GEOGRAPHIC 🟨 GODDAMN IT", 215 | "unironically cheating", 216 | "YIPPEEE", 217 | "check him hold times", 218 | 219 | # suggested by u/Chibu68_ 220 | "check him pc", 221 | "I showed osu! to a girl at work", # nice copypasta, u/Comfortable-Chip-740 222 | 223 | # suggested by u/helium1337 224 | "one of the best players in the world and spare" 225 | ] 226 | -------------------------------------------------------------------------------- /osubot/utils.py: -------------------------------------------------------------------------------- 1 | import pylev 2 | import os 3 | import sys 4 | import traceback 5 | import zipfile 6 | 7 | from . import consts 8 | 9 | 10 | def map_str(beatmap): 11 | """Format a beatmap into a string.""" 12 | if not beatmap: 13 | return None 14 | return "%s - %s [%s]" % (beatmap.artist, beatmap.title, beatmap.version) 15 | 16 | 17 | def escape(s): 18 | """Escape Markdown formatting.""" 19 | tb = str.maketrans({"^": "\\^", "*": "\\*", "_": "\\_", "~": "\\~", "<": "\\<"}) 20 | return s.translate(tb) 21 | 22 | 23 | def combine_mods(mods): 24 | """Convert a mod integer to a mod string.""" 25 | mods_a = [] 26 | for k, v in consts.mods2int.items(): 27 | if v & mods == v: 28 | mods_a.append(k) 29 | 30 | ordered_mods = list(filter(lambda m: m in mods_a, consts.mod_order)) 31 | "NC" in ordered_mods and ordered_mods.remove("DT") 32 | "PF" in ordered_mods and ordered_mods.remove("SD") 33 | 34 | return "+%s" % "".join(ordered_mods) if ordered_mods else "" 35 | 36 | 37 | def accuracy(s, mode): 38 | """Calculate accuracy for a score s as a float from 0-100.""" 39 | if mode == consts.std: 40 | return ( 41 | 100 42 | * (s.count300 + s.count100 / 3 + s.count50 / 6) 43 | / (s.count300 + s.count100 + s.count50 + s.countmiss) 44 | ) 45 | 46 | if mode == consts.taiko: 47 | return ( 48 | 100 49 | * (s.count300 + s.count100 / 2) 50 | / (s.count300 + s.count100 + s.countmiss) 51 | ) 52 | 53 | if mode == consts.ctb: 54 | return ( 55 | 100 56 | * (s.count300 + s.count100 + s.count50) 57 | / (s.count300 + s.count100 + s.count50 + s.countkatu + s.countmiss) 58 | ) 59 | 60 | if mode == consts.mania: 61 | x = ( 62 | s.countgeki 63 | + s.count300 64 | + 2 * s.countkatu / 3 65 | + s.count100 / 3 66 | + s.count50 / 6 67 | ) # noqa 68 | y = ( 69 | s.countgeki 70 | + s.count300 71 | + s.countkatu 72 | + s.count100 73 | + s.count50 74 | + s.countmiss 75 | ) # noqa 76 | return 100 * x / y 77 | 78 | 79 | def s_to_ts(secs): 80 | """Convert s seconds into a timestamp.""" 81 | hrs = secs // 3600 82 | mins = (secs - hrs * 3600) // 60 83 | secs = secs - hrs * 3600 - mins * 60 84 | 85 | ts = "%02d:%02d:%02d" % (hrs, mins, secs) 86 | return ts if hrs else ts[3:] 87 | 88 | 89 | def round_to_str(n, p, force=False): 90 | """Round n to p digits, or less if force is not set. Returns a string.""" 91 | epsilon = 1 / 10000 ** p # For floating point errors. 92 | if p == 0 or (abs(n - round(n)) + epsilon < 1 / 10 ** p and not force): 93 | return str(round(n)) 94 | 95 | if force: 96 | assert type(p) == int 97 | return eval("'%%.0%df' %% n" % p) 98 | 99 | return str(round(n, p)) 100 | 101 | 102 | def nonbreaking(s): 103 | """Return a visually identical version of s that does not break lines.""" 104 | return s.replace(" ", consts.spc).replace("-", consts.hyp) 105 | 106 | 107 | def safe_call(f, *args, alt=None, msg=None, **kwargs): 108 | """Execute some function, and return alt upon failure.""" 109 | try: 110 | return f(*args, **kwargs) 111 | except Exception as e: 112 | print("Function %s failed: %s" % (f.__name__, e)) 113 | print("args: %s" % list(args)) 114 | print("kwargs: %s" % kwargs) 115 | traceback.print_exc(file=sys.stdout) 116 | if msg: 117 | print(msg) 118 | return alt 119 | 120 | 121 | def request(url, *args, text=True, **kwargs): 122 | """Wrapper around HTTP requests.""" 123 | resp = safe_call(consts.sess.get, url, *args, **kwargs) 124 | 125 | if resp is None: 126 | print("Request to %s returned empty" % safe_url(url)) 127 | return None 128 | if resp.status_code != 200: 129 | print("Request to %s returned %d" % (safe_url(url), resp.status_code)) 130 | return None 131 | if not resp.text: 132 | print("Request to %s returned empty body" % safe_url(url)) 133 | return None 134 | 135 | return resp.text if text else resp 136 | 137 | 138 | def sep(n): 139 | """Format n with commas.""" 140 | return "{:,}".format(n) 141 | 142 | 143 | def safe_url(s): 144 | """Obfuscate sensitive keys in a string.""" 145 | return s.replace(consts.osu_key, "###") # noqa 146 | 147 | 148 | def compare(x, y): 149 | """Leniently compare two strings.""" 150 | x = x.replace(" ", "").replace(""", '"').replace("&", "&") 151 | y = y.replace(" ", "").replace(""", '"').replace("&", "&") 152 | 153 | return pylev.levenshtein(x.upper(), y.upper()) <= 2 154 | 155 | 156 | def is_ignored(mods): 157 | """Check whether all enabled mods are to be ignored.""" 158 | if mods is None or mods == consts.nomod: 159 | return True 160 | nonignores = set(consts.int2mods.keys()) - set(consts.ignore_mods) 161 | return not any(m & mods for m in nonignores) 162 | 163 | 164 | def changes_diff(mods): 165 | """Check whether any enabled mods change difficulty values.""" 166 | if mods is None: 167 | return False 168 | 169 | diff_changers = set(consts.int2mods.keys()) - set(consts.samediffmods) 170 | return any(m & mods for m in diff_changers) 171 | 172 | 173 | def matched_bracket_contents(s): 174 | """Find the contents of a pair of square brackets.""" 175 | if "[" not in s: 176 | return None 177 | 178 | s = s[(s.index("[") + 1) :] 179 | n = 0 180 | 181 | for i, c in enumerate(s): 182 | if c == "]" and n == 0: 183 | return s[:i] 184 | elif c == "]": 185 | n -= 1 186 | elif c == "[": 187 | n += 1 188 | 189 | return None 190 | 191 | 192 | def s3_zipped_download(key): 193 | """Download and unzip a file from S3 to /tmp/.""" 194 | if not os.environ.get("USE_S3_CACHE"): 195 | return False 196 | 197 | zip_path = "/tmp/%s" % os.path.basename(key) 198 | try: 199 | consts.s3_bucket.download_file(key, zip_path) 200 | except Exception as e: 201 | print("Downloading %s failed: %s" % (key, e)) 202 | return False 203 | 204 | with zipfile.ZipFile(zip_path) as zf: 205 | zf.extractall("/tmp/") 206 | 207 | return True 208 | 209 | 210 | def s3_zipped_upload(key, filename, body): 211 | """ 212 | Zip and upload a file to S3. 213 | filename is the destination inside the archive, not the file to zip. 214 | body is the string data to be zipped into filename. 215 | """ 216 | if not os.environ.get("USE_S3_CACHE"): 217 | return False 218 | 219 | zip_path = "/tmp/%s" % os.path.basename(key) 220 | with zipfile.ZipFile(zip_path, "w") as zf: 221 | zf.writestr(filename, body, compress_type=zipfile.ZIP_DEFLATED) 222 | 223 | with open(zip_path, "rb") as f: 224 | try: 225 | consts.s3_bucket.put_object(Key=key, Body=f) 226 | except Exception as e: 227 | print("Uploading %s failed: %s" % (key, e)) 228 | return False 229 | 230 | return True 231 | -------------------------------------------------------------------------------- /osubot/context.py: -------------------------------------------------------------------------------- 1 | from . import consts 2 | from .beatmap_search import search, search_events 3 | from .utils import combine_mods, map_str, matched_bracket_contents, safe_call 4 | 5 | 6 | class Context: 7 | """A container for all relevant data.""" 8 | 9 | def __init__(self, player, beatmap, mode, mods, acc, guest_mapper, logs): 10 | self.player = player # osuapi.models.User, None if missing 11 | self.beatmap = beatmap # osuapi.models.Beatmap, None if missing 12 | self.mode = mode # Int (0-4), None if missing 13 | self.mods = mods # Int, 0 if missing 14 | self.acc = acc # Float (0-100), None if missing 15 | self.guest_mapper = guest_mapper # osuapi.models.User, None if missing 16 | self.logs = logs # List of strings 17 | 18 | def __repr__(self): 19 | mode = "Unknown" if self.mode is None else consts.mode2str[self.mode] 20 | mods = "NoMod" if self.mods == consts.nomod else combine_mods(self.mods) # noqa 21 | acc = "None" if self.acc is None else "%.2f%%" % self.acc 22 | gm = "None" if self.guest_mapper is None else self.guest_mapper.username # noqa 23 | s = "Context:\n" 24 | s += "> Player: %s\n" % self.player 25 | s += "> Beatmap: %s\n" % map_str(self.beatmap) 26 | s += "> Mode: %s\n" % mode 27 | s += "> Mods: %s\n" % mods 28 | s += "> Accuracy: %s\n" % acc 29 | s += "> Guest mapper: %s" % gm 30 | return s 31 | 32 | def to_dict(self): 33 | """ 34 | Convert the context to a dict. 35 | This isn't meant for passing information, only displaying in JSON. 36 | """ 37 | return { 38 | "acc": "None" if self.acc is None else "%.2f%%" % self.acc, 39 | "beatmap": map_str(self.beatmap) if self.beatmap else "None", 40 | "mode": "Unknown" 41 | if self.mode is None 42 | else consts.mode2str[self.mode], # noqa 43 | "mods": combine_mods(self.mods), 44 | "player": self.player.username if self.player else "None", 45 | "guest mapper": self.guest_mapper.username 46 | if self.guest_mapper 47 | else "None", # noqa 48 | } 49 | 50 | 51 | def from_score_post(title): 52 | """Construct a Context from the title of score post.""" 53 | logs = [] 54 | player = getplayer(title, logs=logs) 55 | beatmap = getmap(title, player=player, logs=logs) 56 | mode = getmode(title, player=player, beatmap=beatmap) 57 | mods = getmods(title) 58 | logs.append("osr2mp4-mods: %s" % combine_mods(mods)) 59 | acc = getacc(title) 60 | guest_mapper = getguestmapper(title) 61 | 62 | # Once we know the game mode, we can ensure that the player and map 63 | # are of the right mode (this really helps with autoconverts). 64 | if mode is not None and mode != consts.std: 65 | match = consts.player_re.search(title) 66 | if match: 67 | name = strip_annots(match.group(1)) 68 | updated_players = safe_call( 69 | consts.osu_api.get_user, 70 | player.user_id if player else name, 71 | mode=consts.int2osuapimode[mode], 72 | ) 73 | if updated_players: 74 | player = updated_players[0] 75 | 76 | if beatmap is not None and beatmap.mode.value == consts.std: 77 | updated_beatmaps = safe_call( 78 | consts.osu_api.get_beatmaps, 79 | beatmap_id=beatmap.beatmap_id, 80 | mode=consts.int2osuapimode[mode], 81 | include_converted=True, 82 | ) 83 | if updated_beatmaps: 84 | beatmap = updated_beatmaps[0] 85 | 86 | return Context(player, beatmap, mode, mods, acc, guest_mapper, logs) 87 | 88 | 89 | def getplayer(title, logs=[]): 90 | """Get the player from the post title.""" 91 | match = consts.player_re.search(title) 92 | if not match: 93 | logs.append("Player: No regex match") 94 | return None 95 | name = strip_annots(match.group(1)) 96 | 97 | players = safe_call(consts.osu_api.get_user, name) 98 | if players: 99 | return players[0] 100 | logs.append("Player: '%s' not found" % name) 101 | return None 102 | 103 | 104 | def strip_annots(s): 105 | """Remove annotations in brackets and parentheses from a username.""" 106 | name = consts.paren_re.sub("", s.upper()).strip() 107 | 108 | ignores = consts.title_ignores + list(consts.mode_annots.keys()) 109 | for cap in consts.bracket_re.findall(name): 110 | if cap in ignores: 111 | name = name.replace("[%s]" % cap, "") 112 | 113 | return name.strip() 114 | 115 | 116 | def getmap(title, player=None, logs=[]): 117 | """Search for the beatmap.""" 118 | match = consts.map_re.search(title) 119 | if not match: 120 | logs.append("Beatmap: No regex match") 121 | return None 122 | map_s = match.group(1).strip() 123 | 124 | match = consts.map_double_brackets_re.search(map_s) 125 | if not match: 126 | match = consts.map_pieces_re.search(map_s) 127 | 128 | if match: 129 | diff = match.group(3) 130 | contents = matched_bracket_contents("[%s]" % diff) 131 | if contents: 132 | map_s = "%s - %s [%s]" % (match.group(1), match.group(2), contents) 133 | 134 | return search(player, map_s, logs=logs) 135 | 136 | 137 | def getmode(title, player=None, beatmap=None): 138 | """ 139 | Search for the game mode. 140 | If title doesn't contain any relevant information, try player's events. 141 | Otherwise, use beatmap's mode. 142 | If beatmap is None, then return None for unknown. 143 | """ 144 | match = consts.player_re.search(title.upper()) 145 | if not match: 146 | return None 147 | playername = match.group(1) 148 | 149 | for match in consts.paren_re.findall(playername): 150 | if match in consts.mode_annots: 151 | return consts.mode_annots[match] 152 | for match in consts.bracket_re.findall(playername): 153 | if match in consts.mode_annots: 154 | return consts.mode_annots[match] 155 | 156 | if beatmap: 157 | if player: 158 | m = search_events(player, "", b_id=beatmap.beatmap_id, mode=True) 159 | if m is not None: 160 | return m 161 | return beatmap.mode.value 162 | 163 | return None 164 | 165 | 166 | def getmods(title): 167 | """Search for mods in title.""" 168 | match = consts.tail_re.search(title) 169 | if not match: 170 | return consts.nomod 171 | tail = match.group(1) 172 | 173 | if "+" in tail and tail.index("+") < (len(tail) - 1): 174 | tokens = tail[(tail.index("+") + 1) :].split() 175 | if tokens: 176 | mods = getmods_token(tokens[0]) 177 | if mods is not None: 178 | return mods 179 | 180 | for token in tail.split(): 181 | mods = getmods_token(token) 182 | if mods is not None and mods != consts.nomod: 183 | return mods 184 | 185 | return consts.nomod 186 | 187 | 188 | def getmods_token(token): 189 | """ 190 | Get mods from a single token. 191 | Returns None if the token doesn't look like mods. 192 | """ 193 | token = consts.scorev2_re.sub("V2", token.upper().replace(",", "")) 194 | if len(token) % 2: 195 | return None 196 | 197 | mods = set() 198 | for i in range(0, len(token), 2): 199 | mod = consts.mods2int.get(token[i : i + 2]) 200 | if mod is None: 201 | return None 202 | mods.add(mod) 203 | 204 | return sum(mods) 205 | 206 | 207 | def getacc(title): 208 | """Search for accuracy in title.""" 209 | match = consts.tail_re.search(title) 210 | if not match: 211 | return None 212 | match = consts.acc_re.search(match.group(1)) 213 | return float(match.group(1).replace(",", ".")) if match else None 214 | 215 | 216 | def getguestmapper(title): 217 | """Search for a guest mapper in title.""" 218 | match = consts.map_pieces_re.search(title) 219 | if not match: 220 | return None 221 | diff = match.group(3) 222 | 223 | guest = diff.split()[0] 224 | if guest.endswith("'s"): 225 | guest = guest[:-2] 226 | elif guest.endswith("s'"): # jakads', etc. 227 | guest = guest[:-1] 228 | else: 229 | return None 230 | 231 | players = safe_call(consts.osu_api.get_user, guest) 232 | if players: 233 | player = players[0] 234 | else: 235 | return None 236 | 237 | # Only return the guest mapper if they have at least one map of their own. 238 | maps = safe_call(consts.osu_api.get_beatmaps, username=player.username) 239 | return player if maps else None 240 | -------------------------------------------------------------------------------- /osubot/markdown.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import markdown_strings as md 3 | import random 4 | 5 | from . import consts, diff, pp, scrape 6 | from .utils import ( 7 | accuracy, 8 | combine_mods, 9 | escape, 10 | map_str, 11 | nonbreaking, 12 | round_to_str, 13 | safe_call, 14 | sep, 15 | s_to_ts, 16 | ) 17 | 18 | 19 | def build_comment(ctx): 20 | """Build a full comment from ctx.""" 21 | if not ctx.player and not ctx.beatmap: 22 | return None 23 | 24 | comment = "\n\n".join( 25 | filter( 26 | bool, 27 | [map_header(ctx), map_table(ctx), player_table(ctx), "***", footer(ctx)], 28 | ) 29 | ) 30 | 31 | return None if comment.startswith("***") else comment 32 | 33 | 34 | def map_header(ctx): 35 | """Return a line or two with basic map information.""" 36 | if not ctx.beatmap: 37 | return None 38 | b = ctx.beatmap 39 | 40 | map_url = "%s/b/%d" % (consts.osu_url, b.beatmap_id) 41 | if ctx.mode is not None: 42 | map_url += "?m=%d" % ctx.mode 43 | map_link = md.link(escape(map_str(b)), map_url) 44 | mapper_id = scrape.mapper_id(ctx) 45 | mapper = b.creator if mapper_id is None else mapper_id 46 | mapper_url = "%s/u/%s" % (consts.osu_url, mapper) 47 | 48 | rename = mapper_renamed(ctx, mapper_id=mapper_id) 49 | hover = "Renamed to '%s'" % rename if rename is not None else "" 50 | 51 | counts = mapper_counts(ctx, mapper=mapper) 52 | if counts: 53 | hover += ": %s" % counts if hover else counts 54 | 55 | if hover: 56 | mapper_url += ' "%s"' % hover 57 | 58 | mapper_link = md.link(escape(b.creator), mapper_url) 59 | map_s = "%s by %s" % (map_link, mapper_link) 60 | 61 | if ctx.guest_mapper: 62 | guest_url = "%s/u/%d" % (consts.osu_url, ctx.guest_mapper.user_id) 63 | counts = mapper_counts(ctx, mapper=ctx.guest_mapper.user_id) 64 | if counts: 65 | guest_url += ' "%s"' % counts 66 | guest_link = md.link(ctx.guest_mapper.username, guest_url) 67 | map_s += " (GD by %s)" % guest_link 68 | 69 | tokens = [map_s] 70 | 71 | unranked = consts.int2status[b.approved.value] == "Unranked" 72 | 73 | if not unranked and ctx.mode is not None: 74 | tokens.append(consts.mode2str[ctx.mode]) 75 | 76 | header = md.header(" || ".join(tokens), 4) 77 | subheader = (unranked_subheader if unranked else approved_subheader)(ctx) 78 | 79 | return "%s\n%s" % (header, subheader) 80 | 81 | 82 | def approved_subheader(ctx): 83 | """Build a subheader for a ranked/qualified/loved beatmap.""" 84 | tokens = [] 85 | 86 | rank_one = map_rank_one(ctx) 87 | if rank_one is not None: 88 | tokens.append(rank_one) 89 | 90 | max_combo = scrape.max_combo(ctx) 91 | if max_combo is not None: 92 | tokens.append("%sx max combo" % sep(max_combo)) 93 | 94 | status = consts.int2status[ctx.beatmap.approved.value] 95 | if ctx.beatmap.approved_date is not None and status != "Qualified": 96 | status += " (%d)" % ctx.beatmap.approved_date.year 97 | tokens.append(status) 98 | 99 | if ctx.beatmap.playcount: 100 | tokens.append("%s plays" % sep(ctx.beatmap.playcount)) 101 | 102 | return md.bold(" || ".join(tokens)) 103 | 104 | 105 | def unranked_subheader(ctx): 106 | """Build a subheader for an unranked beatmap.""" 107 | tokens = [] 108 | if ctx.mode is not None: 109 | tokens.append(consts.mode2str[ctx.mode]) 110 | 111 | max_combo = scrape.max_combo(ctx) 112 | if max_combo is not None: 113 | tokens.append("%sx max combo" % sep(max_combo)) 114 | 115 | tokens.append("Unranked") 116 | 117 | return md.bold(" || ".join(tokens)) 118 | 119 | 120 | def map_table(ctx): 121 | """Build a table with map difficulty and pp values.""" 122 | if not ctx.beatmap: 123 | return None 124 | 125 | nomod = diff.diff_vals(ctx, modded=False) 126 | if nomod is None: 127 | return None 128 | if ctx.mods == consts.nomod: 129 | modded = None 130 | else: 131 | modded = diff.diff_vals(ctx, modded=True) 132 | 133 | r = round_to_str 134 | if modded: 135 | cols = [ 136 | ["", "NoMod", combine_mods(ctx.mods)], 137 | ["CS", r(nomod["cs"], 1), r(modded["cs"], 1)], 138 | ["AR", r(nomod["ar"], 1), r(modded["ar"], 1)], 139 | ["OD", r(nomod["od"], 1), r(modded["od"], 1)], 140 | ["HP", r(nomod["hp"], 1), r(modded["hp"], 1)], 141 | ["SR", r(nomod["sr"], 2, force=True), r(modded["sr"], 2, force=True),], 142 | ["BPM", round(nomod["bpm"]), round(modded["bpm"])], 143 | ["Length", s_to_ts(nomod["length"]), s_to_ts(modded["length"]),], 144 | ] 145 | else: 146 | cols = [ 147 | ["CS", r(nomod["cs"], 1)], 148 | ["AR", r(nomod["ar"], 1)], 149 | ["OD", r(nomod["od"], 1)], 150 | ["HP", r(nomod["hp"], 1)], 151 | ["SR", r(nomod["sr"], 2, force=True)], 152 | ["BPM", round(nomod["bpm"])], 153 | ["Length", s_to_ts(nomod["length"])], 154 | ] 155 | 156 | pp_vals = {} 157 | for acc in filter(bool, set([95, 98, 99, 100, ctx.acc])): 158 | nomod_pp = pp.pp_val(ctx, acc, modded=False) 159 | if nomod_pp is None: 160 | continue 161 | 162 | if modded: 163 | modded_pp = pp.pp_val(ctx, acc, modded=True) 164 | if modded_pp is not None: 165 | pp_vals[acc] = nomod_pp, modded_pp 166 | else: 167 | pp_vals[acc] = nomod_pp, None 168 | 169 | accs_joined = (" %s " % consts.bar).join( 170 | ( 171 | "%s%%" % (r(a, 2, force=True) if int(a) != a else str(a)) 172 | for a in sorted(pp_vals.keys()) 173 | ) 174 | ) 175 | nomod_joined = (" %s " % consts.bar).join( 176 | (sep(round(pp_vals[acc][0])) for acc in sorted(pp_vals.keys())) 177 | ) 178 | 179 | if pp_vals: 180 | cols.append(["pp (%s)" % accs_joined, nomod_joined]) 181 | if modded: 182 | modded_joined = (" % s " % consts.bar).join( 183 | (sep(round(pp_vals[acc][1])) for acc in sorted(pp_vals.keys())) 184 | ) 185 | cols[-1].append(modded_joined) 186 | 187 | return centre_table(md.table([[str(x) for x in col] for col in cols])) 188 | 189 | 190 | def player_table(ctx): 191 | """Build a table with player information.""" 192 | if not ctx.player: 193 | return None 194 | p = ctx.player 195 | if not p.pp_raw: # Player is inactive so most stats are null. 196 | return None 197 | 198 | rank = "#%s (#%s %s)" % (sep(p.pp_rank), sep(p.pp_country_rank), p.country) 199 | 200 | player_url = "%s/u/%d" % (consts.osu_url, p.user_id) 201 | old_username = scrape.player_old_username(ctx) 202 | if old_username and old_username.lower() != p.username.lower(): 203 | player_url += " \"Previously known as '%s'\"" % old_username 204 | player_link = md.link(nonbreaking(escape(p.username)), player_url) 205 | 206 | cols = [ 207 | ["Player", player_link], 208 | ["Rank", nonbreaking(rank)], 209 | ["pp", sep(round(p.pp_raw))], 210 | ["Accuracy", "%s%%" % round_to_str(p.accuracy, 2, force=True)], 211 | ["Playcount", sep(p.playcount)], 212 | ] 213 | 214 | # There's no point getting playstyle for non-standard players. 215 | playstyle = scrape.playstyle(ctx) if ctx.mode == consts.std else None 216 | if playstyle is not None: 217 | cols.insert(4, ["Playstyle", playstyle]) # Place after acc. 218 | 219 | mode = ctx.mode if ctx.mode is not None else consts.std 220 | scores = safe_call( 221 | consts.osu_api.get_user_best, 222 | ctx.player.user_id, 223 | mode=consts.int2osuapimode[mode], 224 | limit=1, 225 | ) 226 | if scores: 227 | score = scores[0] 228 | beatmaps = safe_call( 229 | consts.osu_api.get_beatmaps, 230 | beatmap_id=score.beatmap_id, 231 | mode=consts.int2osuapimode[mode], 232 | include_converted=True, 233 | ) 234 | if beatmaps: 235 | bmap = beatmaps[0] 236 | map_url = "%s/b/%d" % (consts.osu_url, bmap.beatmap_id) 237 | if ctx.mode is not None: 238 | map_url += "?m=%d" % ctx.mode 239 | 240 | ctx_clone = copy.deepcopy(ctx) 241 | ctx_clone.beatmap = bmap 242 | ctx_clone.mods = score.enabled_mods.value 243 | ctx_clone.mode = mode 244 | hover = map_hover(ctx_clone, oldmap=ctx.beatmap, oldmods=ctx.mods) 245 | 246 | if hover: 247 | map_url += ' "%s"' % hover 248 | 249 | map_link = md.link(nonbreaking(escape(map_str(bmap))), map_url) 250 | 251 | mods = combine_mods(score.enabled_mods.value) 252 | buf = "%s %s " % (mods, consts.bar) if mods else "" 253 | 254 | buf += "%s%%" % round_to_str(accuracy(score, mode), 2, force=True) 255 | 256 | if score.pp: 257 | buf += " %s %spp" % (consts.bar, sep(round(score.pp))) 258 | 259 | cols.append(["Top Play", "%s %s" % (map_link, nonbreaking(buf))]) 260 | 261 | return centre_table(md.table([[str(x) for x in col] for col in cols])) 262 | 263 | 264 | def footer(ctx): 265 | """Return a footer with some general information and hidden logs.""" 266 | tokens = [ 267 | md.link("Source", consts.repo_url), 268 | md.link("Developer", consts.new_dev), 269 | md.link("Original Developer", consts.me) 270 | ] 271 | 272 | # TODO: Add usage instructions link when commands are ready. 273 | 274 | # if random.random() < consts.promo_rate: 275 | # tokens.append(md.link( 276 | # "^([Unnoticed]: Unranked leaderboards)", 277 | # consts.unnoticed, 278 | # )) 279 | 280 | exp_pp = bool(ctx.beatmap) and ctx.beatmap.mode.value != ctx.mode 281 | exp_pp |= ctx.mode in [consts.ctb, consts.mania] 282 | if exp_pp: 283 | if ctx.mode == consts.taiko: 284 | mode = "Autoconverted " 285 | else: 286 | mode = "" 287 | mode += consts.mode2str[ctx.mode] 288 | tokens.append("^(%s pp is experimental)" % mode) 289 | 290 | text = "^(%s – )%s" % (random.choice(consts.memes), "^( | )".join(tokens)) 291 | logs = md.link( # Invisible link with hover text. 292 | consts.spc, 'http://x "%s"' % "\n".join(s.replace('"', "'") for s in ctx.logs), 293 | ) 294 | return "%s %s" % (text, logs) 295 | 296 | 297 | def map_rank_one(ctx): 298 | """Fetch and format the top play for a beatmap.""" 299 | if not ctx.beatmap: 300 | return None 301 | 302 | mode = ctx.mode if ctx.mode is not None else consts.std 303 | apimode = consts.int2osuapimode[mode] 304 | scores = safe_call( 305 | consts.osu_api.get_scores, ctx.beatmap.beatmap_id, mode=apimode, limit=2, 306 | ) 307 | if not scores: 308 | return None 309 | score = scores[0] 310 | 311 | use_two = bool(ctx.player) and score.user_id == ctx.player.user_id 312 | use_two &= score.enabled_mods.value == ctx.mods 313 | 314 | if use_two and len(scores) > 1: 315 | score = scores[1] 316 | 317 | players = safe_call(consts.osu_api.get_user, score.user_id, mode=apimode) 318 | if players: 319 | ctx_clone = copy.deepcopy(ctx) 320 | ctx_clone.player = players[0] 321 | hover = player_hover(ctx_clone, oldplayer=ctx.player) 322 | else: 323 | hover = None 324 | player_url = "%s/u/%s" % (consts.osu_url, score.user_id) 325 | if hover: 326 | player_url += ' "%s"' % hover 327 | player_link = md.link(escape(score.username), player_url) 328 | 329 | player = "#%d: %s" % (2 if use_two else 1, player_link) 330 | tokens = [] 331 | 332 | if score.enabled_mods.value != consts.nomod: 333 | tokens.append(combine_mods(score.enabled_mods.value)) 334 | tokens.append("%.2f%%" % accuracy(score, mode)) 335 | if score.pp is not None: 336 | tokens.append("%spp" % sep(round(score.pp))) 337 | 338 | return "%s (%s)" % (player, " - ".join(tokens)) 339 | 340 | 341 | def mapper_counts(ctx, mapper=None): 342 | """Get the number of maps per status for a beatmap's mapper.""" 343 | if not ctx.beatmap: 344 | return None 345 | 346 | if not mapper: 347 | mapper_id = scrape.mapper_id(ctx) 348 | mapper = ctx.beatmap.creator if mapper_id is None else mapper_id 349 | 350 | maps = safe_call(consts.osu_api.get_beatmaps, username=mapper) 351 | if not maps: 352 | return None 353 | 354 | groups = {k: 0 for k in consts.status2str} 355 | ids = [] 356 | for b in [(m.beatmapset_id, m.approved.value) for m in maps]: 357 | if b[0] in ids: 358 | continue 359 | ids.append(b[0]) 360 | if consts.int2status.get(b[1]) in groups: 361 | groups[consts.int2status[b[1]]] += 1 362 | 363 | return "%s ranked, %s qualified, %s loved, %s unranked" % tuple( 364 | sep(groups[k]) for k in ["Ranked", "Qualified", "Loved", "Unranked"] 365 | ) # noqa 366 | 367 | 368 | def mapper_renamed(ctx, mapper_id=None): 369 | """Check if the mapper of ctx's beatmap has renamed.""" 370 | if not mapper_id: 371 | mapper_id = scrape.mapper_id(ctx) 372 | if mapper_id is None: 373 | return None 374 | 375 | mapper_updated = safe_call(consts.osu_api.get_user, mapper_id) 376 | if mapper_updated and mapper_updated[0].username != ctx.beatmap.creator: 377 | return mapper_updated[0].username 378 | 379 | return None 380 | 381 | 382 | def map_hover(ctx, oldmap=None, oldmods=None): 383 | """Generate link hover text for a beatmap.""" 384 | if not ctx.beatmap: 385 | return None 386 | if oldmap and ctx.beatmap.beatmap_id == oldmap.beatmap_id and ctx.mods == oldmods: 387 | return None 388 | 389 | d = diff.diff_vals(ctx, modded=ctx.mods != consts.nomod) 390 | if not d: 391 | return None 392 | 393 | return " - ".join( 394 | [ 395 | "SR%s" % round_to_str(d["sr"], 2, force=True), 396 | "CS%s" % round_to_str(d["cs"], 1), 397 | "AR%s" % round_to_str(d["ar"], 1), 398 | "OD%s" % round_to_str(d["od"], 1), 399 | "HP%s" % round_to_str(d["hp"], 1), 400 | "%dBPM" % d["bpm"], 401 | s_to_ts(d["length"]), 402 | ] 403 | ) 404 | 405 | 406 | def player_hover(ctx, oldplayer=None): 407 | """Generate link hover text for a player.""" 408 | if not ctx.player or (oldplayer and ctx.player.user_id == oldplayer.user_id): 409 | return None 410 | p = ctx.player 411 | if not p.pp_raw: # Player is inactive so most stats are null. 412 | return None 413 | 414 | return " - ".join( 415 | [ 416 | "%spp" % sep(round(p.pp_raw)), 417 | "rank #%s (#%s %s)" 418 | % (sep(p.pp_rank), sep(p.pp_country_rank), p.country), # noqa 419 | "%s%% accuracy" % round_to_str(p.accuracy, 2, force=True), 420 | "%s playcount" % sep(p.playcount), 421 | ] 422 | ) 423 | 424 | 425 | def centre_table(t): 426 | """Centre cells in a Markdown table.""" 427 | lines = t.split("\n") 428 | return t.replace(lines[1], "|".join([":-:"] * (lines[0].count("|") - 1))) 429 | -------------------------------------------------------------------------------- /test/tests.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import markdown_strings as md 3 | import osubot 4 | import re 5 | 6 | logging.getLogger("urllib3").propagate = False 7 | 8 | 9 | # Map name Map URL Download Download URL Mapper Mapper URL Rename Map counts GD name GD URL GD map counts Mode # noqa 10 | approved_header = re.compile( 11 | """#### \[.+-.+\[.+\]\]\(https://osu\.ppy\.sh/b/\d+(:?\?m=\d)?\) \[\(⬇\)\]\(https://osu\.ppy\.sh/d/\d+ "Download this beatmap"\) by \[.+\]\(https://osu\.ppy\.sh/u/.+ "(?:Renamed to '.+': )?[\d,]+ ranked, [\d,]+ qualified, [\d,]+ loved, [\d,]+ unranked"\)(?: \(GD by \[.+\]\(https://osu.ppy.sh/u/\d+ "[\d,]+ ranked, [\d,]+ qualified, [\d,]+ loved, [\d,]+ unranked"\))? \|\| osu![a-z]+""" 12 | ) # noqa 13 | # #1/2 Player Player URL pp Rank Country rank Accuracy Playcount Mods Accuracy pp Max combo Ranked status/year Playcount # noqa 14 | approved_subheader = re.compile( 15 | """\*\*#[12]: \[.+\]\(https://osu\.ppy\.sh/u/\d+(?: "[\d,]+pp - rank #[\d,]+ \(#[\d,]+ [A-Z]{2}\) - \d{1,3}\.\d{2}% accuracy - [\d,]+ playcount")?\) \((?:\+(?:[A-Z2]{2})+ - )?\d{1,3}\.\d{2}%(?: - [\d,]+pp)?\) \|\| [\d,]+x max combo \|\| \w+(?: \(\d{4}\))? \|\| [\d,]+ plays\*\*""" 16 | ) # noqa 17 | 18 | # Map name Map URL Download Download URL Mapper Mapper URL Rename Map counts GD name GD URL GD map counts # noqa 19 | unranked_header = re.compile( 20 | """#### \[.+-.+\[.+\]\]\(https://osu\.ppy\.sh/b/\d+(:?\?m=\d)?\) \[\(⬇\)\]\(https://osu\.ppy\.sh/d/\d+ "Download this beatmap"\) by \[.+\]\(https://osu\.ppy\.sh/u/.+ "(?:Renamed to '.+': )?[\d,]+ ranked, [\d,]+ qualified, [\d,]+ loved, [\d,]+ unranked"\)(?: \(GD by \[.+\]\(https://osu.ppy.sh/u/\d+ "[\d,]+ ranked, [\d,]+ qualified, [\d,]+ loved, [\d,]+ unranked"\))?""" 21 | ) # noqa 22 | # Mode Max combo Ranked status # noqa 23 | unranked_subheader = re.compile( 24 | """osu![a-z]+ \|\| [\d,]+x max combo \|\| Unranked""" 25 | ) # noqa 26 | 27 | nomod_map_table_header = re.compile( 28 | """\ 29 | \|\s+CS\s+\|\s+AR\s+\|\s+OD\s+\|\s+HP\s+\|\s+SR\s+\|\s+BPM\s+\|\s+Length\s+\|\s+pp \(.+\)\s+\| 30 | :-:\|:-:\|:-:\|:-:\|:-:\|:-:\|:-:\|:-:\ 31 | """ 32 | ) # noqa 33 | # CS AR OD HP SR BPM Length pp # noqa 34 | nomod_map_table_values = re.compile( 35 | """\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}\.\d{2}\s+\|\s+[\d,]+\s+\|\s+(?:\d{2}:)?\d{2}:\d{2}\s+\|\s+.+\s+\|""" 36 | ) # noqa 37 | 38 | modded_map_table_header = re.compile( 39 | """\ 40 | \|\s+\|\s+CS\s+\|\s+AR\s+\|\s+OD\s+\|\s+HP\s+\|\s+SR\s+\|\s+BPM\s+\|\s+Length\s+\|\s+pp \(.+\)\s+\| 41 | :-:\|:-:\|:-:\|:-:\|:-:\|:-:\|:-:\|:-:\|:-:\ 42 | """ 43 | ) # noqa 44 | 45 | # Mod CS AR OD HP SR BPM Length pp # noqa 46 | nomod = """\|\s+NoMod\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}\.\d{2}\s+\|\s+[\d,]+\s+\|\s+(?:\d{2}:)?\d{2}:\d{2}\s+\|\s+.+\s+\|""" # noqa 47 | # Mod CS AR OD HP SR BPM Length pp # noqa 48 | modded = """\|\s+\+(?:[A-Z2]{2})+\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}(?:\.\d)?\s+\|\s+\d{1,2}\.\d{2}\s+\|\s+[\d,]+\s+\|\s+(?:\d{2}:)?\d{2}:\d{2}\s+\|\s+.+\s+\|""" # noqa 49 | modded_map_table_values = re.compile("%s\n%s" % (nomod, modded)) 50 | 51 | player_table_header = re.compile( 52 | """\ 53 | \|\s+Player\s+\|\s+Rank\s+\|\s+pp\s+\|\s+Accuracy\s+\|(?:\s+Playstyle\s+\|)?\s+Playcount\s+\|\s+Top Play\s+\| 54 | :-:\|:-:\|:-:\|:-:\|:-:\|:-:(?:\|:-:)?\ 55 | """ 56 | ) # noqa 57 | 58 | # Name URL Old name Rank Country rank pp Accuracy Playstyle Playcount Top plau map Map URL Map SR Map CS Map AR Map OD Map HP Map BPM Map length Mods Accuracy pp # noqa 59 | player_table_values = re.compile( 60 | """\|\s+\[.+\]\(https://osu\.ppy\.sh/u/\d+(?: "Previously known as '.+'")?\)\s+\|\s+#[\d,]+ \(#[\d,]+ [A-Z]{2}\)\s+\|\s+[\d,]+\s+\|\s+\d{1,3}\.\d{2}%\s+\|(?:\s+[A-Z\+]+\s+\|)?\s+[\d,]+\s+\|\s+\[.+#x2011;.+\[.+\]\]\(https://osu\.ppy\.sh/b/\d+(?:\?m=\d)?(?: "SR\d{1,2}\.\d{2} - CS\d{1,2}(?:\.\d)? - AR\d{1,2}(?:\.\d)? - OD\d{1,2}(?:\.\d)? - HP\d{1,2}(?:\.\d)? - [\d,]+BPM - (?:\d{2}:)?\d{2}:\d{2}")?\) (?:\+(?:[A-Z2]{2})+ | )?\d{1,3}\.\d{2}% | [\d,]+pp\s+\|""" 61 | ) # noqa 62 | 63 | # Meme Repo Profile # noqa 64 | footer = re.compile( 65 | """\^\(.+ – \)\[\^Source\]\(https://github\.com/christopher-dG/osu-bot\)\^\( \| \)\[\^Developer\]\(https://reddit\.com/u/PM_ME_DOG_PICS_PLS\)""" 66 | ) # noqa 67 | 68 | std_t = "Cookiezi | xi - FREEDOM DiVE [FOUR DIMENSIONS] +HDHR 99.83%" 69 | std_unranked_t = ( 70 | "Mlaw | t+pazolite with Kabocha - Elder Dragon Legend [???] 99.95%" # noqa 71 | ) 72 | taiko_t = "applerss | KASAI HARCORES - Cycle Hit [Strike] HD,DT 96,67%" 73 | ctb_t = "[ctb] Dusk | onoken - P8107 [Nervous Breakdown] +HR 99.92%" 74 | mania_t = "(mania) WindyS | LeaF - Doppelganger [Alter Ego] 98.53%" 75 | 76 | 77 | def try_assert(f, expected, *args, attr=None, **kwargs): 78 | try: 79 | result = f(*args, **kwargs) 80 | if attr: 81 | result = result.__getattribute__(attr) 82 | assert result == expected 83 | except Exception as e: 84 | assert False, "%s: %s" % (f.__name__, e) 85 | 86 | 87 | def _assert_match(regex, text): 88 | assert regex.search(text), "\nText:\n%s\nRegex:\n%s" % (text, regex.pattern) # noqa 89 | 90 | 91 | def isapprox(x, y, t=0.005): 92 | return abs(x - y) < t 93 | 94 | 95 | def test_combine_mods(): 96 | assert osubot.utils.combine_mods(1 >> 1) == "" 97 | assert osubot.utils.combine_mods(1 << 3 | 1 << 9 | 1 << 6) == "+HDNC" 98 | assert osubot.utils.combine_mods(1 << 3 | 1 << 4) == "+HDHR" 99 | assert osubot.utils.combine_mods(1 << 10 | 1 << 5 | 1 << 14) == "+FLPF" 100 | assert osubot.utils.combine_mods(1 << 6) == "+DT" 101 | 102 | 103 | def test_getmods(): 104 | assert osubot.context.getmods(" | - [ ] ") == 0 105 | assert osubot.context.getmods(" | - [ ] + ") == 0 106 | assert osubot.context.getmods(" | - [ ] +HD") == 8 107 | assert osubot.context.getmods(" | - [ ] HDHR +HD") == 8 108 | assert osubot.context.getmods(" | - [ ] HDHR HD") == 24 109 | assert osubot.context.getmods(" | - [ ] HD,HR HD") == 24 110 | assert osubot.context.getmods(" | - [ ] HD,HR +") == 24 111 | 112 | 113 | def test_getmods_token(): 114 | assert osubot.context.getmods_token("") == 0 115 | assert osubot.context.getmods_token("HDX") is None 116 | assert osubot.context.getmods_token("hd") == 8 117 | assert osubot.context.getmods_token("HDHR") == 24 118 | assert osubot.context.getmods_token("HD,HR") == 24 119 | assert osubot.context.getmods_token("HDHRHR") == 24 120 | assert osubot.context.getmods_token("HDHRSCOREV2") == 536870936 121 | assert osubot.context.getmods_token("HDHRSV2DT") == 536871000 122 | 123 | 124 | def test_accuracy(): 125 | class Foo: 126 | def __init__(self, n3, n1, n5, ng, nk, nm): 127 | self.count300 = n3 128 | self.count100 = n1 129 | self.count50 = n5 130 | self.countgeki = ng 131 | self.countkatu = nk 132 | self.countmiss = nm 133 | 134 | assert isapprox( 135 | osubot.utils.accuracy(Foo(1344, 236, 2, 206, 82, 8), osubot.consts.std,), 89.5, 136 | ) 137 | assert isapprox( 138 | osubot.utils.accuracy(Foo(2401, 436, 0, 13, 4, 92), osubot.consts.taiko,), 139 | 89.42, 140 | ) 141 | assert isapprox( 142 | osubot.utils.accuracy(Foo(2655, 171, 435, 339, 3, 31), osubot.consts.ctb,), 143 | 98.97, 144 | ) 145 | assert isapprox( 146 | osubot.utils.accuracy(Foo(902, 13, 4, 1882, 180, 16), osubot.consts.mania,), 147 | 97.06, 148 | ) 149 | 150 | 151 | def test_map_str(): 152 | class Foo: 153 | def __init__(self, a, t, v): 154 | self.artist = a 155 | self.title = t 156 | self.version = v 157 | 158 | assert osubot.utils.map_str(Foo("foo", "bar", "baz")) == "foo - bar [baz]" 159 | 160 | 161 | def test_s_to_ts(): 162 | assert osubot.utils.s_to_ts(0) == "00:00" 163 | assert osubot.utils.s_to_ts(10) == "00:10" 164 | assert osubot.utils.s_to_ts(340) == "05:40" 165 | assert osubot.utils.s_to_ts(3940) == "01:05:40" 166 | 167 | 168 | def test_nonbreaking(): 169 | assert osubot.utils.nonbreaking("") == "" 170 | assert osubot.utils.nonbreaking("foobar") == "foobar" 171 | assert osubot.utils.nonbreaking("foo bar") == "foo%sbar" % osubot.consts.spc # noqa 172 | assert osubot.utils.nonbreaking("foo-bar") == "foo%sbar" % osubot.consts.hyp # noqa 173 | 174 | 175 | def test_round_to_string(): 176 | assert osubot.utils.round_to_str(1, 2) == "1" 177 | assert osubot.utils.round_to_str(1, 2, force=True) == "1.00" 178 | assert osubot.utils.round_to_str(1.4, 0) == "1" 179 | assert osubot.utils.round_to_str(1.4, 1) == "1.4" 180 | assert osubot.utils.round_to_str(1.4, 2) == "1.4" 181 | assert osubot.utils.round_to_str(1.4, 2, force=True) == "1.40" 182 | assert osubot.utils.round_to_str(1.01, 1) == "1" 183 | assert osubot.utils.round_to_str(0.9997, 3) == "1" 184 | assert osubot.utils.round_to_str(4.1, 1) == "4.1" 185 | 186 | 187 | def test_safe_call(): 188 | def foo(x, y=0): 189 | return x / y 190 | 191 | assert osubot.utils.safe_call(foo, 0, y=1) == 0 192 | assert osubot.utils.safe_call(foo, 1, y=0) is None 193 | assert osubot.utils.safe_call(foo, 1, y=0, alt=10) == 10 194 | 195 | 196 | def test_sep(): 197 | assert osubot.utils.sep(0) == "0" 198 | assert osubot.utils.sep(1) == "1" 199 | assert osubot.utils.sep(999) == "999" 200 | assert osubot.utils.sep(9999) == "9,999" 201 | assert osubot.utils.sep(999999999) == "999,999,999" 202 | 203 | 204 | def test_centre_table(): 205 | t = md.table([["foobar"] * 10] * 5) 206 | lines = t.split("\n") 207 | centred = osubot.markdown.centre_table(t) 208 | centred_lines = centred.split("\n") 209 | assert centred_lines[1] == ":-:|:-:|:-:|:-:|:-:" 210 | assert lines[0] == centred_lines[0] 211 | assert "\n".join(lines[2:]) == "\n".join(centred_lines[2:]) 212 | 213 | 214 | def test_compare(): 215 | assert osubot.utils.compare("", "") 216 | assert not osubot.utils.compare("foo", "bar") 217 | assert osubot.utils.compare("foo bar", "foobar") 218 | assert osubot.utils.compare("foobar", "FOOBAR") 219 | assert osubot.utils.compare("foo&bar", "FOO&BAR") 220 | assert osubot.utils.compare('foo"bar', "FOO"BAR") 221 | assert osubot.utils.compare("foo", "fob") 222 | 223 | 224 | def test_safe_url(): 225 | assert osubot.utils.safe_url("") == "" 226 | assert osubot.utils.safe_url("foobar") == "foobar" 227 | assert osubot.utils.safe_url(osubot.consts.osu_key) == "###" 228 | assert ( 229 | osubot.utils.safe_url("?k=%s&b=1" % osubot.consts.osu_key) == "?k=###&b=1" 230 | ) # noqa 231 | 232 | 233 | def test_escape(): 234 | assert osubot.utils.escape("") == "" 235 | assert osubot.utils.escape("a*b_c^d~e") == "a\*b\_c\^d\~e" 236 | 237 | 238 | def test_is_ignored(): 239 | assert not osubot.utils.is_ignored(1 << 3) 240 | assert osubot.utils.is_ignored(1 << 7) 241 | assert osubot.utils.is_ignored(1 << 11) 242 | assert not osubot.utils.is_ignored(1 << 3 | 1 << 7) 243 | assert osubot.utils.is_ignored(1 << 7 | 1 << 11) 244 | assert not osubot.utils.is_ignored(1 << 3 | 1 << 7 | 1 << 11) 245 | 246 | 247 | def test_changes_diff(): 248 | assert osubot.utils.changes_diff(1 << 4) 249 | assert not osubot.utils.changes_diff(1 << 3) 250 | assert osubot.utils.changes_diff(1 << 3 | 1 << 4) 251 | assert not osubot.utils.changes_diff(1 << 2 | 1 << 0 | 1 << 10) 252 | assert osubot.utils.changes_diff(1 << 2 | 1 << 0 | 1 << 10 | 1 << 6) 253 | 254 | 255 | def test_matched_bracket_contents(): 256 | func = osubot.utils.matched_bracket_contents 257 | assert func("") is None 258 | assert func("[foo]") == "foo" 259 | assert func("[foo [bar]]") == "foo [bar]" 260 | assert func("[foo [bar] baz [qux]]") == "foo [bar] baz [qux]" 261 | assert func("[foo bar [ baz]") is None 262 | 263 | 264 | def test_strip_annots(): 265 | assert osubot.context.strip_annots("") == "" 266 | assert osubot.context.strip_annots("foo") == "FOO" 267 | assert osubot.context.strip_annots("foo (bar)") == "FOO" 268 | assert osubot.context.strip_annots("[foo] bar") == "[FOO] BAR" 269 | assert osubot.context.strip_annots("[unnoticed] foo") == "FOO" 270 | assert osubot.context.strip_annots("(foo) bar (baz)") == "BAR" 271 | assert osubot.context.strip_annots("[mania] [foo] bar") == "[FOO] BAR" 272 | 273 | # NOTE: Most of the tests below are network dependent, 274 | # so spurious failures are possible. 275 | 276 | 277 | def test_getplayer(): 278 | try_assert(osubot.context.getplayer, 124493, std_t, attr="user_id") 279 | 280 | 281 | def test_getmap(): 282 | try_assert(osubot.context.getmap, 129891, std_t, attr="beatmap_id") 283 | 284 | 285 | def test_getmode(): 286 | assert osubot.context.getmode(ctb_t) == osubot.consts.ctb 287 | assert osubot.context.getmode(mania_t) == osubot.consts.mania 288 | 289 | 290 | def test_getacc(): 291 | assert osubot.context.getacc(std_t) == 99.83 292 | assert osubot.context.getacc(taiko_t) == 96.67 293 | 294 | 295 | def test_getguestmapper(): 296 | try_assert( 297 | osubot.context.getguestmapper, 298 | "toybot", 299 | ". | . - . [toybot's .]", 300 | attr="username", 301 | ) 302 | assert not osubot.context.getguestmapper(".|.-.[Insane]") 303 | 304 | 305 | def test_std_end2end(): 306 | ctx, reply = osubot.scorepost(std_t) 307 | assert str(ctx) == "\n".join( 308 | [ 309 | "Context:", 310 | "> Player: Cookiezi", 311 | "> Beatmap: xi - FREEDOM DiVE [FOUR DIMENSIONS]", 312 | "> Mode: osu!standard", 313 | "> Mods: +HDHR", 314 | "> Accuracy: 99.83%", 315 | "> Guest mapper: None", 316 | ] 317 | ) 318 | _assert_match(approved_header, reply) 319 | _assert_match(approved_subheader, reply) 320 | _assert_match(modded_map_table_header, reply) 321 | _assert_match(modded_map_table_values, reply) 322 | _assert_match(player_table_header, reply) 323 | _assert_match(player_table_values, reply) 324 | _assert_match(footer, reply) 325 | 326 | 327 | def test_std_unranked_end2end(): 328 | ctx, reply = osubot.scorepost(std_unranked_t) 329 | assert str(ctx) == "\n".join( 330 | [ 331 | "Context:", 332 | "> Player: Mlaw", 333 | "> Beatmap: t+pazolite with Kabocha - Elder Dragon Legend [???]", 334 | "> Mode: osu!standard", 335 | "> Mods: NoMod", 336 | "> Accuracy: 99.95%", 337 | "> Guest mapper: None", 338 | ] 339 | ) 340 | _assert_match(unranked_header, reply) 341 | _assert_match(unranked_subheader, reply) 342 | _assert_match(nomod_map_table_header, reply) 343 | _assert_match(nomod_map_table_values, reply) 344 | _assert_match(player_table_header, reply) 345 | _assert_match(player_table_values, reply) 346 | _assert_match(footer, reply) 347 | 348 | 349 | def test_taiko_end2end(): 350 | ctx, reply = osubot.scorepost(taiko_t) 351 | assert str(ctx) == "\n".join( 352 | [ 353 | "Context:", 354 | "> Player: applerss", 355 | "> Beatmap: KASAI HARCORES - Cycle Hit [Strike]", 356 | "> Mode: osu!taiko", 357 | "> Mods: +HDDT", 358 | "> Accuracy: 96.67%", 359 | "> Guest mapper: None", 360 | ] 361 | ) 362 | _assert_match(approved_header, reply) 363 | _assert_match(approved_subheader, reply) 364 | _assert_match(modded_map_table_header, reply) 365 | _assert_match(modded_map_table_values, reply) 366 | _assert_match(player_table_header, reply) 367 | _assert_match(player_table_values, reply) 368 | _assert_match(footer, reply) 369 | 370 | 371 | def test_ctb_end2end(): 372 | ctx, reply = osubot.scorepost(ctb_t) 373 | assert str(ctx) == "\n".join( 374 | [ 375 | "Context:", 376 | "> Player: Dusk", 377 | "> Beatmap: onoken - P8107 [Nervous Breakdown]", 378 | "> Mode: osu!catch", 379 | "> Mods: +HR", 380 | "> Accuracy: 99.92%", 381 | "> Guest mapper: None", 382 | ] 383 | ) 384 | _assert_match(approved_header, reply) 385 | _assert_match(approved_subheader, reply) 386 | _assert_match(modded_map_table_header, reply) 387 | _assert_match(modded_map_table_values, reply) 388 | _assert_match(player_table_header, reply) 389 | _assert_match(player_table_values, reply) 390 | _assert_match(footer, reply) 391 | assert "osu!catch pp is experimental" in reply 392 | 393 | 394 | def test_mania_end2end(): 395 | ctx, reply = osubot.scorepost(mania_t) 396 | assert str(ctx) == "\n".join( 397 | [ 398 | "Context:", 399 | "> Player: WindyS", 400 | "> Beatmap: LeaF - Doppelganger [Alter Ego]", 401 | "> Mode: osu!mania", 402 | "> Mods: NoMod", 403 | "> Accuracy: 98.53%", 404 | "> Guest mapper: None", 405 | ] 406 | ) 407 | _assert_match(approved_header, reply) 408 | _assert_match(approved_subheader, reply) 409 | _assert_match(nomod_map_table_header, reply) 410 | _assert_match(nomod_map_table_values, reply) 411 | _assert_match(player_table_header, reply) 412 | _assert_match(player_table_values, reply) 413 | _assert_match(footer, reply) 414 | assert "osu!mania pp is experimental" in reply 415 | 416 | 417 | test_getmap.net = 1 418 | test_getplayer.net = 1 419 | test_getguestmapper.net = 1 420 | test_std_end2end.net = 1 421 | test_std_unranked_end2end.net = 1 422 | test_taiko_end2end.net = 1 423 | test_ctb_end2end.net = 1 424 | test_mania_end2end.net = 1 425 | --------------------------------------------------------------------------------