├── .gitignore ├── LICENSE ├── README.md ├── codeforces ├── Ranking.py ├── codeforces.py ├── standings.py └── upcoming.py ├── commands ├── behavior_settings.py ├── bot.py ├── general_settings.py ├── notification_settings.py ├── settings.py └── widthSelector.py ├── fixNewYearHandles.py ├── logo.png ├── main.py ├── requirements.txt ├── sendBroadcast.py ├── services ├── AnalyseStandingsService.py ├── SummarizingService.py ├── UpcomingService.py └── UpdateService.py ├── telegram ├── Chat.py └── telegram.py ├── userStats ├── gnuplotInstr.txt ├── logUserStats.sh └── plotStats.sh └── utils ├── Spooler.py ├── Table.py ├── database.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | bot.log 2 | __pycache__/ 3 | .* 4 | log/ 5 | backup/ 6 | .vscode/ 7 | userStats/stats.txt 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Codeforces Live Bot 2 | 3 | ![Codeforces Live Bot](https://raw.githubusercontent.com/TobxD/codeforces_live_bot/master/logo.png) 4 | 5 | This is a Telegram bot for users of the [Codeforces](https://codeforces.com) website. 6 | Telegram username: [@codeforces_live_bot](https://t.me/codeforces_live_bot) 7 | 8 | ### Top features: 9 | 10 | - See your and your friends' performance **summarized** after the contest and receive "motivational" advice 11 | - Never miss a contest again: receive **configurable contest reminders** 12 | - Watch your friends' standings from anywhere on a **live updating scoreboard** with rating delta predictions (powered by [CFPredictor](https://codeforces.com/blog/entry/50411)) 13 | - No need to spam F5 anymore while waiting for the system tests: get **notified** when your solution passes (or fails) the **system tests** 14 | - Check your and your friends' ratings in a single nice table (with emojis!) 15 | 16 | Feel free to comment your favourite feature, further improvements, feature requests and general feedback on the [Codeforces Blog post](https://codeforces.com/blog/entry/82669). 17 | 18 | ### Contribution 19 | You can add your own features by creating a pull request. 20 | -------------------------------------------------------------------------------- /codeforces/Ranking.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from utils import util 3 | 4 | class Problem: 5 | def __init__(self): 6 | self.solved = False 7 | self.time = 0 # seconds 8 | self.rejCount = 0 9 | self.preliminary = False 10 | self.upsolved = False 11 | self.upsolvingRejCount = 0 12 | 13 | def toTableRow(self, isSysTesting=False): 14 | def formatRej(rejCount, solved=False): # returns 2 chars describing 15 | if rejCount == 0: 16 | return ("+ " if solved else " ") 17 | return ("+" if solved else "-") + (str(rejCount) if rejCount <= 9 else "∞") 18 | 19 | if self.solved: 20 | return util.formatSeconds(self.time, self.rejCount != 0, longOk=False) 21 | if self.preliminary and isSysTesting: 22 | return " ? " if self.rejCount == 0 else ("?" + formatRej(self.rejCount) + " ") 23 | # "-2+3" : 2xWA in contest, 3xWA in upsolving then solved 24 | return formatRej(self.rejCount) + formatRej(self.upsolvingRejCount, self.upsolved) 25 | 26 | class RankingRow: 27 | def __init__(self, problemCnt): 28 | self.problems : List[Problem] = [Problem() for i in range(problemCnt)] 29 | self.ratingInfo : str = "" #e.g. "2800 -> 3400 (+600)" 30 | self.rank : int = 0 31 | self.isVirtual : bool = False 32 | 33 | def toTableRow(self, handle, isSysTesting=False): 34 | row = {} 35 | row["head"] = (("* " if self.isVirtual or self.rank == 0 else "") 36 | + (handle if len(handle) < 11 else handle[:10] + "…") 37 | + (" (" + str(self.rank) +".)" if self.rank != 0 else "")) 38 | if self.ratingInfo: 39 | row["head2"] = self.ratingInfo 40 | row["body"] = [p.toTableRow(isSysTesting) for p in self.problems] 41 | return row 42 | 43 | class Ranking: 44 | def __init__(self, rows, ratingChanges, problemCnt): 45 | self.ranking : Dict[str, RankingRow] = {} # handle -> RankingRow 46 | self.order : List[str] = [] # ordered list of handles for standings 47 | self.parseRanking(rows, ratingChanges, problemCnt) 48 | 49 | def parseRanking(self, rows, ratingChanges, problemCnt): 50 | def getRatingInfo(handle): 51 | if handle not in ratingChanges: 52 | return "" 53 | (oldR, newR) = ratingChanges[handle] 54 | ratingC = newR-oldR 55 | ratingC = ("+" if ratingC >= 0 else "") + str(ratingC) 56 | return str(oldR) + " -> " + str(newR) + " (" + ratingC + ")" 57 | 58 | for row in rows: 59 | handle = row["party"]["members"][0]["handle"] 60 | if handle not in self.ranking: 61 | self.order.append(handle) 62 | 63 | if row["rank"] != 0: # not upsolving but real (or virtual) participation 64 | rrow = RankingRow(problemCnt) 65 | rrow.rank = row["rank"] 66 | rrow.ratingInfo = getRatingInfo(handle) 67 | rrow.isVirtual = row["party"]["participantType"] == "VIRTUAL" 68 | 69 | for i in range(problemCnt): 70 | sub = row["problemResults"][i] 71 | problem = rrow.problems[i] 72 | problem.solved = sub["points"] > 0 73 | if problem.solved: 74 | problem.time = sub["bestSubmissionTimeSeconds"] 75 | problem.rejCount = sub["rejectedAttemptCount"] 76 | problem.preliminary = sub["type"] == "PRELIMINARY" 77 | 78 | else: # upsolving: 79 | rrow = self.ranking.get(handle, RankingRow(problemCnt)) # get old row or new one if not exist 80 | for i in range(problemCnt): 81 | sub = row["problemResults"][i] 82 | problem = rrow.problems[i] 83 | problem.upsolved = sub["points"] > 0 84 | problem.upsolvingRejCount = sub["rejectedAttemptCount"] 85 | self.ranking[handle] = rrow 86 | 87 | def getRows(self, isSysTesting=False): 88 | return [self.ranking[handle].toTableRow(handle, isSysTesting) for handle in self.order] 89 | -------------------------------------------------------------------------------- /codeforces/codeforces.py: -------------------------------------------------------------------------------- 1 | import requests, urllib, simplejson 2 | import random, time 3 | import queue, threading 4 | from collections import defaultdict 5 | 6 | from utils import database as db 7 | from utils import util 8 | from telegram import Chat 9 | from utils.util import logger, perfLogger 10 | from services import UpdateService 11 | 12 | codeforcesUrl = 'https://codeforces.com/api/' 13 | 14 | contestListLock = threading.Lock() 15 | aktuelleContests = [] # display scoreboard + upcoming 16 | currentContests = [] # display scoreboard 17 | 18 | standingsLock = defaultdict(lambda : threading.Condition()) 19 | globalStandings = {} # contest-id -> {time, standings} 20 | 21 | endTimes = queue.Queue() 22 | for i in range(1): 23 | endTimes.put(-1) 24 | 25 | def sendRequest(method, params, authorized = False, chat = None): 26 | rnd = random.randint(0, 100000) 27 | rnd = str(rnd).zfill(6) 28 | tailPart = method + '?' 29 | 30 | if authorized: 31 | try: 32 | if chat == None or chat.apikey == None or chat.secret == None: 33 | # maybe we don't have apikey so we cannot request friends or smt 34 | return False 35 | params['apiKey'] = str(chat.apikey) 36 | params['time'] = str(int(time.time())) 37 | except Exception as e: 38 | logger.critical("%s", e, exc_info=True) 39 | return False 40 | 41 | for key,val in sorted(params.items()): 42 | tailPart += str(key) + "=" + urllib.parse.quote(str(val)) + "&" 43 | tailPart = tailPart[:-1] 44 | 45 | if authorized: 46 | hsh = util.sha512Hex(rnd + '/' + tailPart + '#' + chat.secret) 47 | tailPart += '&apiSig=' + rnd + hsh 48 | request = codeforcesUrl + tailPart 49 | startWait = time.time() 50 | waitTime = endTimes.get() + 1 - time.time() 51 | if waitTime > 0: 52 | time.sleep(waitTime) 53 | startT = time.time() 54 | try: 55 | r = requests.get(request, timeout=15) 56 | except requests.exceptions.Timeout as errt: 57 | logger.error("Timeout on Codeforces.") 58 | return False 59 | except requests.exceptions.ChunkedEncodingError as e: 60 | logger.error("ChunkedEncodingError on CF: %s", e) 61 | return False 62 | except Exception as e: 63 | logger.critical('Failed to request codeforces: \nexception: %s\n', e, exc_info=True) 64 | return False 65 | finally: 66 | perfLogger.info("cf request " + method + ": {:.3f}s; waittime: {:.3f}".format(time.time()-startT, startT-startWait)) 67 | endTimes.put(time.time()) 68 | if r.status_code != requests.codes.ok: 69 | if r.status_code == 429: 70 | logger.error("too many cf requests... trying again") 71 | return sendRequest(method, params, authorized, chat) 72 | elif r.status_code//100 == 5: 73 | logger.error("Codeforces Http error " + str(r.reason) + " (" + str(r.status_code) + ")") 74 | else: 75 | try: 76 | r = r.json() 77 | handleCFError(request, r, chat) 78 | except simplejson.errors.JSONDecodeError as jsonErr: 79 | logger.critical("no valid json; status code for cf request: " + str(r.status_code) + "\n" + 80 | "this request caused the error:\n" + str(request), 81 | exc_info=True) 82 | return False 83 | else: 84 | try: 85 | r = r.json() 86 | if r['status'] == 'OK': 87 | return r['result'] 88 | else: 89 | logger.critical("Invalid Codeforces request: " + r['comment']) 90 | return False 91 | except Exception as e: #TODO why does CF send an invalid json with http 200? 92 | logger.critical("json decoding failed; status code for cf request: " + str(r.status_code) + "\n" + 93 | "this request caused the error:\n" + str(request) + "\n" + 94 | "text got back:\n" + str(r.text), 95 | exc_info=True) 96 | return False 97 | 98 | def handleCFError(request, r, chat): 99 | if r['status'] == 'FAILED': 100 | #delete nonexisting friends 101 | startS = "handles: User with handle " 102 | endS = " not found" 103 | if r['comment'].startswith(startS) and r['comment'].endswith(endS): 104 | handle = r['comment'][len(startS):-len(endS)] 105 | db.deleteFriend(handle) 106 | return 107 | #remove wrong authentification 108 | if 'Incorrect API key' in r['comment'] or 'Incorrect signature' in r['comment']: 109 | chat.apikey = None 110 | chat.secret = None 111 | chat.sendMessage("Your API-key did not work 😢. Please add a valid key and secret in the settings.") 112 | return 113 | if "contestId: Contest with id" in r['comment'] and "has not started" in r['comment']: 114 | return # TODO fetch new contest start time 115 | if "contestId: Contest with id" in r['comment'] and "not found" in r['comment']: 116 | logger.debug("codeforces error: " + r['comment']) 117 | return 118 | logger.critical("codeforces error: " + str(r['comment']) + "\n" + 119 | "this request caused the error:\n" + (str(request)[:200]), 120 | exc_info=True) 121 | 122 | def getUserInfos(userNameArr): 123 | batchSize = 200 124 | split = [userNameArr[batchSize*i:batchSize*(i+1)] for i in range((len(userNameArr)+batchSize-1)//batchSize)] 125 | res = [] 126 | for part in split: 127 | usrList = ';'.join(part) 128 | logger.debug('requesting info of ' + str(len(part)) + ' users ') 129 | r = sendRequest('user.info', {'handles':usrList}) 130 | if r is False: 131 | return False 132 | res.extend(r) 133 | return res 134 | 135 | def getUserRating(handle): 136 | info = getUserInfos([handle]) 137 | if info == False or len(info) == 0 or "rating" not in info[0]: 138 | return 0 139 | return info[0]["rating"] 140 | 141 | def updateFriends(chat): 142 | p = {'onlyOnline':'false'} 143 | logger.debug('request friends of chat with chat_id ' + str(chat.chatId)) 144 | f = sendRequest("user.friends", p, True, chat) 145 | logger.debug('requesting friends finished') 146 | if f != False: 147 | db.addFriends(chat.chatId, f, chat.notifyLevel) 148 | logger.debug('friends updated for chat ' + str(chat.chatId)) 149 | 150 | 151 | def getAllFriends(chat): 152 | friends = db.getFriends(chat.chatId) 153 | return [f[0] for f in friends] 154 | 155 | def getListFriends(chat): 156 | friends = db.getFriends(chat.chatId, selectorColumn="showInList") 157 | return [f[0] for f in friends] 158 | 159 | def updateStandings(contestId): 160 | global aktuelleContests 161 | logger.debug('updating standings for contest '+str(contestId)+' for all users') 162 | stNew = sendRequest('contest.standings', {'contestId':contestId, 'showUnofficial':True}) 163 | if stNew and "contest" in stNew: 164 | with standingsLock[contestId]: 165 | globalStandings[contestId] = {"time": time.time(), "standings": stNew} 166 | with contestListLock: 167 | aktuelleContests = [stNew["contest"] if stNew["contest"]["id"] == c["id"] else c for c in aktuelleContests] 168 | logger.debug('standings received') 169 | else: 170 | logger.error('standings not updated') 171 | 172 | def getStandings(contestId, handleList, forceRequest=False): 173 | with standingsLock[contestId]: 174 | contestOld = contestId not in globalStandings or globalStandings[contestId] is False or time.time() - globalStandings[contestId]["time"] > 120 175 | toUpd = contestOld or forceRequest 176 | shouldUpdate = False 177 | if toUpd: 178 | if getStandings.isUpdating[contestId]: 179 | standingsLock[contestId].wait() 180 | else: 181 | getStandings.isUpdating[contestId] = True 182 | shouldUpdate = True 183 | 184 | if shouldUpdate: 185 | updateStandings(contestId) 186 | with standingsLock[contestId]: 187 | getStandings.isUpdating[contestId] = False 188 | standingsLock[contestId].notifyAll() 189 | 190 | handleSet = set(handleList) 191 | 192 | with standingsLock[contestId]: 193 | if contestId not in globalStandings or globalStandings[contestId] is False or globalStandings[contestId]["standings"] is False: 194 | return False 195 | allStandings = globalStandings[contestId]["standings"] 196 | allRows = allStandings["rows"] 197 | # filter only users from handleList 198 | rows = [r for r in allRows if r["party"]["members"][0]["handle"] in handleSet] 199 | standings = {} 200 | standings['problems'] = allStandings['problems'] 201 | standings['contest'] = allStandings['contest'] 202 | standings["rows"] = rows 203 | return standings 204 | getStandings.isUpdating = defaultdict(lambda : False) 205 | 206 | def getContestStatus(contest): 207 | startT = contest.get('startTimeSeconds', -1) 208 | if startT >= time.time(): 209 | return 'before' 210 | elif startT + contest['durationSeconds'] >= time.time(): 211 | return 'running' 212 | elif contest['phase'] != 'FINISHED' and startT + contest['durationSeconds'] >= time.time()-5*60*60: 213 | return 'testing' 214 | else: 215 | return 'finished' 216 | 217 | def selectImportantContests(contestList): 218 | global aktuelleContests 219 | global currentContests 220 | 221 | def contestInfos(contest): 222 | endT = contest.get('startTimeSeconds', -1) + contest['durationSeconds'] 223 | status = getContestStatus(contest) 224 | return {'contest':contest, 'duration':contest['durationSeconds'], 'endT':endT, 'status':status} 225 | 226 | contestList = list(map(contestInfos, contestList)) 227 | futureContests = list(filter(lambda c: c['status'] == 'before', contestList)) 228 | contestList = list(filter(lambda c: c['status']!='before', contestList)) 229 | activeShort = list(filter((lambda c: (c['status'] in ['running', 'testing']) and c['duration'] <= 5*60*60), contestList)) 230 | if len(activeShort) > 0: 231 | currentContests = activeShort 232 | else: 233 | currentContests = list(filter(lambda c: c['endT'] >= time.time()-60*60*24*2, contestList)) 234 | if len(currentContests) == 0: 235 | lastFin = max(map(lambda c: c['endT'], contestList)) 236 | currentContests = list(filter(lambda c: c['endT']==lastFin, contestList)) 237 | 238 | aktuelleContests = currentContests + futureContests 239 | currentContests = list(map(lambda c: c['contest'], currentContests)) 240 | aktuelleContests = list(map(lambda c: c['contest'], aktuelleContests)) 241 | 242 | def getCurrentContests(): 243 | with contestListLock: 244 | curC = aktuelleContests 245 | selectImportantContests(curC) 246 | return currentContests 247 | 248 | def getCurrentContestsId(): 249 | return [c['id'] for c in getCurrentContests()] 250 | 251 | def getFutureContests(): 252 | res = [] 253 | with contestListLock: 254 | for c in aktuelleContests: 255 | if c.get('startTimeSeconds', -1) > time.time(): 256 | res.append(c) 257 | return res 258 | 259 | def getFutureAndCurrentContests(): 260 | return aktuelleContests 261 | 262 | class FriendUpdateService (UpdateService.UpdateService): 263 | def __init__(self): 264 | UpdateService.UpdateService.__init__(self, 24*3600) 265 | self.name = "FriendUpdateService" 266 | 267 | def _doTask(self): 268 | logger.debug('starting to update all friends') 269 | for chatId in db.getAllChatPartners(): 270 | updateFriends(Chat.getChat(chatId)) 271 | logger.debug('updating all friends finished') 272 | 273 | class ContestListService (UpdateService.UpdateService): 274 | def __init__(self): 275 | UpdateService.UpdateService.__init__(self, 3600) 276 | self.name = "contestListService" 277 | self._doTask() 278 | 279 | def _doTask(self): 280 | logger.debug('loading current contests') 281 | allContests = sendRequest('contest.list', {'gym':'false'}) 282 | if allContests is False: 283 | logger.error('failed to load current contest - maybe cf is not up') 284 | else: 285 | with contestListLock: 286 | selectImportantContests(allContests) 287 | logger.debug('loading contests finished') 288 | -------------------------------------------------------------------------------- /codeforces/standings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import requests, threading, time 3 | from collections import defaultdict 4 | from typing import TYPE_CHECKING 5 | if TYPE_CHECKING: 6 | from telegram.Chat import Chat 7 | 8 | from commands import bot 9 | from codeforces import codeforces as cf, Ranking 10 | from telegram import telegram as tg 11 | from telegram import Chat 12 | from utils import util 13 | from utils.Table import Table 14 | from utils.util import logger, perfLogger 15 | from utils import database as db 16 | 17 | standingsSentLock = threading.Lock() 18 | standingsSent = defaultdict(lambda : defaultdict(lambda : None)) # [chatId][contest] = (msgId, msg) 19 | 20 | cfPredictorLock = threading.Lock() 21 | handleToRatingChanges = defaultdict(lambda : {}) 22 | cfPredictorLastRequest = defaultdict(lambda : 0) 23 | cfPredictorUrl = "https://cf-predictor.wasylf.xyz/GetNextRatingServlet?contestId=" 24 | 25 | def getRatingChanges(contestId): 26 | with cfPredictorLock: 27 | if time.time() > cfPredictorLastRequest[contestId] + 20: 28 | logger.debug('request rating changes from cf-predictor') 29 | cfPredictorLastRequest[contestId] = time.time() 30 | try: 31 | startT = time.time() 32 | r = requests.get(cfPredictorUrl + str(contestId), timeout=10) 33 | perfLogger.info("cf predictor request {:.3f}s".format(time.time()-startT)) 34 | except requests.exceptions.Timeout as errt: 35 | logger.error("Timeout on CF-predictor.") 36 | return handleToRatingChanges[contestId] 37 | except Exception as e: 38 | logger.critical('Failed to request cf-predictor: \nexception: %s\n', e, exc_info=True) 39 | return handleToRatingChanges[contestId] 40 | if r.status_code != requests.codes.ok: 41 | logger.error("CF-Predictor request failed with code "+ str(r.status_code) + ", "+ str(r.reason)) 42 | return handleToRatingChanges[contestId] 43 | logger.debug('rating changes received') 44 | r = r.json() 45 | if r['status'] != 'OK': 46 | return handleToRatingChanges[contestId] 47 | r = r['result'] 48 | handleToRatingChanges[contestId] = {} 49 | for row in r: 50 | handleToRatingChanges[contestId][row['handle']] = (row['oldRating'], row['newRating']) 51 | cfPredictorLastRequest[contestId] = time.time() 52 | return handleToRatingChanges[contestId] 53 | 54 | def getContestHeader(contest): 55 | msg = contest["name"] + " " 56 | if contest["relativeTimeSeconds"] < contest["durationSeconds"]: 57 | msg += "*"+ util.formatSeconds(contest["relativeTimeSeconds"]) + "* / " 58 | msg += util.formatSeconds(contest["durationSeconds"]) + "\n\n" 59 | elif contest['phase'] != 'FINISHED': 60 | msg += "*TESTING*\n\n" 61 | else: 62 | msg += "*FINISHED*\n\n" 63 | return msg 64 | 65 | 66 | # if !sendIfEmpty and standings are empty then False is returned 67 | def getFriendStandings(chat:Chat, contestId, sendIfEmpty=True): 68 | friends = cf.getListFriends(chat) 69 | if len(friends) == 0: 70 | if sendIfEmpty: 71 | chat.sendMessage(("You have no friends :(\n" 72 | "Please add your API key in the settings or add friends with `/add_friend`.")) 73 | logger.debug("user has no friends -> empty standings") 74 | return False 75 | standings = cf.getStandings(contestId, friends) 76 | if standings == False: 77 | logger.debug("failed to get standings for " + str(friends)) 78 | return False 79 | 80 | msg = getContestHeader(standings["contest"]) 81 | problemNames = [p["index"] for p in standings["problems"]] 82 | ratingChanges = getRatingChanges(contestId) 83 | ranking = Ranking.Ranking(standings["rows"], ratingChanges, len(problemNames)) 84 | tableRows = ranking.getRows(standings["contest"]['phase'] == 'SYSTEM_TEST') 85 | 86 | if not sendIfEmpty and len(tableRows) == 0: 87 | return False 88 | table = Table(problemNames, tableRows) 89 | msg += table.formatTable(chat.width) 90 | return msg 91 | 92 | def sendContestStandings(chat:Chat, contestId, sendIfEmpty=True): 93 | msg = getFriendStandings(chat, contestId, sendIfEmpty=sendIfEmpty) 94 | if msg is False: # CF is down or (standings are emtpy and !sendIfEmpty) 95 | return 96 | def callbackFun(id): 97 | if id != False: 98 | with standingsSentLock: 99 | if standingsSent[chat.chatId][contestId]: 100 | chat.deleteMessage(standingsSent[chat.chatId][contestId][0]) 101 | updateStandingsSent(chat.chatId, contestId, id, msg) 102 | chat.sendMessage(msg, callback=callbackFun) 103 | 104 | def sendStandings(chat:Chat, msg): 105 | bot.setOpenCommandFunc(chat.chatId, None) 106 | contestIds = cf.getCurrentContestsId() 107 | if len(contestIds) > 0: 108 | for c in contestIds: 109 | sendContestStandings(chat, c) 110 | else: 111 | chat.sendMessage("No contests in the last two days 🤷🏻") 112 | 113 | # updates only, if message exists and the standings-message has changed 114 | def updateStandingsForChat(contest, chat:Chat): 115 | with standingsSentLock: 116 | if contest not in standingsSent[chat.chatId]: # only used as speed-up, checked again later 117 | return 118 | msg = getFriendStandings(chat, contest) 119 | if msg is False: 120 | return 121 | logger.debug('update standings for ' + str(chat.chatId) + '!') 122 | with standingsSentLock: 123 | if contest not in standingsSent[chat.chatId]: 124 | return 125 | msgId, oldMsg = standingsSent[chat.chatId][contest] 126 | if tg.shortenMessage(oldMsg) != tg.shortenMessage(msg): 127 | updateStandingsSent(chat.chatId, contest, msgId, msg) 128 | chat.editMessageTextLater(msgId, contest, lambda chat, contest: getFriendStandings(chat, contest)) 129 | 130 | def initDB(): 131 | data = db.getAllStandingsSentList() 132 | with standingsSentLock: 133 | for (chatId, contestId, msgId, msgIdNotf) in data: 134 | if msgId: # maybe only msgIdNotf is set 135 | standingsSent[chatId][contestId] = (msgId, "") 136 | 137 | def updateStandingsSent(chatId, contestId, msgId, msg): 138 | standingsSent[chatId][contestId] = (msgId, msg) 139 | db.saveStandingsSent(chatId, contestId, msgId) 140 | -------------------------------------------------------------------------------- /codeforces/upcoming.py: -------------------------------------------------------------------------------- 1 | import time, datetime 2 | from commands import bot 3 | from utils import util 4 | from codeforces import codeforces as cf 5 | 6 | def getDescription(contest, chat, timez = None): 7 | if timez is None: 8 | timez = chat.timezone 9 | tim = contest['startTimeSeconds'] 10 | 11 | timeLeft = int(contest['startTimeSeconds'] - time.time()) 12 | delta = datetime.timedelta(seconds=timeLeft) 13 | 14 | timeStr = "*" + util.displayTime(tim, timez) 15 | timeStr += '* (in ' + ':'.join(str(delta).split(':')[:2]) + ' hours' + ')' 16 | 17 | res = timeStr.ljust(35) 18 | res += ":\n" + contest['name'] + "" 19 | res += '\n' 20 | return res 21 | 22 | def handleUpcoming(chat, req): 23 | bot.setOpenCommandFunc(chat.chatId, None) 24 | timez = chat.timezone 25 | msg = "" 26 | for c in sorted(cf.getFutureContests(), key=lambda x: x['startTimeSeconds']): 27 | if msg != "": 28 | msg += "\n" 29 | msg += getDescription(c, chat, timez) 30 | chat.sendMessage(msg) 31 | 32 | -------------------------------------------------------------------------------- /commands/behavior_settings.py: -------------------------------------------------------------------------------- 1 | import queue, time, random, re 2 | from collections import defaultdict 3 | import threading 4 | 5 | from utils.util import logger 6 | from commands import settings 7 | from telegram import telegram as tg 8 | 9 | chatsLock = threading.Lock() 10 | 11 | def getChatSettingsButtons(chat): 12 | politeText = ("Polite 😇" if chat.polite else "Rude 😈") 13 | replyText = ("R" if chat.reply else "Not r") + "eceiving funny replies" + ("✅" if chat.reply else "❌") 14 | reminder2hText = "2h Reminder: " + ("active 🔔" if chat.reminder2h else "not active 🔕") 15 | reminder1dText = "1d Reminder: " + ("active 🔔" if chat.reminder1d else "not active 🔕") 16 | reminder3dText = "3d Reminder: " + ("active 🔔" if chat.reminder3d else "not active 🔕") 17 | 18 | buttons = [ 19 | [{"text": politeText, "callback_data": "behavior:polite"}], 20 | [{"text": replyText, "callback_data": "behavior:reply"}], 21 | [{"text": reminder2hText, "callback_data": "behavior:reminder2h"}], 22 | [{"text": reminder1dText, "callback_data": "behavior:reminder1d"}], 23 | [{"text": reminder3dText, "callback_data": "behavior:reminder3d"}], 24 | [{"text": "👈 Back to the Overview", "callback_data": "settings:"}] 25 | ] 26 | return buttons 27 | 28 | def handleChatCallback(chat, data, callback): 29 | answerText = None 30 | with chatsLock: 31 | if data == "polite": 32 | chat.polite = not chat.polite 33 | if chat.polite: 34 | answerText = "👿 This is what I call weakness…" 35 | else: 36 | answerText = "😈 Welcome back to the dark side." 37 | elif data == "reply": 38 | chat.reply = not chat.reply 39 | elif data == "reminder2h": 40 | chat.reminder2h = not chat.reminder2h 41 | elif data == "reminder1d": 42 | chat.reminder1d = not chat.reminder1d 43 | elif data == "reminder3d": 44 | chat.reminder3d = not chat.reminder3d 45 | elif data != "": 46 | logger.critical("no valid bahaviour option: " + data) 47 | 48 | buttons = getChatSettingsButtons(chat) 49 | replyMarkup = settings.getReplyMarkup(buttons) 50 | chat.editMessageText(callback['message']['message_id'], "Change the behavior of the bot:", replyMarkup) 51 | return answerText 52 | -------------------------------------------------------------------------------- /commands/bot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import queue, time, random, re 3 | from collections import defaultdict 4 | import threading 5 | from typing import TYPE_CHECKING 6 | if TYPE_CHECKING: 7 | from telegram.Chat import Chat as ChatClass 8 | 9 | from utils import database as db 10 | from telegram import telegram as tg 11 | from codeforces import codeforces as cf 12 | from utils import util 13 | from utils.util import logger 14 | from services import AnalyseStandingsService, UpcomingService, SummarizingService 15 | from codeforces import standings, upcoming 16 | from commands import settings 17 | from commands import general_settings 18 | from telegram import Chat 19 | 20 | # chatId -> function 21 | openCommandFunc = {} 22 | # Change emoji after serveral invalid commands in last 5h 23 | invalidComTimes = defaultdict(lambda : queue.Queue()) 24 | invalidComTimesLock = threading.Lock() 25 | 26 | def invalidCommandCount(chatId): # how often was 'invalid command' used in last 5h 27 | with invalidComTimesLock: 28 | invalidComTimes[chatId].put(time.time()) 29 | while invalidComTimes[chatId].queue[0] < time.time() - 5*60*60: 30 | invalidComTimes[chatId].get() 31 | return invalidComTimes[chatId].qsize() - 1 32 | 33 | def setOpenCommandFunc(chatId, func): 34 | global openCommandFunc 35 | if func is None: 36 | if chatId in openCommandFunc: 37 | del openCommandFunc[chatId] 38 | else: 39 | openCommandFunc[chatId] = func 40 | 41 | 42 | #------------------------- Rating request -------------------------------------- 43 | def ratingsOfUsers(userNameArr): 44 | if len(userNameArr) == 0: 45 | return ("You have no friends 😭\n" 46 | "Please add your API key in the settings to add codeforces friends automatically or add friends manually with `/add_friend`.") 47 | userInfos = cf.getUserInfos(userNameArr) 48 | if userInfos is False or len(userInfos) == 0: 49 | return "Unknown user in this list" 50 | res = "```\n" 51 | maxNameLen = max([len(user['handle']) for user in userInfos]) 52 | userInfos = sorted(userInfos, key= lambda k: k.get('rating', 0), reverse=True) 53 | for user in userInfos: 54 | rating = user.get('rating', 0) 55 | res += util.getUserSmiley(rating) + " " + user['handle'].ljust(maxNameLen) + ': ' + str(rating) + '\n' 56 | res += "```" 57 | return res 58 | 59 | def handleRatingRequestCont(chat, handleStr): 60 | handleStr = util.cleanString(handleStr) 61 | handles = handleStr.split(',') 62 | handles = [util.cleanString(h) for h in handles] 63 | chat.sendMessage(ratingsOfUsers(handles)) 64 | setOpenCommandFunc(chat.chatId, None) 65 | 66 | def handleRatingRequest(chat, req): 67 | setOpenCommandFunc(chat.chatId, handleRatingRequestCont) 68 | chat.sendMessage("Codeforces handle(s), comma seperated:") 69 | 70 | def handleFriendRatingsRequest(chat, req): 71 | setOpenCommandFunc(chat.chatId, None) 72 | chat.sendMessage(ratingsOfUsers(cf.getAllFriends(chat))) 73 | 74 | # ----- Add Friend ----- 75 | def handleAddFriendRequestCont(chat, req): 76 | handles = [util.cleanString(s) for s in req.split(',')] 77 | resMsg = [] 78 | didnotwork = [] 79 | for handle in handles: 80 | userInfo = cf.getUserInfos([handle]) 81 | if userInfo == False or len(userInfo) == 0 or "handle" not in userInfo[0]: 82 | didnotwork.append("`" + handle + "`") 83 | else: 84 | user = userInfo[0] 85 | db.addFriends(chat.chatId, [user['handle']], chat.notifyLevel) 86 | rating = user.get('rating', 0) 87 | resMsg.append(util.getUserSmiley(rating) + " User `" + user['handle'] + "` with rating " + str(rating) + " added.") 88 | if len(didnotwork) > 0: 89 | resMsg.append("👻 No user" + ("" if len(didnotwork) == 1 else "s") + " with handle " + ", ".join(didnotwork) + "! Please try again for those:") 90 | else: 91 | setOpenCommandFunc(chat.chatId, None) 92 | chat.sendMessage("\n".join(resMsg)) 93 | 94 | def handleAddFriendRequest(chat, req): 95 | setOpenCommandFunc(chat.chatId, handleAddFriendRequestCont) 96 | chat.sendMessage("Codeforces handle(s), comma seperated:") 97 | 98 | # ----- Remove Friend ----- 99 | def handleRemoveFriendRequestCont(chat, req): 100 | handles = [util.cleanString(s) for s in req.split(',')] 101 | userInfos = cf.getUserInfos(handles) 102 | if userInfos == False or len(userInfos) == 0 or "handle" not in userInfos[0]: 103 | chat.sendMessage("👻 No user with this handle!") 104 | else: 105 | for user in userInfos: 106 | if "handle" in user: 107 | db.deleteFriendOfUser(user['handle'], chat.chatId) 108 | chat.sendMessage("💀 User `" + user['handle'] + "` was removed from your friends. If this is one of your Codeforces friends, they will be added automatically again in case you added your API-key. If so, just disable notifications for this user in the settings.") 109 | setOpenCommandFunc(chat.chatId, None) 110 | 111 | def handleRemoveFriendRequest(chat, req): 112 | setOpenCommandFunc(chat.chatId, handleRemoveFriendRequestCont) 113 | chat.sendMessage("Codeforces handle(s), comma seperated:") 114 | 115 | #------ Start ------------- 116 | def handleStart(chat, text): 117 | setOpenCommandFunc(chat.chatId, general_settings.handleSetTimezone) 118 | msg = ("🔥*Welcome to the Codeforces Live Bot!*🔥\n\n" 119 | "You will receive reminders for upcoming Codeforces Contests. Please tell me your *timezone* so that " 120 | "the contest start time will be displayed correctly. So text me the name of the city you live in, for example " 121 | "'Munich'.") 122 | if chat.chatId.startswith('-'): # group chat 123 | msg += "\nAs you are in a group, be sure to *reply* to one of my messages so that I receive your text.\n\n*Your city:*" 124 | chat.sendMessage(msg) 125 | 126 | def sendSetupFinished(chat:ChatClass): 127 | friends = db.getFriends(chat.chatId) 128 | friendsTotal = len(friends) if friends else 0 129 | setOpenCommandFunc(chat.chatId, None) 130 | msg = ("*Setup Completed*\n\n" 131 | "You completed the bot setup, now feel free to use the bot.\n" 132 | "Your current settings are:\n" 133 | f"Timezone: {util.escapeMarkdown(chat.timezone)}\n" 134 | f"Handle: {util.formatHandle(chat.handle) if chat.handle else '❌'}\n" 135 | f"API key added: {'✅' if chat.apikey else '❌'}\n" 136 | f"Friends: {friendsTotal}\n" 137 | "\nLearn what the bot can do with /help.\n" 138 | "Also check out the /settings.") 139 | chat.sendMessage(msg) 140 | 141 | #-------- HELP ------------ 142 | def handleHelp(chat, text): 143 | msg = ("🔥*Codeforces Live Bot*🔥\n\n" 144 | + "With this bot you can:\n" 145 | + "• receive reminders about upcoming _Codeforces_ contest\n" 146 | + "• list upcoming _Codeforces_ contest via /upcoming\n" 147 | + "• see the current contest standings of your friends via /current\_standings\n" 148 | + "• receive notifications if your friends solve tasks - during the contest or in the upsolving\n" 149 | + "• look at the leaderboard of your friends via /friend\_ratings\n" 150 | + "• get the current rating of a specific user with /rating\n" 151 | + "• manage your friends with /add\_friend and /remove\_friend\n" 152 | + "• import your Codeforces friends by adding a Codeforces API key in /settings\n" 153 | + "• configure your time zone, contest reminders, table width and more in /settings\n" 154 | + "• modify the bot's behaviour (politeness, replies, …) in /settings\n" 155 | + "\nWe use the following ranking system:\n" 156 | + "• " + util.getUserSmiley(2400) + ": rating ≥ 2400\n" 157 | + "• " + util.getUserSmiley(2100) + ": rating ≥ 2100\n" 158 | + "• " + util.getUserSmiley(1900) + ": rating ≥ 1900\n" 159 | + "• " + util.getUserSmiley(1600) + ": rating ≥ 1600\n" 160 | + "• " + util.getUserSmiley(1400) + ": rating ≥ 1400\n" 161 | + "• " + util.getUserSmiley(1200) + ": rating ≥ 1200\n" 162 | + "• " + util.getUserSmiley(1199) + ": rating < 1200\n" 163 | ) 164 | if chat.chatId.startswith('-'): # group chat 165 | msg += "\n\nAs you are in a group, be sure to *reply* to one of my messages so that I receive your text." 166 | chat.sendMessage(msg) 167 | 168 | # ------ Other -------- 169 | def invalidCommand(chat, msg): 170 | emoji = ["😅", "😬", "😑", "😠", "😡", "🤬"] 171 | c = invalidCommandCount(chat.chatId) 172 | chat.sendMessage("Invalid command!" + ("" if chat.polite else emoji[min(len(emoji)-1, c)])) 173 | 174 | def randomMessage(chat, msg): 175 | provocations = [ 176 | "Better watch your mouth☝🏻", 177 | "What are you talking?", 178 | "Stop it! \nTomorrow 12:00\n*1 on 1 on Codeforces*\nwithout cheatsheet!\nIf you dare…", 179 | "Watch out!" 180 | ] 181 | funnyComments = [ 182 | "Ok.", 183 | "I will consider that next time", 184 | "Good point!", 185 | "Haha lol😂", 186 | "🤔", 187 | "🤨", 188 | "WTF", 189 | "No, are you stupid?", 190 | "No way!", 191 | "I didn't get that, can you please repeat it?", 192 | "Sure.", 193 | "Why not", 194 | "Are you sure?", 195 | "No! Don't leave me!😢 The insults after the last contest were just a joke. " + 196 | "I didn't mean to hurt you. Pleeeaasee stay! " + 197 | "I was always kind to you, provided you with the latest contest results and even had a uptime > 0! " + 198 | "Forgive me, please!\n" + 199 | "Ok, apparently you have blocked me now, so I'm gonna delete all your data...\n\n" + 200 | "EDIT: sry, wrong chat" 201 | ] + provocations 202 | 203 | if re.match(r'.*\bbot\b', msg.lower()): # msg contains the word 'bot' 204 | if random.randint(0,1) == 0: 205 | chat.sendMessage(provocations[random.randint(0,len(provocations)-1)]) 206 | elif random.randint(0,6) == 0: #random comment 207 | chat.sendMessage(funnyComments[random.randint(0,len(funnyComments)-1)]) 208 | 209 | def noCommand(chat, msg): 210 | if chat.chatId in openCommandFunc: 211 | openCommandFunc[chat.chatId](chat, msg) 212 | elif chat.reply: 213 | if msg.startswith("/"): 214 | invalidCommand(chat, msg) 215 | elif not chat.polite: 216 | randomMessage(chat, msg) 217 | 218 | #----- 219 | def handleMessage(chat, text): 220 | logger.info("-> " + text + " <- (" + ((chat.handle + ": ") if chat.handle else "") + str(chat.chatId) + ")") 221 | text = text.replace("@codeforces_live_bot", "") 222 | text = text.replace("@codeforces_live_testbot", "") 223 | msgSwitch = { 224 | "/start": handleStart, 225 | "/rating": handleRatingRequest, 226 | "/friend_ratings": handleFriendRatingsRequest, 227 | "/add_friend": handleAddFriendRequest, 228 | "/remove_friend": handleRemoveFriendRequest, 229 | "/settings": settings.handleSettings, 230 | "/current_standings": standings.sendStandings, 231 | "/upcoming": upcoming.handleUpcoming, 232 | "/help": handleHelp 233 | } 234 | func = msgSwitch.get(util.cleanString(text), noCommand) 235 | func(chat, text) 236 | 237 | def initContestServices(): 238 | Chat.initChats() 239 | standings.initDB() 240 | services = [ 241 | cf.ContestListService(), 242 | cf.FriendUpdateService(), 243 | AnalyseStandingsService.AnalyseStandingsService(), 244 | UpcomingService.UpcomingService(), 245 | SummarizingService.SummarizingService() 246 | ] 247 | for service in services: 248 | service.start() 249 | 250 | def startTestingMode(): 251 | tg.testFlag = True 252 | initContestServices() 253 | while True: 254 | msg = input() 255 | handleMessage(Chat.getChat('0'), msg) 256 | 257 | def startTelegramBot(): 258 | initContestServices() 259 | tg.TelegramUpdateService().start() 260 | while True: 261 | msg = input() 262 | handleMessage(Chat.getChat('0'), msg) 263 | -------------------------------------------------------------------------------- /commands/general_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | from typing import TYPE_CHECKING 4 | if TYPE_CHECKING: 5 | from telegram.Chat import Chat as ChatClass 6 | 7 | from utils import database as db 8 | from telegram import telegram as tg 9 | from codeforces import codeforces as cf 10 | from utils import util 11 | from utils.util import logger 12 | from commands import bot 13 | from telegram import Chat 14 | from commands import settings 15 | 16 | # "setup": setup.handleSetupCallback, 17 | #funs[pref](chat, suff, callback) 18 | # ---- General Setup buttons ---- 19 | def handleSetupCallback(chat, data, callback): 20 | if data == "": 21 | showSetupPage(chat, data, callback) 22 | else: 23 | funs = { 24 | 'timezone': handleChangeTimezone, 25 | 'handle': handleSetUserHandlePrompt, 26 | 'apikey': handleSetAuthorization, 27 | } 28 | if data not in funs: 29 | logger.critical("wrong setup data: " + str(data)) 30 | else: 31 | return funs[data](chat) 32 | 33 | def showSetupPage(chat, data, callback): 34 | buttons = [ 35 | [{"text": "Set timezone", "callback_data": "general:timezone"}], 36 | [{"text": "Set your handle", "callback_data": "general:handle"}], 37 | [{"text": "Set your api key", "callback_data": "general:apikey"}], 38 | [{"text": "Set displayed table width", "callback_data": "width:"}], 39 | [{"text": "👈 Back to the Overview", "callback_data": "settings:"}] 40 | ] 41 | replyMarkup = settings.getReplyMarkup(buttons) 42 | chat.editMessageText(callback['message']['message_id'], "General settings:", replyMarkup) 43 | 44 | # ---- Set User Handle ------ 45 | def handleSetUserHandlePrompt(chat, msg=None): 46 | bot.setOpenCommandFunc(chat.chatId, handleSetUserHandle) 47 | chat.sendNotification("Please enter your Codeforces handle:") 48 | 49 | def handleSetUserHandle(chat:ChatClass, handle): 50 | handle = util.cleanString(handle) 51 | if util.cleanString(handle) == 'no': 52 | bot.sendSetupFinished(chat) 53 | return 54 | userInfos = cf.getUserInfos([handle]) 55 | if userInfos == False or len(userInfos) == 0 or "handle" not in userInfos[0]: 56 | chat.sendMessage("👻 No user with this handle! Try again:") 57 | else: 58 | bot.setOpenCommandFunc(chat.chatId, None) 59 | chat.handle = userInfos[0]['handle'] 60 | db.addFriends(chat.chatId, [userInfos[0]['handle']], chat.notifyLevel) 61 | rating = userInfos[0].get('rating', 0) 62 | chat.sendNotification("Welcome `" + userInfos[0]['handle'] + "`. Your current rating is " + str(rating) + " " + util.getUserSmiley(rating) + ".") 63 | if chat.apikey is None: 64 | chat.sendNotification("Do you want import your friends from Codeforces? Then, I need your Codeforces API key.") 65 | handleSetAuthorization(chat, "") 66 | 67 | # ------- Add API KEY ----- 68 | def handleAddSecret(chat, secret): 69 | if not secret.isalnum(): 70 | chat.sendMessage("Your API-secret is incorrect, it may only contain alphanumerical letters. Please try again:") 71 | return 72 | chat.secret = secret 73 | bot.setOpenCommandFunc(chat.chatId, None) 74 | logger.debug('new secret added for user ' + str(chat.chatId)) 75 | chat.sendMessage("Key added. Now fetching your codeforces friends...") 76 | cf.updateFriends(chat) 77 | bot.sendSetupFinished(chat) 78 | 79 | def handleAddKey(chat, key): 80 | if util.cleanString(key) == "no": 81 | bot.sendSetupFinished(chat) 82 | return 83 | if not key.isalnum(): 84 | chat.sendMessage("Your API-key is incorrect, it may only contain alphanumerical letters. Please try again:") 85 | return 86 | chat.apikey = key 87 | bot.setOpenCommandFunc(chat.chatId, handleAddSecret) 88 | chat.sendMessage("Enter your secret:") 89 | 90 | def handleSetAuthorization(chat, req=None): 91 | bot.setOpenCommandFunc(chat.chatId, handleAddKey) 92 | chat.sendNotification("Go to https://codeforces.com/settings/api and generate a key.\n" 93 | + "Then text me two seperate messages - the first one containing the key and the second one containing the secret.\n" 94 | + "If you do not want to add your secret now, text me _no_ and don't forget to add your secret later in the settings.") 95 | 96 | # ------- Time zone ------------- 97 | def handleChangeTimezone(chat, text=None): 98 | bot.setOpenCommandFunc(chat.chatId, handleSetTimezone) 99 | chat.sendMessage("Setting up your time zone... Please enter the city you live in:") 100 | 101 | def handleSetTimezone(chat:ChatClass, tzstr): 102 | tzstr = tzstr.lstrip().rstrip() 103 | tz = util.getTimeZone(tzstr) 104 | if not tz: 105 | chat.sendMessage("Name lookup failed. Please use a different city:") 106 | else: 107 | bot.setOpenCommandFunc(chat.chatId, None) 108 | chat.timezone = tz 109 | chat.sendNotification("Timezone set to '" + util.escapeMarkdown(tz) + "'") 110 | # if in setup after start, ask for user handle 111 | if chat.handle is None: 112 | # use notification so that the order is correct 113 | chat.sendNotification("Now I need *your* handle. Answer 'no' if you don't want to add your handle.") 114 | handleSetUserHandlePrompt(chat, "") 115 | -------------------------------------------------------------------------------- /commands/notification_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | from typing import TYPE_CHECKING 4 | if TYPE_CHECKING: 5 | from telegram.Chat import Chat 6 | from utils import database as db 7 | from telegram import telegram as tg 8 | from codeforces import codeforces as cf 9 | from utils import util 10 | from utils.util import logger 11 | from commands import bot, settings 12 | 13 | # constants 14 | NOTIFY_LEVEL_DESC_SHORT = ["Disabled", "Only Scoreboard", "Scoreboard + SysTest fail", "Scb + SysTest + Upsolve", "All Notifications"] 15 | NOTIFY_LEVEL_DESC = ["Disabled", "Only Scoreboard", "Scoreboard + System Test Fail Notf.", "Scoreboard + System Test Fail Notf. + Upsolving Notf.", "All Notifications"] 16 | NOTIFY_SETTINGS_DESC = ["Show on Scoreboard", "Notify System Test Failed", "Upsolving Notf.", "In Contest Notifications"] 17 | 18 | def getUserButtons(handle, notSettingsRow, page): 19 | return [ 20 | [{"text": handle, "callback_data": "friend_notf:handlepress"}], 21 | [ {"text": '✅' if notSettingsRow[i] else '❌', "callback_data": f"friend_notf:toggle-{handle};{i};{page}"} 22 | for i in range(len(notSettingsRow))] 23 | ] 24 | 25 | def getButtonRows(chat, page=0): 26 | PAGE_SIZE = 10 27 | friends = db.getFriends(chat.chatId) # [(handle, ...notRow)] 28 | if friends == None: 29 | chat.sendMessage("You don't have any friends :(") 30 | return [], "" 31 | 32 | buttons = [] 33 | for i in range(PAGE_SIZE*page, min(len(friends), PAGE_SIZE*(page+1))): 34 | handle = friends[i][0] 35 | notRow = friends[i][1:] 36 | buttons += getUserButtons(handle, notRow, page) 37 | 38 | pagesCount = (len(friends)+PAGE_SIZE-1) // PAGE_SIZE 39 | btnNextPage = {"text": "Next Page 👉", "callback_data": "friend_notf:config-page" + str(page+1)} 40 | btnPrevPage = {"text": "👈 Previous Page", "callback_data": "friend_notf:config-page" + str(page-1)} 41 | btnManyPagesR = {"text": "(10) 👉👉", "callback_data": "friend_notf:config-page" + str(page+10)} 42 | btnManyPagesL = {"text": "👈👈 (10)", "callback_data": "friend_notf:config-page" + str(page-10)} 43 | if pagesCount > 1: 44 | if page == 0: 45 | buttons.append([btnNextPage]) 46 | elif page == pagesCount-1: 47 | buttons.append([btnPrevPage]) 48 | else: 49 | buttons.append([btnPrevPage, btnNextPage]) 50 | # skip many pages: 51 | if page >= 10 or pagesCount - 1 - page >= 10: 52 | if not page >= 10: 53 | buttons.append([btnManyPagesR]) 54 | elif not pagesCount-1 - page >= 10: 55 | buttons.append([btnManyPagesL]) 56 | else: 57 | buttons.append([btnManyPagesL, btnManyPagesR]) 58 | buttons.append([{"text":"👈 Back to the Notification Settings", "callback_data":"friend_notf:"}]) 59 | title = (f"Page {page+1} / {pagesCount}\n" 60 | "With the buttons you change (in this order):\n• " + ('\n• '.join(NOTIFY_SETTINGS_DESC))) 61 | return buttons, title 62 | 63 | 64 | def toggleFriendsSettings(chat:Chat, handleIdAndPage): 65 | [handle, notId, page] = handleIdAndPage.split(';') 66 | notId = int(notId) 67 | page = int(page) 68 | gesetzt = db.toggleFriendSettings(chat.chatId, handle, notId) 69 | notf = f"{NOTIFY_SETTINGS_DESC[notId]}: {'✅ enabled' if gesetzt else '❌ disabled'}" 70 | buttons, title = getButtonRows(chat, page) 71 | return notf, buttons, title 72 | 73 | 74 | def getMenu(chat:Chat): 75 | friends = db.getFriends(chat.chatId) # [(handle, ...notifySettingsRow)] 76 | friendsTotal = len(friends) 77 | friendsCountWithLvl = [len([f for f in friends if f[i+1]]) for i in range(len(NOTIFY_SETTINGS_DESC))] 78 | 79 | def activeState(id): 80 | return '✅' if id <= chat.notifyLevel else '❌' 81 | def condBold(id): 82 | return '*' if id <= chat.notifyLevel else '' 83 | 84 | title = (f"*Change Your Friends Settings*\n" 85 | "\nThere are 4 different settings:\n" 86 | f"{condBold(1)}1. Show user in the scoreboard {condBold(1)}{activeState(1)}\n" 87 | f"{condBold(2)}2. Receive _System Tests failed_ notifications for user {condBold(2)}{activeState(2)}\n" 88 | f"{condBold(3)}3. Receive upsolving notifications for user (has solved a task after the contest) {condBold(3)}{activeState(3)}\n" 89 | f"{condBold(4)}4. Receive solving notifications during the contest {condBold(4)}{activeState(4)}\n" 90 | "\nYou can specify a global notifcation level and override it for specific users if you want.\n" 91 | "Currently, your settings are:\n" 92 | f"For *{friendsCountWithLvl[0]}* / {friendsTotal} friends: see them in the standings\n" 93 | f"For *{friendsCountWithLvl[1]}* / {friendsTotal} friends: receive 'system test failed' notifications\n" 94 | f"For *{friendsCountWithLvl[2]}* / {friendsTotal} friends: receive upsolving notifications\n" 95 | f"For *{friendsCountWithLvl[3]}* / {friendsTotal} friends: in contest notifications\n" 96 | f"\nYour *global notification level* is\n_({chat.notifyLevel}) {NOTIFY_LEVEL_DESC[chat.notifyLevel]}_\nChange it here:") # TODO bar with pointer 97 | 98 | buttons = [ 99 | [{"text": f"({chat.notifyLevel}) {NOTIFY_LEVEL_DESC_SHORT[chat.notifyLevel]}", "callback_data": "friend_notf:hoverNotifyLvl"}], 100 | [ 101 | {"text": "⬅️" if chat.notifyLevel -1 >= 0 else " ", "callback_data": "friend_notf:decNotifyLvl"}, 102 | {"text": "➡️" if chat.notifyLevel +1 < len(NOTIFY_LEVEL_DESC) else " ", "callback_data": "friend_notf:incNotifyLvl"} 103 | ], 104 | [{"text": "Reset All to Level", "callback_data": "friend_notf:reset"}], 105 | [{"text": "Configure Individually", "callback_data": "friend_notf:config-page0"}], 106 | [{"text": "👈 Back to the Overview", "callback_data": "settings:"}] 107 | ] 108 | return buttons, title 109 | 110 | def handleChatCallback(chat:Chat, data, callback): 111 | answerToast = None 112 | if data == "": 113 | buttons, title = getMenu(chat) 114 | elif data.startswith("config-page"): 115 | page = int(data[len("config-page"):]) 116 | buttons, title = getButtonRows(chat, page) 117 | elif data.startswith("toggle-"): 118 | answerToast, buttons, title = toggleFriendsSettings(chat, data[len("toggle-"):]) 119 | elif data == "handlepress": 120 | return 121 | elif data == "decNotifyLvl": 122 | if chat.notifyLevel == 0: 123 | return "You already disabled all friend notifications" 124 | else: 125 | db.updateToNotifyLevel(chat.chatId, chat.notifyLevel-1, chat.notifyLevel,) 126 | chat.notifyLevel -= 1 127 | buttons, title = getMenu(chat) 128 | elif data == "incNotifyLvl": 129 | if chat.notifyLevel >= len(NOTIFY_LEVEL_DESC) -1: 130 | return "You already enabled all friend notifications" 131 | else: 132 | db.updateToNotifyLevel(chat.chatId, chat.notifyLevel+1, chat.notifyLevel) 133 | chat.notifyLevel += 1 134 | buttons, title = getMenu(chat) 135 | elif data == "hoverNotifyLvl": 136 | return 137 | elif data == "reset": 138 | db.updateToNotifyLevel(chat.chatId, chat.notifyLevel, reset=True) 139 | buttons, title = getMenu(chat) 140 | else: 141 | logger.critical("no valid bahaviour option for notify settings: " + data) 142 | 143 | replyMarkup = settings.getReplyMarkup(buttons) 144 | msgId = callback['message']['message_id'] 145 | chat.editMessageText(msgId, title, replyMarkup) 146 | return answerToast 147 | -------------------------------------------------------------------------------- /commands/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | from typing import TYPE_CHECKING 4 | if TYPE_CHECKING: 5 | from telegram.Chat import Chat 6 | 7 | from utils import database as db 8 | from telegram import telegram as tg 9 | from codeforces import codeforces as cf 10 | from utils import util 11 | from utils.util import logger 12 | from commands import bot 13 | from telegram import Chat as ChatFunctions 14 | from commands import general_settings, notification_settings as notify_settings, behavior_settings, widthSelector 15 | 16 | 17 | 18 | def handleSettings(chat:Chat, req): 19 | def deleteOldSettings(chat:Chat): 20 | if chat.settings_msgid: 21 | chat.deleteMessage(chat.settings_msgid) 22 | chat.settings_msgid = None 23 | def callbackSetMsgId(id): 24 | if id != False: 25 | chat.settings_msgid = id 26 | 27 | bot.setOpenCommandFunc(chat.chatId, None) 28 | deleteOldSettings(chat) 29 | buttons = getSettingsButtons() 30 | replyMarkup = getReplyMarkup(buttons) 31 | chat.sendMessage("What do you want to change?", replyMarkup, callbackSetMsgId) 32 | 33 | def getReplyMarkup(inlineKeyboard): 34 | replyMarkup = {"inline_keyboard": inlineKeyboard} 35 | jsonReply = json.dumps(replyMarkup) 36 | return jsonReply 37 | 38 | def getSettingsButtons(): 39 | buttons = [ 40 | [{"text": "General Settings", "callback_data": "general:"}], 41 | [{"text": "Behavior Settings", "callback_data": "behavior:"}], 42 | [{"text": "Friend Notification Settings", "callback_data": "friend_notf:"}], 43 | ] 44 | return buttons 45 | 46 | def handleCallbackQuery(callback): 47 | chat = ChatFunctions.getChat(str(callback['message']['chat']['id'])) 48 | data = callback['data'] 49 | 50 | if not ":" in data: 51 | logger.critical("Invalid callback data: " + data) 52 | return 53 | 54 | pref, suff = data.split(":", 1) 55 | funs = { 56 | "settings": handleSettingsCallback, 57 | "general": general_settings.handleSetupCallback, 58 | "behavior": behavior_settings.handleChatCallback, 59 | "friend_notf": notify_settings.handleChatCallback, 60 | "width": widthSelector.handleWidthChange, 61 | } 62 | if pref not in funs: 63 | logger.critical("Invalid callback prefix: "+ pref + ", data: "+ suff) 64 | else: 65 | retMsg = funs[pref](chat, suff, callback) 66 | tg.requestSpooler.put(lambda : tg.sendAnswerCallback(chat.chatId, callback['id'], retMsg), priority=0) 67 | 68 | def handleSettingsCallback(chat:Chat, data, callback): 69 | if data != "": 70 | logger.critical("Invalid callback settings data: " + data) 71 | else: 72 | chat.editMessageText(callback['message']['message_id'], "What do you want to change?", getReplyMarkup(getSettingsButtons())) 73 | -------------------------------------------------------------------------------- /commands/widthSelector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from telegram.Chat import Chat 5 | 6 | from utils.Table import Table 7 | from utils.util import logger 8 | from commands import settings 9 | from telegram import telegram as tg 10 | 11 | 12 | def handleWidthChange(chat:Chat, data, callback): 13 | def getMsg(width): 14 | table = Table(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M"], []) 15 | text = ("Configure how many columns you want to display. Choose the maximum " 16 | "value which still displays the table correctly.\n" 17 | "Note: This setting is global for the chat, make sure it works on all of " 18 | "your devices.\n") 19 | text += table.formatTable(chat.width) 20 | buttons = [ 21 | [{"text": "-", "callback_data": "width:-"}, {"text": "+", "callback_data": "width:+"}], 22 | [{"text":"👈 Back to General Settings", "callback_data":"general:"}], 23 | ] 24 | return text, buttons 25 | 26 | warningText = None 27 | if data == '': 28 | pass # initial call, don't change 29 | elif data == '+': 30 | if chat.width == 12: 31 | warningText = "❗️You reached the maximum table width❗️" 32 | else: 33 | chat.width = chat.width + 1 34 | elif data == '-': 35 | if chat.width == 4: 36 | warningText = "❗️You reached the minimum table width❗️" 37 | else: 38 | chat.width = chat.width - 1 39 | else: 40 | logger.critical("unrecognized data at handle width: " + str(data)) 41 | 42 | if warningText: 43 | return warningText 44 | else: 45 | text, buttons = getMsg(chat.width) 46 | chat.editMessageText(callback["message"]["message_id"], text, 47 | settings.getReplyMarkup(buttons)) 48 | -------------------------------------------------------------------------------- /fixNewYearHandles.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | import urllib 3 | import requests 4 | from utils import database as db 5 | 6 | renamed = {} 7 | 8 | def renameHandle(oldHandle, newHandle): 9 | query = "update ignore friends set friend=%s where friend=%s" 10 | db.insertDB(query, (newHandle, oldHandle)) 11 | query2 = "update tokens set handle=%s where handle=%s" 12 | db.insertDB(query2, (newHandle, oldHandle)) 13 | query3 = "delete from friends where friend=%s" 14 | db.insertDB(query3, (oldHandle, )) 15 | renamed[oldHandle] = newHandle 16 | 17 | def requestHandles(handleList): 18 | handleStr = ";".join(handleList) 19 | codeforcesUrl = 'https://codeforces.com/api/' 20 | reqUrl = codeforcesUrl + "user.info?handles=" + urllib.parse.quote(handleStr) 21 | res = requests.get(reqUrl).json() 22 | sleep(1) 23 | startS = "handles: User with handle " 24 | endS = " not found" 25 | if 'comment' in res and res['comment'].startswith(startS) and res['comment'].endswith(endS): 26 | handle = res['comment'][len(startS):-len(endS)] 27 | #db.deleteFriend(handle) 28 | return handle 29 | else: 30 | return None 31 | 32 | def getNewName(handle): 33 | prefix = "https://codeforces.com/profile/" 34 | res = requests.get(prefix + handle) 35 | sleep(1) 36 | if res.url != "https://codeforces.com/": 37 | newHandle = res.url[len(prefix):] 38 | print(handle, "->", newHandle) 39 | renameHandle(oldHandle=handle, newHandle=newHandle) 40 | return newHandle 41 | else: 42 | print(handle, "not found") 43 | return None 44 | 45 | def getAllHandles(): 46 | print("requesting all friends from DB") 47 | friends = db.getAllFriends() 48 | print("requesting all users from DB") 49 | query = "select distinct handle from tokens" 50 | users = [x[0] for x in db.queryDB(query, ()) if x[0] != None] 51 | res = list(set(friends + users)) 52 | print(res[:100]) 53 | return res 54 | 55 | def fixBatch(handles): 56 | curLen = len(handles) 57 | while True: 58 | failed = requestHandles(handles) 59 | if failed is not None: 60 | getNewName(failed) 61 | handles = [h for h in handles if h not in renamed] 62 | newLen = len(handles) 63 | if newLen == curLen or newLen == 0: 64 | break 65 | curLen = newLen 66 | 67 | handles = getAllHandles() 68 | batchSize = 200 69 | split = [handles[batchSize*i:batchSize*(i+1)] for i in range((len(handles)+batchSize-1)//batchSize)] 70 | res = [] 71 | batchNum = 1 72 | for part in split: 73 | print("starting batch", batchNum, "/", len(split)) 74 | fixBatch(part) 75 | batchNum += 1 76 | 77 | print(renamed) 78 | print("done") -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobxD/codeforces_live_bot/bd78d00dd3ccbd0559db973b9d007105838e7181/logo.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | from commands import bot 3 | from utils import util 4 | 5 | util.initLogging() 6 | 7 | # with -t the testing mode gets enabled, 8 | # which only communicates over stdin/stdout instead of telegram 9 | if "-t" in sys.argv: 10 | bot.startTestingMode() 11 | elif "--production" in sys.argv: 12 | bot.startTelegramBot() 13 | else: 14 | print("invalid options") 15 | os._exit(0) 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | geopy 2 | mysql-connector-python 3 | numpy 4 | pytz 5 | requests 6 | simplejson 7 | timezonefinder 8 | -------------------------------------------------------------------------------- /sendBroadcast.py: -------------------------------------------------------------------------------- 1 | import sys, os, time, re, datetime 2 | from telegram import Chat 3 | from utils import util 4 | from utils.util import logger 5 | from telegram import telegram as tg 6 | 7 | util.initLogging() 8 | Chat.initChats() 9 | 10 | if len(sys.argv) != 1: 11 | try: 12 | msgText = open(sys.argv[1]).read()[:-1] # discard trailing newline 13 | logger.info("sending broadcast message:\n" + msgText) 14 | for chatId in Chat.chats: 15 | msg = msgText 16 | chat = Chat.chats[chatId] 17 | while True: 18 | m = re.search("\[%t [0-9]*\]", msg) 19 | if m is None: 20 | break 21 | tInSec = int(m.group()[4: -1]) 22 | timeLeft = int(tInSec - time.time()) 23 | delta = datetime.timedelta(seconds=timeLeft) 24 | dTime = f"*{util.displayTime(tInSec, chat.timezone)}* (in {':'.join(str(delta).split(':')[:2])} hours)" 25 | msg = msg[:m.span()[0]] + dTime + msg[m.span()[1]:] 26 | #print(f"chat: {chatId}: {msg}") 27 | chat.sendMessage(msg) 28 | time.sleep(1) 29 | while tg.requestSpooler._q[0].qsize() > 0: 30 | try: 31 | print(f"waiting {tg.requestSpooler._q[0].qsize()}") 32 | except Exception as ex: 33 | print(ex) 34 | time.sleep(1) 35 | logger.info("sending broadcasts finished") 36 | except Exception as e: 37 | logger.critical(str(e)) 38 | else: 39 | logger.error("wrong command line options\nusage: python3 sendBroadcast.py ") 40 | os._exit(0) 41 | -------------------------------------------------------------------------------- /services/AnalyseStandingsService.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import random, os 3 | from collections import defaultdict 4 | from threading import Thread 5 | 6 | from services import UpdateService 7 | from codeforces import codeforces as cf 8 | from utils import util 9 | from utils.util import logger 10 | from utils import database as db 11 | from codeforces import standings 12 | from telegram import Chat 13 | 14 | class AnalyseStandingsService (UpdateService.UpdateService): 15 | def __init__(self): 16 | UpdateService.UpdateService.__init__(self, 30) 17 | self.name = "analyseStandings" 18 | self._points = defaultdict(lambda : defaultdict(list)) 19 | self._notFinal = defaultdict(lambda : defaultdict(list)) 20 | self._doTask(True) 21 | 22 | def _notifyTaskSolved(self, handle, task, rejectedAttemptCount, time, official): 23 | if official: 24 | msg = "💡* ["+ util.formatSeconds(time) +"]* " 25 | else: 26 | msg = "💡 *[UPSOLVING]* " 27 | msg += util.formatHandle(handle) + " has solved task " + task 28 | if rejectedAttemptCount > 0: 29 | msg += " *after " + str(rejectedAttemptCount) + " wrong submissions*" 30 | usersToNotify = db.getWhoseFriendsContestSolved(handle) if official else db.getWhoseFriendsUpsolving(handle) 31 | for chatId in usersToNotify: 32 | Chat.getChat(chatId).sendNotification(msg) 33 | 34 | def _notifyTaskTested(self, handle, task, accepted): 35 | funnyInsults = ["%s failed on system tests for task %s. What a looser.💩", 36 | "%s should probably look for a different hobby.💁🏻‍♂️ The system tests failed for task %s.", 37 | "📉 %s failed the system tests for task %s. *So sad! It's true.*", 38 | "%s didn't manage to solve task %s. The system tests failed. You can remove this friend using the command `/remove_friend`👋🏻", 39 | "Hmmm...🤔 Probably the Codeblocks debugger did not work for %s. The solution for task %s was not good enough. It failed on system tests.", 40 | "Div. 5 is near for %s 👋🏻. The system tests failed for task %s.", 41 | "%s failed systest for task %s. Did they hardcode the samples?"] 42 | if accepted: 43 | msg = "✔️ You got accepted on system tests for task " + task 44 | for chatId in db.getChatIds(handle): # only to user with this handle 45 | Chat.getChat(chatId).sendMessage(msg) 46 | else: 47 | insult = funnyInsults[random.randint(0,len(funnyInsults)-1)] % (util.formatHandle(handle), task) 48 | neutralMsg = "%s failed on system tests for task %s." % (util.formatHandle(handle), task) 49 | for chatId in db.getWhoseFriendsSystemTestFail(handle): # to all users with this friend 50 | chat = Chat.getChat(chatId) 51 | if chat.polite: 52 | chat.sendMessage(neutralMsg) 53 | else: 54 | chat.sendMessage(insult) 55 | 56 | def _updateStandings(self, contest, chatIds : List[str]): 57 | for chatId in chatIds: 58 | chat = Chat.getChat(chatId) 59 | standings.updateStandingsForChat(contest, chat) 60 | 61 | def _analyseRow(self, contestId, row, ranking, firstRead): 62 | handle = row["party"]["members"][0]["handle"] 63 | pointsList = self._points[contestId][handle] 64 | for taski in range(len(row["problemResults"])): 65 | task = row["problemResults"][taski] 66 | taskName = ranking["problems"][taski]["index"] 67 | if task["points"] > 0 and taski not in pointsList: 68 | if not firstRead: 69 | self._notifyTaskSolved(handle, taskName, task["rejectedAttemptCount"], 70 | task["bestSubmissionTimeSeconds"], row["rank"] != 0) 71 | if ranking["contest"]['phase'] == 'FINISHED': # if contest is running, standings are updated automatically 72 | self._updateStandings(contestId, db.getWhoseFriendsListed(handle)) 73 | pointsList.append(taski) 74 | if task['type'] == 'PRELIMINARY' and (taski not in self._notFinal[contestId][handle]): 75 | logger.debug('adding non-final task ' + str(taski) + ' for user ' + str(handle)) 76 | self._notFinal[contestId][handle].append(taski) 77 | if task['type'] == 'FINAL' and (taski in self._notFinal[contestId][handle]): 78 | logger.debug('finalizing non-final task ' + str(taski) + ' for user ' + str(handle)) 79 | self._notFinal[contestId][handle].remove(taski) 80 | self._notifyTaskTested(handle, taskName, task['points'] > 0) 81 | self._updateStandings(contestId, db.getWhoseFriendsListed(handle)) 82 | if int(task['points']) == 0: #failed on system tests, now not solved 83 | pointsList.remove(taski) 84 | 85 | def _analyseContest(self, contestId, friends, firstRead): 86 | ranking = cf.getStandings(contestId, friends, forceRequest=True) 87 | if ranking is False: 88 | if firstRead: 89 | logger.critical("------------ ranking not fetched during firstRead ----------------------------") 90 | logger.critical("Aborting to avoid resending of solved notifications ...") 91 | os._exit(1) 92 | return 93 | results = ranking['rows'] 94 | for row in results: 95 | self._analyseRow(contestId, row, ranking, firstRead) 96 | if ranking['contest']['phase'] != 'FINISHED' and not firstRead: 97 | self._updateStandings(contestId, db.getAllChatPartners()) 98 | 99 | # analyses the standings 100 | def _doTask(self, firstRead=False): 101 | logger.debug('started analysing standings') 102 | friends = db.getAllFriends() 103 | threads = [] 104 | for contestId in cf.getCurrentContestsId(): 105 | t = util.createThread(target=self._analyseContest, args=(contestId, friends, firstRead), name="analyseContest"+str(contestId)) 106 | t.start() 107 | threads.append(t) 108 | for t in threads: 109 | t.join() 110 | logger.debug('finished analysing standings') 111 | -------------------------------------------------------------------------------- /services/SummarizingService.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from collections import defaultdict 4 | 5 | from codeforces import codeforces as cf 6 | from utils import database as db 7 | from codeforces import standings 8 | from utils import util 9 | from utils.util import logger 10 | from services import UpdateService 11 | from telegram import Chat 12 | 13 | class SummarizingService (UpdateService.UpdateService): 14 | def __init__(self): 15 | UpdateService.UpdateService.__init__(self, 60) 16 | self.name = "summarizeService" 17 | self.userRatings = defaultdict(lambda : -1) 18 | self._summarized = set() 19 | self._doTask(True) 20 | 21 | def _doTask(self, quiet=False): 22 | for c in cf.getCurrentContests(): 23 | if cf.getContestStatus(c) == 'finished' and not c['id'] in self._summarized and cf.getStandings(c['id'], []): 24 | self._summarized.add(c['id']) 25 | if not quiet: 26 | self._sendAllSummary(c) 27 | 28 | def _sendAllSummary(self, contest): 29 | # cache rating for all users 30 | chats = [Chat.getChat(c) for c in db.getAllChatPartners()] 31 | 32 | # The getUserInfo command returns False if there is a unknown user in the list 33 | # the user is then removed by the CF error handling routine. A retry is neccessary though. 34 | retries = 20 35 | while retries > 0: 36 | handles = [c.handle for c in chats if c.handle] 37 | infos = cf.getUserInfos(handles) 38 | if infos: 39 | for info in infos: 40 | self.userRatings[info['handle']] = info.get('rating', -1) 41 | break 42 | retries -= 1 43 | time.sleep(5) 44 | logger.debug(f"sending summary for contest {contest['id']}. Cached {len(self.userRatings)} ratings in {20-retries+1} try.") 45 | 46 | for chat in chats: 47 | msg = self._getContestAnalysis(contest, chat) 48 | if len(msg) > 0: # only send if analysis is not empty 49 | msg = contest['name'] + " has finished:\n" + msg 50 | chat.sendMessage(msg) 51 | standings.sendContestStandings(chat, contest['id'], sendIfEmpty=False) 52 | 53 | def _getContestAnalysis(self, contest, chat): 54 | msg = "" 55 | ((minHandle, minRC, minOldR), 56 | (maxHandle, maxRC, maxOldR), 57 | (myRC, myOldR, nowBetter, nowWorse)) = self._getWinnerLooser(chat, contest['id']) 58 | if myRC is not None: 59 | msg += self._getYourPerformance(myRC, myOldR, nowBetter, nowWorse, chat) 60 | if minRC <= -30 and not chat.polite: 61 | msg += "📉 The looser of the day is %s with a rating loss of %s!\n" % (util.formatHandle(minHandle, minRC+minOldR), minRC) 62 | elif minRC > 0: 63 | msg += "What a great contest!🎉\n" 64 | 65 | if maxRC >= 30: 66 | msg += "🏆 Today's king is 👑 %s 👑 with a stunning rating win of +%s!\n" % (util.formatHandle(maxHandle, maxRC+maxOldR), maxRC) 67 | elif maxRC < 0: 68 | msg += "What a terrible contest!😑\n" 69 | 70 | return msg 71 | 72 | def _getYourPerformance(self, myRC, myOldR, nowBetter, nowWorse, chat): 73 | funnyInsults = ["Maybe you should look for a different hobby.💁🏻‍♂️👋🏻", 74 | "Have you thought about actually solving the tasks next time?🤨", 75 | "Are you trying to get your rating below 0?🧐", 76 | "Tip for next time: solve more problems.☝🏻", 77 | "Fun fact: Continue like this and you have negative rating in " + str(-myOldR//(myRC if myRC != 0 else 1)) + " contests.📉", 78 | "My machine learning algorithm has found the perfect training problem for your level: Check out [this problem](https://codeforces.com/problemset/problem/1030/A) on CF.🤯", 79 | "Check out [this article](https://www.learnpython.org/en/Hello%2C_World%21), you can learn a lot from it!🐍"] 80 | funnyCompliments = ["Now you have more rating to loose in the next contest.😬", 81 | "`tourist` would be proud of you.☺️", 82 | str((2999-myOldR)//(myRC if myRC != 0 else 1)) + " more contest and you are a 👑Legendary Grandmaster."] 83 | if chat.polite: 84 | funnyInsults = ["No worries, you will likely increase your rating next time :)"] 85 | funnyCompliments = funnyCompliments[1:] 86 | msg = "" 87 | if myOldR == -1: 88 | return "" 89 | # took part and was rated 90 | if myRC < 0: 91 | msg += "Ohh that hurts.😑 You lost *%s* rating points." % myRC 92 | if myRC < -60: 93 | msg += " " + funnyInsults[random.randint(0,len(funnyInsults)-1)] 94 | else: 95 | msg += "🎉 Nice! You gained *+%s* rating points.🎉" % myRC 96 | if myRC > 60: 97 | msg += " " + funnyCompliments[random.randint(0, len(funnyCompliments)-1)] 98 | msg += "\n" 99 | 100 | if util.getUserSmiley(myOldR) != util.getUserSmiley(myOldR+myRC): 101 | msg += "You are now a " + util.getUserSmiley(myOldR+myRC) + ".\n" 102 | 103 | if len(nowBetter) > 0: 104 | l = ", ".join([util.formatHandle(name, rating) for (name,rating) in nowBetter]) 105 | msg += l + (" is" if len(nowBetter) == 1 else " are") + " now better than you👎🏻." 106 | msg += "\n" 107 | if len(nowWorse) > 0: 108 | l = ", ".join([util.formatHandle(name, rating) for (name,rating) in nowWorse]) 109 | msg += "You passed " + l + "👍🏻." 110 | msg += "\n" 111 | return msg 112 | 113 | def _getWinnerLooser(self, chat, contestId): 114 | curStandings = cf.getStandings(contestId, cf.getListFriends(chat)) 115 | rows = curStandings["rows"] 116 | # are changes already applied? 117 | myRating = self.userRatings[chat.handle] # could be -1 if CF requests fail 20 times - happens during new year special 118 | minRC, maxRC = 0, 0 119 | minOldR, maxOldR = -1, -1 120 | minHandle, maxHandle = 0, 0 121 | myRC, myOldR = None, myRating 122 | nowBetter, nowWorse = [], [] 123 | ratingChanges = standings.getRatingChanges(contestId) 124 | for row in [r for r in rows if r["rank"] != 0]: #official results only 125 | handlename = row["party"]["members"][0]["handle"] 126 | if handlename in ratingChanges: 127 | (oldR, newR) = ratingChanges[handlename] 128 | ratingC = newR-oldR 129 | if ratingC < minRC: 130 | minRC, minOldR, minHandle = ratingC, oldR, handlename 131 | if ratingC > maxRC: 132 | maxRC, maxOldR, maxHandle = ratingC, oldR, handlename 133 | if handlename == chat.handle: 134 | myRC, myOldR = ratingC, oldR 135 | if myRating == myOldR or myRating == -1: 136 | myRating = myOldR + myRC 137 | 138 | # get better and worse 139 | # TODO what about people not participating which you passed? 140 | for row in [r for r in rows if r["rank"] != 0]: #official results only 141 | handlename = row["party"]["members"][0]["handle"] 142 | if handlename in ratingChanges: 143 | (oldR, newR) = ratingChanges[handlename] 144 | if myRating != -1 and oldR < myOldR and newR > myRating: 145 | nowBetter.append((handlename, newR)) 146 | if myRating != -1 and oldR > myOldR and newR < myRating: 147 | nowWorse.append((handlename, newR)) 148 | 149 | 150 | return ((minHandle, minRC, minOldR), (maxHandle, maxRC, maxOldR), 151 | (myRC, myOldR, nowBetter, nowWorse)) 152 | -------------------------------------------------------------------------------- /services/UpcomingService.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from services import UpdateService 4 | from codeforces import upcoming 5 | from codeforces import codeforces as cf 6 | from utils import database as db 7 | from telegram import Chat 8 | from collections import defaultdict 9 | 10 | class UpcomingService (UpdateService.UpdateService): 11 | def __init__(self): 12 | UpdateService.UpdateService.__init__(self, 30) 13 | self.name = "upcomingService" 14 | self._notified = {} 15 | self._notifyTimes = [3600*24*3+59, 3600*24+59, 3600*2+59, -15*60, -100000000] 16 | self._initDB() 17 | self._doTask(True) #initializes notified 18 | 19 | def _initDB(self): 20 | self._reminderSent = defaultdict(lambda : defaultdict(lambda : None)) # [chatId][contest] = msgId 21 | data = db.getAllStandingsSentList() 22 | for (chatId, contestId, msgId, msgIdNotf) in data: 23 | if msgIdNotf: # maybe only msgId is set 24 | self._reminderSent[chatId][contestId] = msgIdNotf 25 | 26 | def _updateReminderSent(self, chatId, contestId, msgId): 27 | self._reminderSent[chatId][contestId] = msgId 28 | db.saveReminderSent(chatId, contestId, msgId) 29 | 30 | def _doTask(self, quiet=False): 31 | for c in cf.getFutureAndCurrentContests(): 32 | timeLeft = c['startTimeSeconds'] - time.time() 33 | if c['id'] not in self._notified: 34 | self._notified[c['id']] = 0 35 | oldLevel = self._notified[c['id']] 36 | while timeLeft <= self._notifyTimes[self._notified[c['id']]]: 37 | self._notified[c['id']] += 1 38 | if oldLevel != self._notified[c['id']] and not quiet: 39 | shouldNotifyFun = lambda chat: False 40 | if self._notified[c['id']] == 1: 41 | shouldNotifyFun = lambda chat: chat.reminder3d 42 | elif self._notified[c['id']] == 2: 43 | shouldNotifyFun = lambda chat: chat.reminder1d 44 | elif self._notified[c['id']] == 3: 45 | shouldNotifyFun = lambda chat: chat.reminder2h 46 | elif self._notified[c['id']] == 4: 47 | shouldNotifyFun = lambda chat: False # contest started -> no new reminder, only delete old reminder 48 | self._notifyAllUpcoming(c, shouldNotifyFun) 49 | 50 | def _notifyAllUpcoming(self, contest, shouldNotifyFun): 51 | for chatId in db.getAllChatPartners(): 52 | chat = Chat.getChat(chatId) 53 | if self._reminderSent[chat.chatId][contest['id']]: 54 | msgId = self._reminderSent[chat.chatId][contest['id']] 55 | chat.deleteMessage(msgId) 56 | self._updateReminderSent(chat.chatId, contest['id'], None) 57 | if shouldNotifyFun(chat): 58 | description = upcoming.getDescription(contest, chat) 59 | callback = lambda msgId, chatId=chatId, contestId=contest['id'] : self._updateReminderSent(chatId, contestId, msgId) 60 | chat.sendMessage(description, callback=callback) 61 | -------------------------------------------------------------------------------- /services/UpdateService.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | from utils.util import logger, perfLogger 5 | 6 | class UpdateService (threading.Thread): 7 | def __init__(self, updateInterval, logPerf=True): 8 | threading.Thread.__init__(self) 9 | self._updateInterval = updateInterval 10 | self._logPerf = logPerf 11 | 12 | def run(self): 13 | lastTime = -1 14 | while True: 15 | waitTime = lastTime + self._updateInterval - time.time() 16 | if waitTime > 0: 17 | time.sleep(waitTime) 18 | try: 19 | startT = time.time() 20 | self._doTask() 21 | if self._logPerf: 22 | perfLogger.info("service: {:.3f}s".format(time.time()-startT)) 23 | except Exception as e: 24 | logger.critical('Run error %s', e, exc_info=True) 25 | lastTime = time.time() 26 | 27 | def _doTask(self): #to be overridden 28 | pass 29 | -------------------------------------------------------------------------------- /telegram/Chat.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from telegram import telegram as tg 4 | from utils import database as db 5 | 6 | chatsLock = threading.Lock() 7 | chats = {} 8 | 9 | def getChat(chatId : str): 10 | with chatsLock: 11 | if chatId not in chats: 12 | chats[chatId] = Chat(chatId) 13 | return chats[chatId] 14 | 15 | def initChats(): 16 | with chatsLock: 17 | chatIds = db.getAllChatPartners() 18 | for chatId in chatIds: 19 | chats[chatId] = Chat(chatId) 20 | 21 | def deleteUser(chatId): 22 | with chatsLock: 23 | if chatId in chats: # necessary, otherwise multiple deletions -> error 24 | del chats[chatId] 25 | db.deleteUser(chatId) 26 | 27 | class Chat: 28 | def __init__(self, chatId:str): 29 | self._chatId = chatId 30 | self._activeMsgGroups = set() 31 | self._editLaterLock = threading.Lock() 32 | self._notifications = [] # all upsolving etc. msgs to be grouped 33 | self._notificationLock = threading.Lock() 34 | infos = db.queryChatInfos(chatId) 35 | if infos is None: 36 | self._apikey = None 37 | self._secret = None 38 | self._timezone = None 39 | self._handle = None 40 | self._notifyLevel = 3 # everything except in contest notifications 41 | self._polite = False 42 | self._reply = True 43 | self._width = 6 44 | self._reminder2h = True 45 | self._reminder1d = True 46 | self._reminder3d = False 47 | self._settings_msgid = None 48 | self._updateDB() 49 | else: 50 | (self._apikey, self._secret, self._timezone, self._handle, 51 | self._notifyLevel, 52 | self._polite, self._reply, self._width, 53 | self._reminder2h, self._reminder1d, self._reminder3d, 54 | self._settings_msgid) = infos 55 | if self._timezone is None: 56 | self._timezone = "UTC" 57 | 58 | @property 59 | def chatId(self): 60 | return self._chatId 61 | 62 | @chatId.setter 63 | def chatId(self, chatId:str): 64 | if self._chatId in chats: 65 | del chats[self._chatId] 66 | self._chatId = chatId 67 | chats[self._chatId] = self 68 | self._updateDB() 69 | 70 | @property 71 | def apikey(self): 72 | return self._apikey 73 | 74 | @apikey.setter 75 | def apikey(self, key): 76 | self._apikey = key 77 | self._updateDB() 78 | 79 | @property 80 | def secret(self): 81 | return self._secret 82 | 83 | @secret.setter 84 | def secret(self, scr): 85 | self._secret = scr 86 | self._updateDB() 87 | 88 | @property 89 | def timezone(self): 90 | return self._timezone 91 | 92 | @timezone.setter 93 | def timezone(self, tz): 94 | self._timezone = tz 95 | self._updateDB() 96 | 97 | @property 98 | def handle(self): 99 | return self._handle 100 | 101 | @handle.setter 102 | def handle(self, h): 103 | self._handle = h 104 | self._updateDB() 105 | 106 | @property 107 | def notifyLevel(self): 108 | return self._notifyLevel 109 | 110 | @notifyLevel.setter 111 | def notifyLevel(self, l): 112 | self._notifyLevel = l 113 | self._updateDB() 114 | 115 | @property 116 | def polite(self): 117 | return self._polite 118 | 119 | @polite.setter 120 | def polite(self, l): 121 | self._polite = l 122 | self._updateDB() 123 | 124 | @property 125 | def reply(self): 126 | return self._reply 127 | 128 | @reply.setter 129 | def reply(self, newVal): 130 | self._reply = newVal 131 | self._updateDB() 132 | 133 | @property 134 | def width(self): 135 | return self._width 136 | 137 | @width.setter 138 | def width(self, newVal): 139 | self._width = newVal 140 | self._updateDB() 141 | 142 | @property 143 | def reminder2h(self): 144 | return self._reminder2h 145 | 146 | @reminder2h.setter 147 | def reminder2h(self, newVal): 148 | self._reminder2h = newVal 149 | self._updateDB() 150 | 151 | @property 152 | def reminder1d(self): 153 | return self._reminder1d 154 | 155 | @reminder1d.setter 156 | def reminder1d(self, newVal): 157 | self._reminder1d = newVal 158 | self._updateDB() 159 | 160 | @property 161 | def reminder1d(self): 162 | return self._reminder1d 163 | 164 | @reminder1d.setter 165 | def reminder1d(self, newVal): 166 | self._reminder1d = newVal 167 | self._updateDB() 168 | 169 | @property 170 | def reminder3d(self): 171 | return self._reminder3d 172 | 173 | @reminder3d.setter 174 | def reminder3d(self, newVal): 175 | self._reminder3d = newVal 176 | self._updateDB() 177 | 178 | @property 179 | def settings_msgid(self): 180 | return self._settings_msgid 181 | 182 | @settings_msgid.setter 183 | def settings_msgid(self, newVal): 184 | self._settings_msgid = newVal 185 | self._updateDB() 186 | 187 | def _updateDB(self): 188 | db.updateChatInfos(self.chatId, self.apikey, self.secret, self.timezone, 189 | self.handle, self.notifyLevel, 190 | self.polite, self.reply, self.width, self.reminder2h, 191 | self.reminder1d, self.reminder3d, self.settings_msgid) 192 | 193 | def sendMessage(self, text, reply_markup = None, callback=None): 194 | if self.chatId == '0': 195 | print('\n----- message sent: ------------\n' + text + "\n--------- End Message ----------\n") 196 | return 0 197 | else: 198 | tg.requestSpooler.put(lambda : tg.sendMessage(self.chatId, text, reply_markup, callback), priority=0) 199 | 200 | # message which can be grouped 201 | def sendNotification(self, text): 202 | if self.chatId == '0': 203 | print('\n----- message sent: ------------\n' + text + "\n--------- End Message ----------\n") 204 | return 205 | def sendGroupedNotifications(): 206 | with self._notificationLock: 207 | msgText = "\n".join(self._notifications) 208 | self._notifications = [] 209 | tg.sendMessage(self._chatId, msgText) 210 | 211 | with self._notificationLock: 212 | self._notifications.append(text) 213 | if len(self._notifications) == 1: # add to spooler queue 214 | tg.requestSpooler.put(sendGroupedNotifications, priority=1) 215 | 216 | def editMessageText(self, msgId, msg, reply_markup = None): 217 | if self.chatId == '0': 218 | print('\n----- message edited to: ---------\n' + msg + "\n--------- End Message ----------\n") 219 | else: 220 | tg.requestSpooler.put(lambda : tg.editMessageText(self.chatId, msgId, msg, reply_markup), priority=0) 221 | 222 | def editMessageTextLater(self, msgId, msgGroup, fun): 223 | if self.chatId == '0': 224 | msg = fun(self, msgGroup) 225 | if msg: 226 | print('\n----- message sent: ------------\n' + msg + "\n--------- End Message ----------\n") 227 | return 228 | with self._editLaterLock: 229 | if msgGroup not in self._activeMsgGroups: 230 | self._activeMsgGroups.add(msgGroup) 231 | else: 232 | return 233 | 234 | def editMsgNow(): 235 | msg = fun(self, msgGroup) 236 | if msg: 237 | tg.editMessageText(self.chatId, msgId, msg) 238 | with self._editLaterLock: 239 | self._activeMsgGroups.remove(msgGroup) 240 | tg.requestSpooler.put(editMsgNow, priority=2) 241 | 242 | def deleteMessage(self, msgId): 243 | if self.chatId == '0': 244 | print('\n----- message deleted:' + msgId + '---------\n') 245 | else: 246 | tg.requestSpooler.put(lambda : tg.deleteMessage(self.chatId, msgId), priority=1) 247 | -------------------------------------------------------------------------------- /telegram/telegram.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from collections import defaultdict 3 | 4 | from utils import database as db 5 | from utils.util import logger 6 | from commands import bot, settings 7 | from services import UpdateService 8 | from telegram import Chat 9 | from codeforces import standings 10 | from utils.Spooler import Spooler 11 | 12 | requestUrl = [line.rstrip('\n') for line in open('.telegram_api_url')][0] 13 | testFlag = False 14 | 15 | def requestPost(chatId, url, data, timeout=30): 16 | errorTxt = 'chatId: ' + str(data.get('chat_id')) + ' text:\n' + str(data.get('text')) 17 | if testFlag: 18 | logger.info("telegram object that would have been sent (url " + url + "): " + errorTxt) 19 | r = {'ok':True, 'result':{'message_id':1}} 20 | return r 21 | try: 22 | r = requests.post(url, timeout=timeout, data=data) 23 | r = r.json() 24 | if r['ok']: 25 | return r 26 | else: 27 | #only print if error not handled yet 28 | if not handleRequestError(chatId, r): 29 | logText = 'Failed to request telegram. Error: ' + r.get('description', "No description available.") + '\n' + errorTxt 30 | if r['description'] == "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message": 31 | logger.error(logText) 32 | else: 33 | logger.critical(logText) 34 | return False 35 | except requests.Timeout as e: 36 | logger.critical('Timeout at telegram request: ' + errorTxt) 37 | return False 38 | except Exception as e: 39 | logger.critical('Failed to request telegram: \nexception: %s\ntext: %s', e, errorTxt, exc_info=True) 40 | return False 41 | 42 | requestSpooler = Spooler(19, "telegram", timeInterval=2, priorityCount=3) 43 | 44 | 45 | # returns whether error could be handled 46 | def handleRequestError(chatId, req): 47 | errMsg = req['description'] 48 | if (errMsg == "Forbidden: bot was blocked by the user" or 49 | errMsg == "Forbidden: bot was kicked from the group chat" or 50 | errMsg == "Forbidden: bot was kicked from the supergroup chat" or 51 | errMsg == "Bad Request: chat not found" or 52 | errMsg == "Forbidden: user is deactivated" or 53 | errMsg == "Forbidden: bot can't initiate conversation with a user" or 54 | errMsg == "Forbidden: the group chat was deleted"): 55 | logger.error(f"No rights to send message in Chat {chatId} -> deleting chat: {errMsg}") 56 | Chat.deleteUser(chatId) 57 | return True 58 | elif errMsg == "Bad Request: group chat was upgraded to a supergroup chat": 59 | newChatId = req['parameters']['migrate_to_chat_id'] 60 | logger.error(f"Migrating to new chat id: {chatId} -> {newChatId}") 61 | if newChatId in Chat.chats: 62 | logger.error(f"Chat for new chatID {newChatId} already exists -> deleting old chat {chatId}") 63 | Chat.deleteUser(chatId) 64 | else: 65 | Chat.getChat(chatId).chatId = newChatId 66 | return True 67 | elif errMsg == "Bad Request: message to edit not found": 68 | with standings.standingsSentLock: 69 | standings.standingsSent[chatId] = defaultdict(lambda : None) 70 | logger.error(f"message to edit not found - deleted standingsSent for Chat {chatId}") 71 | return True 72 | elif (errMsg.startswith("Bad Request: message can't be deleted") or 73 | errMsg == "Bad Request: message to delete not found"): 74 | logger.error(f"Message deletion failed for Chat {chatId}") 75 | return True 76 | # We are not allowed to write to the chat but maybe we are just temporarily muted/blocked 77 | # Therefore, we don't delete the chat from the DB 78 | elif (errMsg == "Bad Request: have no rights to send a message" or 79 | errMsg == "Forbidden: CHAT_WRITE_FORBIDDEN" or 80 | errMsg == "Forbidden: bot is not a member of the supergroup chat" or 81 | errMsg == "Bad Request: not enough rights to send text messages to the chat" or 82 | errMsg == "Bad Request: CHAT_RESTRICTED"): 83 | logger.info(f"No rights to send message in Chat {chatId}: {errMsg}") 84 | else: 85 | return False 86 | 87 | def shortenMessage(text): 88 | if len(text) > 4000: # Telegram doesn't allow longer messages 89 | cutof = text[4000:] 90 | text = text[:4000] 91 | while text[-1] == '`': # don't split on "```" 92 | cutof = text[-1] + cutof 93 | text = text[:-1] 94 | if cutof.count("```") % 2 == 1: 95 | text += "```" 96 | text += "…" 97 | return text 98 | 99 | def sendAnswerCallback(chatId, callback_query_id, text = ""): 100 | params = { 101 | 'callback_query_id':callback_query_id, 102 | 'text':text 103 | } 104 | requestPost(chatId, requestUrl + 'answerCallbackQuery', params) 105 | 106 | def sendMessage(chatId, text, reply_markup = None, callback=None): 107 | text = shortenMessage(text) 108 | logger.debug("sendMessage to " + str(chatId) + ":\n" + text + "\n\n") # TODO test 109 | params = { 110 | 'parse_mode':'Markdown', 111 | 'chat_id':str(chatId), 112 | 'text':text, 113 | 'reply_markup': reply_markup 114 | } 115 | res = requestPost(chatId, requestUrl + 'sendMessage', params) 116 | if callback: 117 | callback(res['result']['message_id'] if res else False) 118 | 119 | def editMessageText(chatId, msgId, msg, reply_markup=None): 120 | msg = shortenMessage(msg) 121 | logger.debug("editMessageText to " + str(chatId) + " msgId: " + str(msgId)+":\n" + msg + "\n\n") # TODO test 122 | params = { 123 | 'parse_mode':'Markdown', 124 | 'chat_id':str(chatId), 125 | 'message_id':str(msgId), 126 | 'text':msg, 127 | 'reply_markup': reply_markup 128 | } 129 | url = requestUrl + 'editMessageText' 130 | requestPost(chatId, url, params) 131 | 132 | def deleteMessage(chatId, msgId): 133 | logger.debug(f"deleting msg {msgId} for chat {chatId}") 134 | params = { 135 | 'chat_id':str(chatId), 136 | 'message_id':msgId, 137 | } 138 | url = requestUrl + 'deleteMessage' 139 | requestPost(chatId, url, params) 140 | 141 | class TelegramUpdateService (UpdateService.UpdateService): 142 | def __init__(self): 143 | UpdateService.UpdateService.__init__(self, 0.2, logPerf=False) 144 | self.name = "telegramService" 145 | self._lastUpdateID = -1 146 | 147 | def _poll(self): 148 | try: 149 | t = 10 150 | r = requests.get(requestUrl + 'getUpdates?offset=' + str(self._lastUpdateID + 1) + ';timeout=' + str(t), timeout=2*t) 151 | r = r.json() 152 | except requests.exceptions.Timeout as errt: 153 | logger.error("Timeout on Telegram polling.") 154 | return [] 155 | except Exception as e: 156 | logger.critical("Error on Telegram polling: " + str(e)) 157 | return [] 158 | if r['ok']: 159 | return r['result'] 160 | else: 161 | return [] 162 | 163 | def _handleUpdate(self, update): 164 | self._lastUpdateID = update['update_id'] 165 | if 'message' in update: 166 | if 'new_chat_members' in update['message'] and any([u.get('username') == 'codeforces_live_bot' for u in update['message']['new_chat_members']]): 167 | # bot joined a group 168 | bot.handleMessage(Chat.getChat(str(update['message']['chat']['id'])), "/start") 169 | if 'text' in update['message']: 170 | bot.handleMessage(Chat.getChat(str(update['message']['chat']['id'])), update['message']['text']) 171 | else: 172 | logger.debug("no text in message: " + str(update['message'])) 173 | elif 'edited_message' in update and 'text' in update['edited_message']: 174 | bot.handleMessage(Chat.getChat(str(update['edited_message']['chat']['id'])), update['edited_message']['text']) 175 | elif 'callback_query' in update: 176 | settings.handleCallbackQuery(update['callback_query']) 177 | 178 | def _doTask(self): 179 | curUpd = self._poll() 180 | for u in curUpd: 181 | self._handleUpdate(u) 182 | -------------------------------------------------------------------------------- /userStats/gnuplotInstr.txt: -------------------------------------------------------------------------------- 1 | if (!exists("col")) col=2 2 | 3 | set terminal wxt persist 4 | 5 | set xdata time 6 | set format x "%m/%Y" 7 | set xlabel "Date" 8 | set autoscale x 9 | set xtics 60. * 60. * 24. * 30. 10 | set xtics rotate by -90 11 | 12 | set ylabel "Count" 13 | set yrange [0:*] 14 | 15 | set style data linespoints 16 | 17 | set timefmt "%Y-%m-%d-%H:%M:%S" 18 | 19 | set key bottom right 20 | 21 | if (col == 0) plot "./stats.txt" using 1:2 t "users" with lines, "./stats.txt" using 1:3 t "distinct friends" with lines, "./stats.txt" using 1:4 t "friends connections" with lines; \ 22 | else if(col == 2) plot "./stats.txt" using 1:col t "users" with lines; \ 23 | else if(col == 3) plot "./stats.txt" using 1:col t "distinct friends" with lines; \ 24 | else if(col == 4) plot "./stats.txt" using 1:col t "friend relation size" with lines; \ 25 | -------------------------------------------------------------------------------- /userStats/logUserStats.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | f=($(cat ../.database_creds)) 4 | user=${f[0]} 5 | pass=${f[1]} 6 | host=${f[2]} 7 | port=${f[3]} 8 | db=${f[4]} 9 | 10 | curTime=`date "+%Y-%m-%d-%H:%M:%S"` 11 | userCnt=`mysql -u $user -p$pass -h $host -P $port -D $db -B -N -e 'select count(*) from tokens'` 12 | distFriends=`mysql -u $user -p$pass -h $host -P $port -D $db -B -N -e 'select count(distinct friend) from friends'` 13 | friends=`mysql -u $user -p$pass -h $host -P $port -D $db -B -N -e 'select count(*) from friends'` 14 | 15 | #echo "curTime: $curTime" 16 | #echo "users: $userCnt" 17 | #echo "distFriends: $distFriends" 18 | #echo "friends: $friends" 19 | echo "$curTime $userCnt $distFriends $friends" >> stats.txt 20 | -------------------------------------------------------------------------------- /userStats/plotStats.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | arg=$1 4 | if [ $# -eq 0 ] 5 | then arg=0 6 | fi 7 | gnuplot -e "col=$arg" gnuplotInstr.txt 8 | -------------------------------------------------------------------------------- /utils/Spooler.py: -------------------------------------------------------------------------------- 1 | import time 2 | from threading import Thread, Condition 3 | import queue 4 | 5 | from utils.util import logger, perfLogger 6 | 7 | class Spooler: 8 | def __init__(self, threadCount, name="", timeInterval=0, priorityCount=1): 9 | self._q = [queue.Queue() for i in range(priorityCount)] 10 | self._timeInterval = timeInterval 11 | self._threadCount = threadCount 12 | self._name = name 13 | self._lock = Condition() 14 | for i in range(threadCount): 15 | Thread(target=self._run, name=name + " spooler #" + str(i)).start() 16 | 17 | def put(self, callbackFun, priority): 18 | with self._lock: 19 | self._q[priority].put((time.time(), callbackFun)) 20 | self._lock.notify() 21 | 22 | def _run(self): 23 | while True: 24 | found = False 25 | with self._lock: 26 | for i in range(len(self._q)): 27 | if self._q[i].qsize() > 0: 28 | (timeStamp, callbackFun) = self._q[i].get() 29 | found = True 30 | break 31 | if not found: 32 | self._lock.wait() 33 | if found: 34 | startT = time.time() 35 | try: 36 | callbackFun() 37 | timeInSpooler = startT-timeStamp 38 | timeForFun = time.time()-startT 39 | if timeInSpooler > 0.001 or timeForFun > 0.001: 40 | perfLogger.info("time in spooler: {:.3f}s; time for fun: {:.3f}s".format(timeInSpooler, timeForFun)) 41 | except Exception as e: 42 | logger.critical('%s spooler error %s', self._name, e, exc_info=True) 43 | sleepT = startT + self._timeInterval - time.time() 44 | if sleepT > 0: 45 | time.sleep(sleepT) 46 | -------------------------------------------------------------------------------- /utils/Table.py: -------------------------------------------------------------------------------- 1 | class Table: 2 | def __init__(self, header, rows): 3 | self._header = header 4 | self._rows = rows 5 | 6 | def formatTable(self, maxColCount): # multiple rows 7 | colW = 4 8 | colC = min(len(self._header), maxColCount) 9 | rowC = (len(self._header)+colC-1)//colC 10 | totalW = colC*(colW+1)+1 11 | msg = "```\n" 12 | msg += self._getDividerHead(colW, totalW) 13 | for i in range(rowC*colC): 14 | if i % colC == 0 and i > 0: 15 | msg += "┃\n" 16 | v = self._header[i] if i < len(self._header) else "" 17 | msg += "┃" + v.center(colW) 18 | 19 | msg += "┃\n" 20 | 21 | for row in self._rows: 22 | msg += self._getDividerHalfBottom(colW, totalW) 23 | msg += "┃" + row["head"].center(totalW-2) + "┃\n" 24 | if "head2" in row: 25 | msg += "┃" + row["head2"].center(totalW-2) + "┃\n" 26 | for i in range(rowC*colC): 27 | if i % colC == 0 and i > 0: 28 | msg += "┃\n" 29 | v = row["body"][i] if i < len(row["body"]) else "" 30 | msg += "┃" + str(v).center(colW) 31 | msg += "┃\n" 32 | 33 | msg += self._getDividerBottom(colW, totalW) 34 | msg += "```" 35 | return msg.replace("┃","|") 36 | 37 | def _getDividerHead(self, colW, totalW): 38 | msg = "" 39 | for i in range(totalW): 40 | if i == 0: 41 | msg += "+"#"┏" 42 | elif i == totalW-1: 43 | msg += "+"#"┓" 44 | elif i % (colW+1) == 0: 45 | msg += "+"#"┳" 46 | else: 47 | msg += "-" 48 | return msg + "\n" 49 | 50 | def _getDividerBottom(self, colW, totalW): 51 | msg = "" 52 | for i in range(totalW): 53 | if i == 0: 54 | msg += "+"#"┗" 55 | elif i == totalW-1: 56 | msg += "+"#"┛" 57 | elif i % (colW+1) == 0: 58 | msg += "+"#"┻" 59 | else: 60 | msg += "-" 61 | return msg + "\n" 62 | 63 | def _getDividerHalfBottom(self, colW, totalW): 64 | msg = "" 65 | for i in range(totalW): 66 | if i == 0: 67 | msg += "+"#"┣" 68 | elif i == totalW-1: 69 | msg += "+"#"┫" 70 | elif i % (colW+1) == 0: 71 | msg += "+"#"┻" 72 | else: 73 | msg += "-"#"━" 74 | return msg + "\n" 75 | 76 | def _getDivider(self, colW, totalW): 77 | msg = "" 78 | for i in range(totalW): 79 | if i == 0: 80 | msg += "+"#"┣" 81 | elif i == totalW-1: 82 | msg += "+"#"┫" 83 | elif i % (colW+1) == 0: 84 | msg += "+"#"╋" 85 | else: 86 | msg += "-"#"━" 87 | return msg + "\n" 88 | -------------------------------------------------------------------------------- /utils/database.py: -------------------------------------------------------------------------------- 1 | import mysql.connector, mysql.connector.pooling, mysql.connector.errors 2 | import threading, time 3 | 4 | from utils.util import logger, perfLogger 5 | from telegram import Chat 6 | 7 | friendsNotfLock = threading.Lock() 8 | 9 | NOTIFY_COLUMNS = ["showInList", "notifyTest", "notifyUpsolving", "notify"] 10 | 11 | db_creds = [line.rstrip('\n') for line in open('.database_creds')] 12 | 13 | def openDB(): 14 | return mysql.connector.connect(user=db_creds[0], password=db_creds[1], host=db_creds[2], port=db_creds[3], database=db_creds[4]) 15 | 16 | def queryDB(query, params): 17 | startT = time.time() 18 | db = openDB() 19 | cursor = db.cursor() 20 | cursor.execute(query, params) 21 | res = cursor.fetchall() 22 | cursor.close() 23 | db.close() 24 | perfLogger.info("db query {}: {:.3f}s".format(query, time.time()-startT)) 25 | return res 26 | 27 | def insertDB(query, params): 28 | if len(params) == 0: 29 | return 30 | db = openDB() 31 | cursor = db.cursor() 32 | cursor.execute(query, params) 33 | db.commit() 34 | db.close() 35 | 36 | # returns (apikey, secret, timezone, handle) or None, if no such user exists 37 | def queryChatInfos(chatId): 38 | query = ("SELECT apikey, secret, timezone, handle, notifyLevel, " 39 | "polite, reply, width, reminder2h, reminder1d, reminder3d, " 40 | "settings_msgid FROM tokens WHERE chatId = %s") 41 | res = queryDB(query, (chatId,)) 42 | if len(res) == 0: 43 | return None 44 | else: 45 | return res[0] 46 | 47 | def updateChatInfos(chatId, apikey, secret, timezone, handle, notifyLevel, 48 | polite, reply, width, reminder2h, reminder1d, reminder3d, settings_msgid): 49 | query = ("INSERT INTO " 50 | "tokens (chatId, apikey, secret, timezone, handle, notifyLevel, " 51 | "polite, reply, width, reminder2h, " 52 | "reminder1d, reminder3d, settings_msgid) " 53 | "VALUES " 54 | "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) " 55 | "ON DUPLICATE KEY UPDATE " 56 | "apikey = %s , secret = %s , timezone = %s , handle = %s , " 57 | "notifyLevel = %s , polite = %s , " 58 | "reply = %s , width = %s , reminder2h = %s , reminder1d = %s , " 59 | "reminder3d = %s , settings_msgid = %s") 60 | insertDB(query, (chatId, apikey, secret, timezone, handle, notifyLevel, 61 | polite, reply, width, reminder2h, reminder1d, reminder3d, 62 | settings_msgid, 63 | apikey, secret, timezone, handle, notifyLevel, 64 | polite, reply, width, reminder2h, reminder1d, reminder3d, 65 | settings_msgid)) 66 | 67 | def getChatIds(handle): 68 | query = "SELECT chatId from tokens WHERE handle = %s" 69 | res = queryDB(query, (handle,)) 70 | return [r[0] for r in res] 71 | 72 | def deleteFriend(handle): 73 | logger.debug("deleting friends with handle " + handle) 74 | query = "DELETE FROM friends WHERE friend = %s" 75 | insertDB(query, (handle,)) 76 | 77 | query = "SELECT chatId FROM tokens WHERE handle = %s" 78 | chatIds = [r[0] for r in queryDB(query, (handle,))] 79 | logger.debug(f"deleting chat handle {handle} for chats {chatIds}") 80 | for chatId in chatIds: 81 | Chat.getChat(chatId).handle = None # write through to DB 82 | 83 | def deleteFriendOfUser(handle, chatId): 84 | logger.debug("deleting friend with handle " + handle + " from user " + str(chatId)) 85 | query = "DELETE FROM friends WHERE friend = %s AND chatId = %s" 86 | insertDB(query, (handle,chatId)) 87 | 88 | def deleteUser(chatId): 89 | logger.debug("deleting all data of user with chatId " + str(chatId)) 90 | query = "DELETE FROM friends WHERE chatId = %s" 91 | logger.debug("deleting all friend entries: " + query) 92 | insertDB(query, (chatId,)) 93 | query = "DELETE FROM tokens WHERE chatId = %s" 94 | logger.debug("deleting all token entries: " + query) 95 | insertDB(query, (chatId,)) 96 | 97 | def addFriends(chatId, friends, notifyLevel): 98 | query = "INSERT INTO friends (chatId, friend, showInList, notifyTest, notifyUpsolving, notify) VALUES " 99 | for f in friends: 100 | query += "(%s, %s, %s, %s, %s, %s), " 101 | query = query[:-2] + " ON DUPLICATE KEY UPDATE chatId=chatId" 102 | 103 | params = [] 104 | for f in friends: 105 | params.append(chatId) 106 | params.append(f) 107 | for i in range(4): #TODO 108 | params.append(i < notifyLevel) 109 | 110 | insertDB(query, tuple(params)) 111 | 112 | def getFriends(chatId, selectorColumn = "True"): 113 | query = f"SELECT friend, {', '.join(NOTIFY_COLUMNS)} FROM friends WHERE chatId = %s AND {selectorColumn}=True" 114 | res = queryDB(query, (chatId,)) 115 | return res 116 | 117 | def getAllFriends(): 118 | query = "SELECT DISTINCT friend FROM friends" 119 | res = queryDB(query, ()) 120 | return [x[0] for x in res] 121 | 122 | 123 | def getWhoseFriendsListed(handle): 124 | query = "SELECT DISTINCT chatId FROM friends WHERE friend = %s AND showInList=True" 125 | res = queryDB(query, (handle,)) 126 | return [row[0] for row in res] 127 | 128 | def getWhoseFriendsSystemTestFail(handle): 129 | query = "SELECT DISTINCT chatId FROM friends WHERE friend = %s AND notifyTest=True" 130 | res = queryDB(query, (handle,)) 131 | return [row[0] for row in res] 132 | 133 | def getWhoseFriendsUpsolving(handle): 134 | query = "SELECT DISTINCT chatId FROM friends WHERE friend = %s AND notifyUpsolving=True" 135 | res = queryDB(query, (handle,)) 136 | return [row[0] for row in res] 137 | 138 | def getWhoseFriendsContestSolved(handle): 139 | query = "SELECT DISTINCT chatId FROM friends WHERE friend = %s AND notify=True" 140 | res = queryDB(query, (handle,)) 141 | return [row[0] for row in res] 142 | 143 | def getAllChatPartners(): 144 | query = "SELECT chatId FROM tokens" 145 | res = queryDB(query, ()) 146 | ret = [] 147 | for x in res: 148 | ret.append(x[0]) 149 | return ret 150 | 151 | def toggleFriendSettings(chatId, friend, columnNum): 152 | column = NOTIFY_COLUMNS[columnNum] 153 | with friendsNotfLock: 154 | query = f"SELECT {column} FROM friends WHERE chatId = %s AND friend = %s" 155 | gesetzt = str(queryDB(query, (chatId, friend))[0][0]) == '1' 156 | newVal = not gesetzt 157 | query = f"UPDATE friends SET {column}= %s WHERE chatId = %s AND friend = %s" 158 | insertDB(query, (newVal, chatId, friend)) 159 | return newVal 160 | 161 | def updateToNotifyLevel(chatId, newLev, oldLev=None, reset=False): 162 | with friendsNotfLock: 163 | colsSet = [f"{NOTIFY_COLUMNS[i]} = {i < newLev}" for i in range(len(NOTIFY_COLUMNS))] 164 | query = "UPDATE friends SET " 165 | query += ", ".join(colsSet) 166 | query += " WHERE chatId = %s" 167 | if not reset: 168 | oldColsCond = [f"{NOTIFY_COLUMNS[i]} = {i < oldLev}" for i in range(len(NOTIFY_COLUMNS))] 169 | query += " AND " + (" AND ".join(oldColsCond)) 170 | insertDB(query, (chatId,)) 171 | 172 | # ---------- standingsSent -------------- 173 | def getAllStandingsSentList(): 174 | query = "SELECT * FROM standingsSent" 175 | return queryDB(query, ()) 176 | 177 | def saveStandingsSent(chatId, contestId, msgid): 178 | query = ( 179 | "INSERT INTO standingsSent (chatId, contestId, msgid_standings)" 180 | "VALUES (%s, %s, %s)" 181 | "ON DUPLICATE KEY UPDATE msgid_standings = %s" 182 | ) 183 | insertDB(query, (chatId, contestId, msgid, msgid)) 184 | 185 | def saveReminderSent(chatId, contestId, msgid): 186 | query = ( 187 | "INSERT INTO standingsSent (chatId, contestId, msgid_reminder)" 188 | "VALUES (%s, %s, %s)" 189 | "ON DUPLICATE KEY UPDATE msgid_reminder = %s" 190 | ) 191 | insertDB(query, (chatId, contestId, msgid, msgid)) 192 | -------------------------------------------------------------------------------- /utils/util.py: -------------------------------------------------------------------------------- 1 | import hashlib, threading, os 2 | import datetime 3 | from geopy.geocoders import Nominatim 4 | from timezonefinder import TimezoneFinder 5 | from pytz import timezone 6 | import logging 7 | from logging.handlers import TimedRotatingFileHandler 8 | from threading import Thread 9 | 10 | # global and exported (init at initLogging) 11 | logger = logging.getLogger() 12 | perfLogger = logging.getLogger("performance") 13 | 14 | def cleanString(s): 15 | return s.lower().strip() 16 | 17 | def escapeMarkdown(s): 18 | s = s.replace("\\", "\\\\") 19 | s = s.replace("`", "\\`") 20 | s = s.replace("_", "\\_") 21 | s = s.replace("*", "\\*") 22 | s = s.replace("[", "\\[") 23 | s = s.replace("]", "\\]") 24 | return s 25 | 26 | def createThread(target, args, name=None): 27 | def newTarget(): 28 | try: 29 | target(*args) 30 | except Exception as e: 31 | logger.critical('Run error %s', e, exc_info=True) 32 | t = Thread(target=newTarget, name=name) 33 | return t 34 | 35 | def sha512Hex(s): 36 | return hashlib.sha512(s.encode()).hexdigest() 37 | 38 | def getLocFromName(name): 39 | geolocator = Nominatim(user_agent="codeforces_live_bot") 40 | res = geolocator.geocode(name) 41 | if res: 42 | return res.latitude, res.longitude 43 | return None, None 44 | 45 | def getTimeZoneFromLatLong(lat, lng): 46 | tf = TimezoneFinder() 47 | tz_name = tf.timezone_at(lng=lng, lat=lat) 48 | return tz_name 49 | 50 | def getTimeZone(name): 51 | lat, lng = getLocFromName(name) 52 | if not lat: 53 | return None 54 | return getTimeZoneFromLatLong(lat, lng) 55 | 56 | def getUTCTime(): 57 | return datetime.datetime.now(timezone("UTC")) 58 | 59 | # date: UTC datetime object 60 | # timezone: string e.g "Europe/Berlin" 61 | def dateToTimezone(date, timez): 62 | date.replace(tzinfo=timezone("UTC")) 63 | return date.astimezone(timezone(timez)) 64 | 65 | def formatDate(date, f): 66 | months = ["Jan.", "Feb.", "March", "April", "May", "June", "July", "Aug.", "Sept.", "Oct.", "Nov.", "Dec."] 67 | days = ["Mon.", "Tue.", "Wen.", "Thu.", "Fri.", "Sat.", "Sun."] 68 | f = f.replace('#hh#', date.strftime("%H")) 69 | f = f.replace('#h#', date.strftime("%-H")) 70 | f = f.replace('#mm#', date.strftime("%M")) 71 | f = f.replace('#DD#', date.strftime("%d")) 72 | f = f.replace('#DDD#', days[date.weekday()]) 73 | f = f.replace('#MM#', date.strftime("%m")) 74 | f = f.replace('#MMM#', months[date.month -1]) 75 | f = f.replace('#YYYY#', date.strftime("%Y")) 76 | f = f.replace('#YY#', date.strftime("%y")) 77 | return f 78 | 79 | def displayTime(t, timez): 80 | if t is None: 81 | return "forever" 82 | if timez is None or timez == "": 83 | timez = "UTC" 84 | now = datetime.datetime.now(timezone(timez)) 85 | t = datetime.datetime.utcfromtimestamp(t).replace(tzinfo=timezone("UTC")).astimezone(timezone(timez)) 86 | 87 | diff = (t - now).total_seconds() 88 | outText = "" 89 | if diff < 60*1: 90 | outText = "Now" 91 | elif diff < 60*60*24: 92 | if now.date() != t.date(): 93 | outText = "Tomorrow " 94 | else: 95 | outText = "Today " 96 | outText += formatDate(t,"#hh#:#mm#") 97 | elif diff < 60*60*24*14: 98 | outText = formatDate(t,"#DDD# #DD# #MMM# #hh#:#mm#") 99 | elif diff < 60*60*24*14: 100 | outText = formatDate(t,"#DD#. #MMM# #hh#:#mm#") 101 | elif diff < 60*60*24*30*3: 102 | outText = formatDate(t,"#DD#. #MMM#") 103 | else: 104 | outText = formatDate(t,"#DD#.#MM#.#YYYY#") 105 | 106 | return outText 107 | 108 | def formatSeconds(s, useExcl=False, longOk=True): 109 | s = s//60 110 | if s < 60: 111 | out = "0:" + str(s).zfill(2) 112 | elif s // 60 >= 10 and not longOk: 113 | out = (str(s//60) + ("H" if useExcl else "h")).rjust(4) 114 | else: 115 | out = str(s//60) + ":" + str(s%60).zfill(2) 116 | return out.replace(":", "!") if useExcl else out 117 | 118 | def getUserSmiley(rating): 119 | rating = int(rating) 120 | if rating < 1200: 121 | return "🧟" 122 | elif rating < 1400: 123 | return "👷🏻" 124 | elif rating < 1600: 125 | return "🧑🏻‍🚀" 126 | elif rating < 1900: 127 | return "🧑🏻‍🔬" 128 | elif rating < 2100: 129 | return "🧑🏻‍🎓" 130 | elif rating < 2400: 131 | return "🧙🏻" 132 | else: 133 | return "🦸🏻" 134 | 135 | def formatHandle(handle, rating=-1): # format as fixed width, add emoji if rating is provided 136 | res = "`" + handle + "`" 137 | if rating != -1: 138 | return getUserSmiley(rating) + res 139 | return res 140 | 141 | def initLogging(): 142 | if not os.path.exists("log"): 143 | os.mkdir("log") 144 | logger.setLevel(logging.DEBUG) 145 | hConsole = logging.StreamHandler() 146 | # rotating every wednesday at 04:00 147 | rotSettings = {"when": "W2", "interval" : 1, "atTime": datetime.time(4, 0), 148 | "backupCount": 10} 149 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s: %(message)s') 150 | 151 | hDebug = TimedRotatingFileHandler("log/debug.txt", **rotSettings) 152 | hInfo = TimedRotatingFileHandler("log/info.txt", **rotSettings) 153 | hError = TimedRotatingFileHandler("log/error.txt", **rotSettings) 154 | hCrit = TimedRotatingFileHandler("log/crit.txt", **rotSettings) 155 | 156 | hConsole.setLevel(logging.INFO) 157 | hDebug.setLevel(logging.DEBUG) 158 | hInfo.setLevel(logging.INFO) 159 | hError.setLevel(logging.ERROR) 160 | hCrit.setLevel(logging.CRITICAL) 161 | 162 | for h in [hConsole, hDebug, hInfo, hError, hCrit]: 163 | h.setFormatter(formatter) 164 | logger.addHandler(h) 165 | 166 | # init performance logger 167 | perfLogger.setLevel(logging.DEBUG) 168 | hPerf = TimedRotatingFileHandler("log/perf.txt", **rotSettings) 169 | hPerf.setLevel(logging.DEBUG) 170 | perfFormatter = logging.Formatter('%(asctime)s - %(levelname)s - %(threadName)s: %(message)s') 171 | hPerf.setFormatter(perfFormatter) 172 | perfLogger.addHandler(hPerf) 173 | perfLogger.propagate = False 174 | --------------------------------------------------------------------------------