├── .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 |
--------------------------------------------------------------------------------