Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2e96612

Browse files
Bibo-Joshipre-commit-ci[bot]
andauthoredJul 5, 2022
Refactor for PTB v20.0a2 (#92)
Co-authored-by: Hinrich Mahler <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d7d4542 commit 2e96612

24 files changed

+1083
-905
lines changed
 

‎.github/workflows/pre-commit_dependencies_notifier.yml

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ on:
33
pull_request_target:
44
paths:
55
- requirements.txt
6-
- requirements-dev.txt
76
- .pre-commit-config.yaml
87
permissions:
98
pull-requests: write
@@ -15,5 +14,5 @@ jobs:
1514
- name: running the check
1615
uses: Poolitzer/notifier-action@master
1716
with:
18-
notify-message: Hey! Looks like you edited the (dev) requirements or the pre-commit hooks. I'm just a friendly reminder to keep the pre-commit hook versions in sync with the dev requirements and the additional dependencies for the hooks in sync with the requirements :)
17+
notify-message: Hey! Looks like you edited the requirements or the pre-commit hooks. I'm just a friendly reminder to keep the additional dependencies for the hooks in sync with the requirements :)
1918
repo-token: ${{ secrets.GITHUB_TOKEN }}

‎.pre-commit-config.yaml

+25-15
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,53 @@ ci:
88

99
repos:
1010
- repo: https://github.com/psf/black
11-
rev: 22.3.0
11+
rev: 22.6.0
1212
hooks:
1313
- id: black
1414
args:
1515
- --diff
1616
- --check
17-
- repo: https://gitlab.com/pycqa/flake8
17+
- repo: https://github.com/PyCQA/flake8
1818
rev: 4.0.1
1919
hooks:
2020
- id: flake8
2121
- repo: https://github.com/PyCQA/pylint
22-
rev: v2.14.3
22+
rev: v2.14.4
2323
hooks:
2424
- id: pylint
2525
args:
2626
- --rcfile=setup.cfg
2727
additional_dependencies:
28-
- beautifulsoup4
29-
- thefuzz==0.19.0
30-
- python-telegram-bot==13.9
31-
- Sphinx
32-
- requests
33-
- github3.py==2.0.0
28+
- beautifulsoup4~=4.11.0
29+
- thefuzz~=0.19.0
30+
- python-telegram-bot==20.0a2
31+
- Sphinx~=5.0.2
32+
- httpx~=0.23.0
33+
- gql~=3.3.0
34+
- async-lru~=1.0.3
3435
- repo: https://github.com/pre-commit/mirrors-mypy
3536
rev: v0.961
3637
hooks:
3738
- id: mypy
3839
additional_dependencies:
39-
- beautifulsoup4
40-
- thefuzz==0.19.0
41-
- python-telegram-bot==13.9
42-
- Sphinx
43-
- requests
44-
- github3.py==2.0.0
40+
- beautifulsoup4~=4.11.0
41+
- thefuzz~=0.19.0
42+
- python-telegram-bot==20.0a2
43+
- Sphinx~=5.0.2
44+
- httpx~=0.23.0
45+
- gql~=3.3.0
46+
- async-lru~=1.0.3
4547
- repo: https://github.com/asottile/pyupgrade
4648
rev: v2.34.0
4749
hooks:
4850
- id: pyupgrade
4951
args:
5052
- --py36-plus
53+
- repo: https://github.com/pycqa/isort
54+
rev: 5.10.1
55+
hooks:
56+
- id: isort
57+
name: isort
58+
args:
59+
- --diff
60+
- --check

‎bot.ini

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
[KEYS]
22
bot_api = 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
3+
github_auth = 123456

‎components/callbacks.py

+212-297
Large diffs are not rendered by default.

‎components/const.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,19 @@
1212
# Welcome new chat members at most ever X minutes
1313
NEW_CHAT_MEMBERS_LIMIT_SPACING = 60
1414
USER_AGENT = "Github: python-telegram-bot/rules-bot"
15+
DEFAULT_HEADERS = {"User-Agent": USER_AGENT}
1516
ENCLOSING_REPLACEMENT_CHARACTER = "+"
1617
_ERC = ENCLOSING_REPLACEMENT_CHARACTER
1718
ENCLOSED_REGEX = re.compile(rf"\{_ERC}([^{_ERC}]*)\{_ERC}")
1819
OFFTOPIC_USERNAME = "pythontelegrambottalk"
1920
ONTOPIC_USERNAME = "pythontelegrambotgroup"
21+
DEV_GROUP_USERNAME = "pythontelegrambotdev"
2022
OFFTOPIC_CHAT_ID = "@" + OFFTOPIC_USERNAME
2123
ONTOPIC_CHAT_ID = "@" + ONTOPIC_USERNAME
2224
ERROR_CHANNEL_CHAT_ID = -1001397960657
2325
TELEGRAM_SUPERSCRIPT = "ᵀᴱᴸᴱᴳᴿᴬᴹ"
2426
FAQ_CHANNEL_ID = "@ptbfaq"
2527
SELF_BOT_NAME = "roolsbot"
26-
ONTOPIC_RULES_MESSAGE_ID = 419903
27-
ONTOPIC_RULES_MESSAGE_LINK = f"https://t.me/{ONTOPIC_USERNAME}/419903"
28-
OFFTOPIC_RULES_MESSAGE_ID = 161133
29-
OFFTOPIC_RULES_MESSAGE_LINK = f"https://t.me/{OFFTOPIC_USERNAME}/161133"
3028
PTBCONTRIB_LINK = "https://github.com/python-telegram-bot/ptbcontrib/"
3129
DOCS_URL = "https://docs.python-telegram-bot.org/"
3230
OFFICIAL_URL = "https://core.telegram.org/bots/api"
@@ -36,8 +34,13 @@
3634
WIKI_FAQ_URL = urljoin(WIKI_URL, "Frequently-Asked-Questions")
3735
WIKI_FRDP_URL = urljoin(WIKI_URL, "Frequently-requested-design-patterns")
3836
EXAMPLES_URL = urljoin(PROJECT_URL, "tree/master/examples/")
39-
ONTOPIC_RULES = f"""
40-
This group is for questions, answers and discussions around the \
37+
ALLOWED_USERNAMES = (OFFTOPIC_USERNAME, ONTOPIC_USERNAME, DEV_GROUP_USERNAME)
38+
ALLOWED_CHAT_IDS = (
39+
ERROR_CHANNEL_CHAT_ID,
40+
-1001494805131, # dev chat
41+
-1001101839433, # Church
42+
)
43+
ONTOPIC_RULES = f"""This group is for questions, answers and discussions around the \
4144
<a href="https://python-telegram-bot.org/">python-telegram-bot library</a> and, to some extent, \
4245
Telegram bots in general.
4346
@@ -59,13 +62,13 @@
5962
Before asking, please take a look at our <a href="{WIKI_URL}">wiki</a> and \
6063
<a href="{EXAMPLES_URL}">example bots</a> or, depending on your question, the \
6164
<a href="{OFFICIAL_URL}">official API docs</a> and <a href="{DOCS_URL}">\
62-
python-telegram-bot docs</a>).
65+
python-telegram-bot docs</a>). Please also make sure to read the <a href="{WIKI_URL}Ask-Right">\
66+
wiki page on how to ask good questions</a>.
6367
For off-topic discussions, please use our <a href="https://t.me/{OFFTOPIC_USERNAME}">\
6468
off-topic group</a>.
6569
"""
6670

67-
OFFTOPIC_RULES = f"""
68-
<b>Topics:</b>
71+
OFFTOPIC_RULES = f"""<b>Topics:</b>
6972
- Discussions about Python in general
7073
- Meta discussions about <code>python-telegram-bot</code>
7174
- Friendly, respectful talking about non-tech topics

‎components/entrytypes.py

+81-78
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
import re
22
from abc import ABC, abstractmethod
3-
from typing import List, Optional
3+
from dataclasses import dataclass
4+
from typing import ClassVar, List, Optional
45

5-
from github3.repos.commit import RepoCommit as GHCommit
6-
from github3.repos import Repository as GHRepo
7-
from github3.issues import Issue as GHIssue
8-
from thefuzz import fuzz
96
from telegram import InlineKeyboardMarkup
7+
from thefuzz import fuzz
108

119
from components.const import (
1210
ARROW_CHARACTER,
13-
TELEGRAM_SUPERSCRIPT,
14-
DEFAULT_REPO_OWNER,
1511
DEFAULT_REPO_NAME,
12+
DEFAULT_REPO_OWNER,
1613
DOCS_URL,
14+
TELEGRAM_SUPERSCRIPT,
1715
)
1816

1917

@@ -382,46 +380,30 @@ def compare_to_query(self, search_query: str) -> float:
382380
return score / 4
383381

384382

383+
@dataclass
385384
class Commit(BaseEntry):
386385
"""A commit on Github
387386
388387
Args:
389-
commit: The github3 commit object
390-
repository: The github3 repository object
388+
owner: str
389+
repo: str
390+
sha: str
391+
url: str
392+
title: str
393+
author: str
391394
"""
392395

393-
def __init__(self, commit: GHCommit, repository: GHRepo) -> None:
394-
self._commit = commit
395-
self._repository = repository
396-
397-
@property
398-
def owner(self) -> str:
399-
return self._repository.owner.login
400-
401-
@property
402-
def repo(self) -> str:
403-
return self._repository.name
404-
405-
@property
406-
def sha(self) -> str:
407-
return self._commit.sha
396+
owner: str
397+
repo: str
398+
sha: str
399+
url: str
400+
title: str
401+
author: str
408402

409403
@property
410404
def short_sha(self) -> str:
411405
return self.sha[:7]
412406

413-
@property
414-
def url(self) -> str:
415-
return self._commit.html_url
416-
417-
@property
418-
def title(self) -> str:
419-
return self._commit.commit["message"]
420-
421-
@property
422-
def author(self) -> str:
423-
return self._commit.author["login"]
424-
425407
@property
426408
def short_name(self) -> str:
427409
return (
@@ -454,45 +436,15 @@ def compare_to_query(self, search_query: str) -> float:
454436
return 0
455437

456438

457-
class Issue(BaseEntry):
458-
"""An issue/PR on Github
459-
460-
Args:
461-
issue: The github3 issue object
462-
repository: The github3 repository object
463-
"""
464-
465-
def __init__(self, issue: GHIssue, repository: GHRepo) -> None:
466-
self._issue = issue
467-
self._repository = repository
468-
469-
@property
470-
def type(self) -> str:
471-
return "Issue" if not self._issue.pull_request_urls else "PR"
472-
473-
@property
474-
def owner(self) -> str:
475-
return self._repository.owner.login
476-
477-
@property
478-
def repo(self) -> str:
479-
return self._repository.name
480-
481-
@property
482-
def number(self) -> int:
483-
return self._issue.number
484-
485-
@property
486-
def url(self) -> str:
487-
return self._issue.html_url
488-
489-
@property
490-
def title(self) -> str:
491-
return self._issue.title
492-
493-
@property
494-
def author(self) -> str:
495-
return self._issue.user.login
439+
@dataclass
440+
class _IssueOrPullRequestOrDiscussion(BaseEntry):
441+
_TYPE: ClassVar = "" # pylint:disable=invalid-name
442+
owner: str
443+
repo: str
444+
number: int
445+
title: str
446+
url: str
447+
author: Optional[str]
496448

497449
@property
498450
def short_name(self) -> str:
@@ -504,7 +456,9 @@ def short_name(self) -> str:
504456

505457
@property
506458
def display_name(self) -> str:
507-
return f"{self.type} {self.short_name}: {self.title} by {self.author}"
459+
if self.author:
460+
return f"{self._TYPE} {self.short_name}: {self.title} by {self.author}"
461+
return f"{self._TYPE} {self.short_name}: {self.title}"
508462

509463
@property
510464
def description(self) -> str:
@@ -515,12 +469,13 @@ def short_description(self) -> str:
515469
# Needs to be here because of cyclical imports
516470
from .util import truncate_str # pylint:disable=import-outside-toplevel
517471

518-
string = f"{self.type} {self.short_name}: {self.title}"
472+
string = f"{self._TYPE} {self.short_name}: {self.title}"
519473
return truncate_str(string, 25)
520474

521-
def html_markup(self, search_query: str = None) -> str:
475+
def html_markup(self, search_query: str = None) -> str: # pylint:disable=unused-argument
522476
return f'<a href="{self.url}">{self.display_name}</a>'
523477

478+
# pylint:disable=unused-argument
524479
def html_insertion_markup(self, search_query: str = None) -> str:
525480
return f'<a href="{self.url}">{self.short_name}</a>'
526481

@@ -534,6 +489,51 @@ def compare_to_query(self, search_query: str) -> float:
534489
return fuzz.token_set_ratio(self.title, search_query)
535490

536491

492+
@dataclass
493+
class Issue(_IssueOrPullRequestOrDiscussion):
494+
"""An issue on GitHub
495+
496+
Args:
497+
number: the number
498+
repo: the repo name
499+
owner: the owner name
500+
url: the url of the issue
501+
title: title of the issue
502+
"""
503+
504+
_TYPE: ClassVar = "Issue"
505+
506+
507+
@dataclass
508+
class PullRequest(_IssueOrPullRequestOrDiscussion):
509+
"""An pullRequest on GitHub
510+
511+
Args:
512+
number: the number
513+
repo: the repo name
514+
owner: the owner name
515+
url: the url of the pull request
516+
title: title of the pull request
517+
"""
518+
519+
_TYPE: ClassVar = "PullRequest"
520+
521+
522+
@dataclass
523+
class Discussion(_IssueOrPullRequestOrDiscussion):
524+
"""A Discussion on GitHub
525+
526+
Args:
527+
number: the number
528+
repo: the repo name
529+
owner: the owner name
530+
url: the url of the pull request
531+
title: title of the pull request
532+
"""
533+
534+
_TYPE: ClassVar = "Discussion"
535+
536+
537537
class PTBContrib(BaseEntry):
538538
"""A contribution of ptbcontrib
539539
@@ -577,6 +577,7 @@ class TagHint(BaseEntry):
577577
description: Description of the tag hint.
578578
default_query: Optional. Inserted into the ``message`` if no other query is provided.
579579
inline_keyboard: Optional. In InlineKeyboardMarkup to attach to the hint.
580+
group_command: Optional. Whether this tag hint should be listed as command in the groups.
580581
"""
581582

582583
def __init__(
@@ -586,12 +587,14 @@ def __init__(
586587
description: str,
587588
default_query: str = None,
588589
inline_keyboard: InlineKeyboardMarkup = None,
590+
group_command: bool = False,
589591
):
590592
self.tag = tag
591593
self._message = message
592594
self._default_query = default_query
593595
self._description = description
594596
self._inline_keyboard = inline_keyboard
597+
self.group_command = group_command
595598

596599
@property
597600
def display_name(self) -> str:

‎components/errorhandler.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
logger = logging.getLogger(__name__)
1414

1515

16-
def error_handler(update: object, context: CallbackContext) -> None:
16+
async def error_handler(update: object, context: CallbackContext) -> None:
1717
"""Log the error and send a telegram message to notify the developer."""
1818
# Log the error before we do anything else, so we can see it even if something breaks.
1919
logger.error(msg="Exception while handling an update:", exc_info=context.error)
@@ -37,14 +37,16 @@ def error_handler(update: object, context: CallbackContext) -> None:
3737
# Finally, send the messages
3838
# We send update and traceback in two parts to reduce the chance of hitting max length
3939
try:
40-
sent_message = context.bot.send_message(chat_id=ERROR_CHANNEL_CHAT_ID, text=message_1)
41-
sent_message.reply_html(message_2)
40+
sent_message = await context.bot.send_message(
41+
chat_id=ERROR_CHANNEL_CHAT_ID, text=message_1
42+
)
43+
await sent_message.reply_html(message_2)
4244
except BadRequest as exc:
4345
if "too long" in str(exc):
4446
message = (
4547
f"Hey.\nThe error <code>{html.escape(str(context.error))}</code> happened."
4648
f" The traceback is too long to send, but it was written to the log."
4749
)
48-
context.bot.send_message(chat_id=ERROR_CHANNEL_CHAT_ID, text=message)
50+
await context.bot.send_message(chat_id=ERROR_CHANNEL_CHAT_ID, text=message)
4951
else:
5052
raise exc

‎components/github.py

+112-216
Original file line numberDiff line numberDiff line change
@@ -1,233 +1,129 @@
1+
import asyncio
12
import logging
2-
import re
3-
import threading
4-
import time
5-
from typing import (
6-
Dict,
7-
Union,
8-
Optional,
9-
no_type_check,
10-
List,
11-
Pattern,
12-
Tuple,
13-
Any,
14-
cast,
15-
Iterable,
16-
)
17-
18-
from github3.repos.contents import Contents
19-
from github3 import login, GitHub
20-
from github3.exceptions import GitHubException
21-
from github3.repos import Repository as GHRepo
22-
from github3.structs import GitHubIterator
23-
from telegram.ext import JobQueue
24-
25-
from components.const import (
26-
DEFAULT_REPO_OWNER,
27-
DEFAULT_REPO_NAME,
28-
PTBCONTRIB_REPO_NAME,
29-
EXAMPLES_URL,
30-
)
31-
from components.entrytypes import Issue, PTBContrib, Commit
32-
33-
34-
class RepoDict(Dict[str, GHRepo]):
35-
def __init__(self, owner: str, session: GitHub):
36-
super().__init__()
37-
self._owner = owner
38-
self._session = session
39-
40-
def __missing__(self, key: str) -> GHRepo:
41-
if key not in self:
42-
self[key] = self._session.repository(self._owner, key)
43-
return self[key]
44-
45-
def update_session(self, session: GitHub) -> None:
46-
self._session = session
47-
48-
49-
class GitHubIssues:
50-
def __init__(
51-
self, default_owner: str = DEFAULT_REPO_OWNER, default_repo: str = DEFAULT_REPO_NAME
52-
) -> None:
53-
self.session = GitHub()
54-
self.default_owner = default_owner
55-
self.default_repo = default_repo
56-
57-
self.logger = logging.getLogger(self.__class__.__qualname__)
58-
59-
self.repos = RepoDict(self.default_owner, self.session)
3+
from typing import Dict, Iterable, List, Optional, Union
4+
5+
from graphql import GraphQLError
6+
7+
from components.const import DEFAULT_REPO_NAME, DEFAULT_REPO_OWNER, USER_AGENT
8+
from components.entrytypes import Commit, Discussion, Example, Issue, PTBContrib, PullRequest
9+
from components.graphqlclient import GraphQLClient
10+
11+
12+
class GitHub:
13+
def __init__(self, auth: str, user_agent: str = USER_AGENT) -> None:
14+
self._gql_client = GraphQLClient(auth=auth, user_agent=user_agent)
15+
16+
self._logger = logging.getLogger(self.__class__.__qualname__)
17+
18+
self.__lock = asyncio.Lock()
6019
self.issues: Dict[int, Issue] = {}
20+
self.pull_requests: Dict[int, PullRequest] = {}
21+
self.discussions: Dict[int, Discussion] = {}
6122
self.issue_iterator: Optional[Iterable[Issue]] = None
62-
self.ptbcontribs: Dict[str, PTBContrib] = {}
63-
self.issues_lock = threading.Lock()
64-
self.ptbcontrib_lock = threading.Lock()
23+
self.ptb_contribs: Dict[str, PTBContrib] = {}
24+
self.examples: Dict[str, Example] = {}
6525

66-
def set_auth(self, client_id: str, client_secret: str) -> None:
67-
self.session = login(client_id, client_secret)
68-
self.repos.update_session(self.session)
26+
async def initialize(self) -> None:
27+
await self._gql_client.initialize()
28+
29+
async def shutdown(self) -> None:
30+
await self._gql_client.shutdown()
6931

7032
@property
7133
def all_ptbcontribs(self) -> List[PTBContrib]:
72-
with self.ptbcontrib_lock:
73-
return list(self.ptbcontribs.values())
34+
return list(self.ptb_contribs.values())
7435

7536
@property
7637
def all_issues(self) -> List[Issue]:
77-
with self.issues_lock:
78-
return list(self.issues.values())
79-
80-
def get_issue(self, number: int, owner: str = None, repo: str = None) -> Optional[Issue]:
81-
if owner or repo:
82-
self.logger.info(
83-
"Getting issue %d for %s/%s",
84-
number,
85-
owner or self.default_owner,
86-
repo or self.default_repo,
87-
)
88-
try:
89-
if owner is not None:
90-
repository = self.session.repository(owner, repo or self.default_repo)
91-
gh_issue = repository.issue(number)
92-
else:
93-
repository = self.repos[repo or self.default_repo]
94-
if repo is None:
95-
if issue := self.issues.get(number):
96-
return issue
97-
gh_issue = repository.issue(number)
98-
else:
99-
gh_issue = repository.issue(number)
100-
issue = Issue(gh_issue, repository)
101-
102-
if repo is None:
103-
self.issues[number] = issue
104-
105-
return issue
106-
except GitHubException:
107-
return None
38+
return list(self.issues.values())
10839

109-
@no_type_check
110-
def get_commit(
111-
self, sha: Union[int, str], owner: str = None, repo: str = None
112-
) -> Optional[Commit]:
113-
if owner or repo:
114-
self.logger.info(
115-
"Getting commit %s for %s/%s",
116-
sha[:7],
117-
owner or self.default_owner,
118-
repo or self.default_repo,
119-
)
120-
try:
121-
if owner is not None:
122-
repository = self.session.repository(owner, repo or self.default_repo)
123-
gh_commit = repository.commit(sha)
124-
else:
125-
repository = self.repos[repo or self.default_repo]
126-
if repo is None:
127-
if commit := self.issues.get(sha):
128-
return commit
129-
gh_commit = sha, repository.commit(sha)
130-
else:
131-
gh_commit = repository.commit(sha)
132-
return Commit(gh_commit, repository)
133-
except GitHubException:
134-
return None
40+
@property
41+
def all_pull_requests(self) -> List[PullRequest]:
42+
return list(self.pull_requests.values())
13543

136-
def _job(self, job_queue: JobQueue) -> None:
137-
self.logger.info("Getting issues for default repo.")
44+
@property
45+
def all_discussions(self) -> List[Discussion]:
46+
return list(self.discussions.values())
13847

48+
@property
49+
def all_examples(self) -> List[Example]:
50+
return list(self.examples.values())
51+
52+
async def update_examples(self) -> None:
53+
self._logger.info("Getting examples")
54+
examples = await self._gql_client.get_examples()
55+
async with self.__lock:
56+
self.examples.clear()
57+
for example in examples:
58+
self.examples[example.short_name] = example
59+
60+
async def update_ptb_contribs(self) -> None:
61+
self._logger.info("Getting ptbcontribs")
62+
ptb_contribs = await self._gql_client.get_ptb_contribs()
63+
async with self.__lock:
64+
self.ptb_contribs.clear()
65+
for ptb_contrib in ptb_contribs:
66+
self.ptb_contribs[ptb_contrib.short_name.split("/")[1]] = ptb_contrib
67+
68+
async def update_issues(self, cursor: str = None) -> Optional[str]:
69+
self._logger.info("Getting 100 issues before cursor %s", cursor)
70+
issues, cursor = await self._gql_client.get_issues(cursor=cursor)
71+
async with self.__lock:
72+
for issue in issues:
73+
self.issues[issue.number] = issue
74+
return cursor
75+
76+
async def update_pull_requests(self, cursor: str = None) -> Optional[str]:
77+
self._logger.info("Getting 100 pull requests before cursor %s", cursor)
78+
pull_requests, cursor = await self._gql_client.get_pull_requests(cursor=cursor)
79+
async with self.__lock:
80+
for pull_request in pull_requests:
81+
self.pull_requests[pull_request.number] = pull_request
82+
return cursor
83+
84+
async def update_discussions(self, cursor: str = None) -> Optional[str]:
85+
self._logger.info("Getting 100 discussions before cursor %s", cursor)
86+
discussions, cursor = await self._gql_client.get_discussions(cursor=cursor)
87+
async with self.__lock:
88+
for discussion in discussions:
89+
self.discussions[discussion.number] = discussion
90+
return cursor
91+
92+
async def get_thread(
93+
self, number: int, owner: str = DEFAULT_REPO_OWNER, repo: str = DEFAULT_REPO_NAME
94+
) -> Union[Issue, PullRequest, Discussion, None]:
95+
if owner != DEFAULT_REPO_OWNER or repo != DEFAULT_REPO_NAME:
96+
self._logger.info("Getting issue %d for %s/%s", number, owner, repo)
13997
try:
140-
repo = self.repos[self.default_repo]
141-
if self.issue_iterator is None:
142-
self.issue_iterator = self.repos[self.default_repo].issues(state="all")
143-
else:
144-
# The GitHubIterator automatically takes care of passing the ETag
145-
# which reduces the number of API requests that count towards the rate limit
146-
cast(GitHubIterator, self.issue_iterator).refresh(True)
147-
148-
for i, gh_issue in enumerate(self.issue_iterator):
149-
# Acquire lock so we don't add while a func (like self.search) is iterating over it
150-
# We do this for ever single issue instead of before the for-loop, because that
151-
# would block self.search during the loop which takes a while
152-
with self.issues_lock:
153-
self.issues[gh_issue.number] = Issue(gh_issue, repo)
154-
# Sleeping a moment after 100 issues to give the API some rest - we're not in a
155-
# hurry. The 100 is the max. per page number and as of 2.0.0 what github3.py
156-
# uses. sleeping doesn't block the bot, as jobs run in their own thread.
157-
# This is outside the lock! (see above commit)
158-
if (i + 1) % 100 == 0:
159-
self.logger.info("Done with %d issues. Sleeping a moment.", i + 1)
160-
time.sleep(10)
161-
162-
# Rerun in 20 minutes
163-
job_queue.run_once(lambda _: self._job(job_queue), 60 * 20)
164-
except GitHubException as exc:
165-
if "rate limit" in str(exc):
166-
self.logger.warning("GH API rate limit exceeded. Retrying in 70 minutes.")
167-
job_queue.run_once(lambda _: self._job(job_queue), 60 * 70)
168-
else:
169-
self.logger.exception(
170-
"Something went wrong fetching issues. Retrying in 10s.", exc_info=exc
171-
)
172-
job_queue.run_once(lambda _: self._job(job_queue), 10)
173-
174-
def init_issues(self, job_queue: JobQueue) -> None:
175-
job_queue.run_once(lambda _: self._job(job_queue), 10)
176-
177-
def _ptbcontrib_job(self, job_queue: JobQueue) -> None:
178-
self.logger.info("Getting ptbcontrib data.")
98+
thread = await self._gql_client.get_thread(
99+
number=number, organization=owner, repository=repo
100+
)
179101

102+
if owner == DEFAULT_REPO_OWNER and repo == DEFAULT_REPO_NAME:
103+
async with self.__lock:
104+
if isinstance(thread, Issue):
105+
self.issues[thread.number] = thread
106+
if isinstance(thread, PullRequest):
107+
self.pull_requests[thread.number] = thread
108+
if isinstance(thread, Discussion):
109+
self.discussions[thread.number] = thread
110+
111+
return thread
112+
except GraphQLError as exc:
113+
self._logger.exception(
114+
"Error while getting issue %d for %s/%s", number, owner, repo, exc_info=exc
115+
)
116+
return None
117+
118+
async def get_commit(
119+
self, sha: str, owner: str = DEFAULT_REPO_OWNER, repo: str = DEFAULT_REPO_NAME
120+
) -> Optional[Commit]:
121+
if owner != DEFAULT_REPO_OWNER or repo != DEFAULT_REPO_NAME:
122+
self._logger.info("Getting commit %s for %s/%s", sha[:7], owner, repo)
180123
try:
181-
files = cast(
182-
List[Tuple[str, Contents]],
183-
self.repos[PTBCONTRIB_REPO_NAME].directory_contents(PTBCONTRIB_REPO_NAME),
124+
return await self._gql_client.get_commit(sha=sha, organization=owner, repository=repo)
125+
except GraphQLError as exc:
126+
self._logger.exception(
127+
"Error while getting commit %s for %s/%s", sha[:7], owner, repo, exc_info=exc
184128
)
185-
with self.ptbcontrib_lock:
186-
self.ptbcontribs.clear()
187-
self.ptbcontribs.update(
188-
{
189-
name: PTBContrib(name, content.html_url)
190-
for name, content in files
191-
if content.type == "dir"
192-
}
193-
)
194-
195-
# Rerun in two hours minutes
196-
job_queue.run_once(lambda _: self._ptbcontrib_job(job_queue), 2 * 60 * 60)
197-
except GitHubException as exc:
198-
if "rate limit" in str(exc):
199-
self.logger.warning("GH API rate limit exceeded. Retrying in 70 minutes.")
200-
job_queue.run_once(lambda _: self._ptbcontrib_job(job_queue), 60 * 70)
201-
else:
202-
self.logger.exception(
203-
"Something went wrong fetching issues. Retrying in 10s.", exc_info=exc
204-
)
205-
job_queue.run_once(lambda _: self._ptbcontrib_job(job_queue), 10)
206-
207-
def init_ptb_contribs(self, job_queue: JobQueue) -> None:
208-
job_queue.run_once(lambda _: self._ptbcontrib_job(job_queue), 5)
209-
210-
@staticmethod
211-
def _build_example_url(example_file_name: str) -> str:
212-
return f'{EXAMPLES_URL}#{example_file_name.replace(".", "")}'
213-
214-
def get_examples_directory(self, pattern: Union[str, Pattern] = None) -> List[Tuple[str, str]]:
215-
if isinstance(pattern, str):
216-
effective_pattern: Optional[Pattern[Any]] = re.compile(pattern)
217-
else:
218-
effective_pattern = pattern
219-
220-
files = cast(
221-
List[Tuple[str, Contents]],
222-
self.repos[self.default_repo].directory_contents("examples"),
223-
)
224-
if effective_pattern is None:
225-
return [(name, self._build_example_url(name)) for name, _ in files]
226-
return [
227-
(name, self._build_example_url(name))
228-
for name, _ in files
229-
if effective_pattern.search(name)
230-
]
231-
232-
233-
github_issues = GitHubIssues()
129+
return None
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
query getCommit($sha: String!, $organization: String = "python-telegram-bot", $repository: String = "python-telegram-bot") {
2+
repository(owner: $organization, name: $repository) {
3+
object(expression: $sha) {
4+
... on Commit {
5+
author {
6+
user {
7+
login
8+
url
9+
}
10+
}
11+
url
12+
message
13+
oid
14+
}
15+
}
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
query getDiscussions($cursor: String) {
2+
repository(owner: "python-telegram-bot", name: "python-telegram-bot") {
3+
discussions(last: 100, before: $cursor) {
4+
nodes {
5+
number
6+
title
7+
url
8+
author {
9+
login
10+
url
11+
}
12+
}
13+
pageInfo {
14+
hasPreviousPage
15+
startCursor
16+
}
17+
}
18+
}
19+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
query getExamples {
2+
repository(owner: "python-telegram-bot", name: "python-telegram-bot") {
3+
object(expression: "master:examples") {
4+
... on Tree {
5+
entries {
6+
name
7+
}
8+
}
9+
}
10+
}
11+
}
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
query getIssues($cursor: String) {
2+
repository(owner: "python-telegram-bot", name: "python-telegram-bot") {
3+
issues(last: 100, before: $cursor) {
4+
nodes {
5+
number
6+
title
7+
url
8+
author {
9+
login
10+
url
11+
}
12+
}
13+
pageInfo {
14+
hasPreviousPage
15+
startCursor
16+
}
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
query getPTBContribs {
2+
repository(owner: "python-telegram-bot", name: "ptbcontrib") {
3+
object(expression: "main:ptbcontrib") {
4+
... on Tree {
5+
entries {
6+
name
7+
type
8+
}
9+
}
10+
}
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
query getPullRequests($cursor: String) {
2+
repository(owner: "python-telegram-bot", name: "python-telegram-bot") {
3+
pullRequests(last: 100, before: $cursor) {
4+
nodes {
5+
number
6+
title
7+
url
8+
author {
9+
login
10+
url
11+
}
12+
}
13+
pageInfo {
14+
hasPreviousPage
15+
startCursor
16+
}
17+
}
18+
}
19+
}
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
query getThread($number: Int!, $organization: String = "python-telegram-bot", $repository: String = "python-telegram-bot") {
2+
repository(owner: $organization, name: $repository) {
3+
issueOrPullRequest(number: $number) {
4+
... on Issue {
5+
number
6+
url
7+
title
8+
author {
9+
login
10+
url
11+
}
12+
__typename
13+
}
14+
... on PullRequest {
15+
number
16+
url
17+
title
18+
author {
19+
login
20+
url
21+
}
22+
__typename
23+
}
24+
}
25+
discussion(number: $number) {
26+
number
27+
url
28+
title
29+
author {
30+
login
31+
url
32+
}
33+
}
34+
}
35+
}

‎components/graphqlclient.py

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from pathlib import Path
2+
from typing import Any, Dict, List, Optional, Tuple, Union
3+
4+
from gql import Client, gql
5+
from gql.client import AsyncClientSession
6+
from gql.transport.aiohttp import AIOHTTPTransport
7+
from gql.transport.exceptions import TransportQueryError
8+
9+
from components.const import DEFAULT_REPO_NAME, DEFAULT_REPO_OWNER, PTBCONTRIB_LINK, USER_AGENT
10+
from components.entrytypes import Commit, Discussion, Example, Issue, PTBContrib, PullRequest
11+
12+
13+
class GraphQLClient:
14+
def __init__(self, auth: str, user_agent: str = USER_AGENT) -> None:
15+
self._transport = AIOHTTPTransport(
16+
url="https://api.github.com/graphql",
17+
headers={
18+
"Authorization": auth,
19+
"user-agent": user_agent,
20+
},
21+
)
22+
self._session = AsyncClientSession(Client(transport=self._transport))
23+
24+
async def initialize(self) -> None:
25+
await self._transport.connect()
26+
27+
async def shutdown(self) -> None:
28+
await self._transport.close()
29+
30+
async def _do_request(
31+
self, query_name: str, variable_values: Dict[str, Any] = None
32+
) -> Dict[str, Any]:
33+
return await self._session.execute(
34+
gql(Path(f"components/graphql_queries/{query_name}.gql").read_text(encoding="utf-8")),
35+
variable_values=variable_values,
36+
)
37+
38+
async def get_examples(self) -> List[Example]:
39+
"""The all examples on the master branch"""
40+
result = await self._do_request("getExamples")
41+
return [
42+
Example(name=file["name"])
43+
for file in result["repository"]["object"]["entries"]
44+
if file["name"].endswith(".py")
45+
]
46+
47+
async def get_ptb_contribs(self) -> List[PTBContrib]:
48+
"""The all ptb_contribs on the main branch"""
49+
result = await self._do_request("getPTBContribs")
50+
return [
51+
PTBContrib(name=contrib["name"], url=f"{PTBCONTRIB_LINK}tree/main/{contrib['name']}")
52+
for contrib in result["repository"]["object"]["entries"]
53+
if contrib["type"] == "tree"
54+
]
55+
56+
async def get_thread(
57+
self,
58+
number: int,
59+
organization: str = DEFAULT_REPO_OWNER,
60+
repository: str = DEFAULT_REPO_NAME,
61+
) -> Union[Issue, PullRequest, Discussion]:
62+
"""Get a specific thread (issue/pr/discussion) on any repository. By default, ptb/ptb
63+
will be searched"""
64+
# The try-except is needed because we query for both issueOrPR & discussion at the same
65+
# time, but it will only ever be one of them. Unfortunately we don't know which one …
66+
try:
67+
result = await self._do_request(
68+
"getThread",
69+
variable_values={
70+
"number": number,
71+
"organization": organization,
72+
"repository": repository,
73+
},
74+
)
75+
except TransportQueryError as exc:
76+
# … but the exc.data will contain the thread that is available
77+
if not exc.data:
78+
raise exc
79+
result = exc.data
80+
81+
data = result["repository"]
82+
thread_data = data["issueOrPullRequest"] or data["discussion"]
83+
84+
entry_type_data = dict(
85+
owner=organization,
86+
repo=repository,
87+
number=number,
88+
title=thread_data["title"],
89+
url=thread_data["url"],
90+
author=thread_data["author"]["login"],
91+
)
92+
93+
if thread_data.get("__typename") == "Issue":
94+
return Issue(**entry_type_data)
95+
if thread_data.get("__typename") == "PullRequest":
96+
return PullRequest(**entry_type_data)
97+
return Discussion(**entry_type_data)
98+
99+
async def get_commit(
100+
self,
101+
sha: str,
102+
organization: str = DEFAULT_REPO_OWNER,
103+
repository: str = DEFAULT_REPO_NAME,
104+
) -> Commit:
105+
"""Get a specific commit on any repository. By default, ptb/ptb
106+
will be searched"""
107+
result = await self._do_request(
108+
"getCommit",
109+
variable_values={
110+
"sha": sha,
111+
"organization": organization,
112+
"repository": repository,
113+
},
114+
)
115+
data = result["repository"]["object"]
116+
return Commit(
117+
owner=organization,
118+
repo=repository,
119+
sha=data["oid"],
120+
url=data["url"],
121+
title=data["message"],
122+
author=data["author"]["user"]["login"],
123+
)
124+
125+
async def get_issues(self, cursor: str = None) -> Tuple[List[Issue], Optional[str]]:
126+
"""Last 100 issues before cursor"""
127+
result = await self._do_request("getIssues", variable_values={"cursor": cursor})
128+
return [
129+
Issue(
130+
owner=DEFAULT_REPO_OWNER,
131+
repo=DEFAULT_REPO_NAME,
132+
number=issue["number"],
133+
title=issue["title"],
134+
url=issue["url"],
135+
author=issue["author"]["login"] if issue["author"] else None,
136+
)
137+
for issue in result["repository"]["issues"]["nodes"]
138+
], result["repository"]["issues"]["pageInfo"]["startCursor"]
139+
140+
async def get_pull_requests(
141+
self, cursor: str = None
142+
) -> Tuple[List[PullRequest], Optional[str]]:
143+
"""Last 100 pull requests before cursor"""
144+
result = await self._do_request("getPullRequests", variable_values={"cursor": cursor})
145+
return [
146+
PullRequest(
147+
owner=DEFAULT_REPO_OWNER,
148+
repo=DEFAULT_REPO_NAME,
149+
number=pull_request["number"],
150+
title=pull_request["title"],
151+
url=pull_request["url"],
152+
author=pull_request["author"]["login"] if pull_request["author"] else None,
153+
)
154+
for pull_request in result["repository"]["pullRequests"]["nodes"]
155+
], result["repository"]["pullRequests"]["pageInfo"]["startCursor"]
156+
157+
async def get_discussions(self, cursor: str = None) -> Tuple[List[Discussion], Optional[str]]:
158+
"""Last 100 discussions before cursor"""
159+
result = await self._do_request("getDiscussions", variable_values={"cursor": cursor})
160+
return [
161+
Discussion(
162+
owner=DEFAULT_REPO_OWNER,
163+
repo=DEFAULT_REPO_NAME,
164+
number=discussion["number"],
165+
title=discussion["title"],
166+
url=discussion["url"],
167+
author=discussion["author"]["login"] if discussion["author"] else None,
168+
)
169+
for discussion in result["repository"]["discussions"]["nodes"]
170+
], result["repository"]["discussions"]["pageInfo"]["startCursor"]

‎components/inlinequeries.py

+13-13
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,18 @@
33
from uuid import uuid4
44

55
from telegram import (
6+
InlineKeyboardMarkup,
7+
InlineQuery,
68
InlineQueryResultArticle,
79
InputTextMessageContent,
810
Update,
9-
InlineQuery,
10-
InlineKeyboardMarkup,
1111
)
1212
from telegram.error import BadRequest
13-
from telegram.ext import CallbackContext
13+
from telegram.ext import ContextTypes
1414

15-
from components.const import (
16-
ENCLOSED_REGEX,
17-
ENCLOSING_REPLACEMENT_CHARACTER,
18-
)
15+
from components.const import ENCLOSED_REGEX, ENCLOSING_REPLACEMENT_CHARACTER
1916
from components.entrytypes import Issue
20-
from components.search import search
17+
from components.search import Search
2118

2219

2320
def article(
@@ -36,15 +33,18 @@ def article(
3633
)
3734

3835

39-
def inline_query(update: Update, _: CallbackContext) -> None: # pylint: disable=R0915
36+
async def inline_query(
37+
update: Update, context: ContextTypes.DEFAULT_TYPE
38+
) -> None: # pylint: disable=R0915
4039
ilq = cast(InlineQuery, update.inline_query)
4140
query = ilq.query
4241
switch_pm_text = "❓ Help"
42+
search = cast(Search, context.bot_data["search"])
4343

4444
if ENCLOSED_REGEX.search(query):
4545
results_list = []
4646
symbols = tuple(ENCLOSED_REGEX.findall(query))
47-
search_results = search.multi_search_combinations(symbols)
47+
search_results = await search.multi_search_combinations(symbols)
4848

4949
for combination in search_results:
5050
description = ", ".join(entry.short_description for entry in combination.values())
@@ -78,7 +78,7 @@ def inline_query(update: Update, _: CallbackContext) -> None: # pylint: disable
7878
)
7979
)
8080
else:
81-
simple_search_results = search.search(query)
81+
simple_search_results = await search.search(query)
8282
if not simple_search_results:
8383
results_list = []
8484
switch_pm_text = "❌ No Search Results Found"
@@ -94,7 +94,7 @@ def inline_query(update: Update, _: CallbackContext) -> None: # pylint: disable
9494
]
9595

9696
try:
97-
ilq.answer(
97+
await ilq.answer(
9898
results=results_list,
9999
switch_pm_text=switch_pm_text,
100100
switch_pm_parameter="inline-help",
@@ -104,7 +104,7 @@ def inline_query(update: Update, _: CallbackContext) -> None: # pylint: disable
104104
except BadRequest as exc:
105105
if "can't parse entities" not in exc.message:
106106
raise exc
107-
ilq.answer(
107+
await ilq.answer(
108108
results=[],
109109
switch_pm_text="❌ Invalid entities. Click me.",
110110
switch_pm_parameter="inline-entity-parsing",

‎components/search.py

+129-99
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,126 @@
1-
import functools
1+
import asyncio
2+
import datetime
23
import heapq
34
import itertools
4-
5-
from datetime import date
6-
7-
from threading import Lock
8-
from typing import List, Tuple, Dict, Callable, Any, Optional, Iterable
5+
from io import BytesIO
6+
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
97
from urllib.parse import urljoin
10-
from urllib.request import urlopen, Request
118

9+
import httpx
10+
from async_lru import alru_cache
1211
from bs4 import BeautifulSoup
1312
from sphinx.util.inventory import InventoryFile
13+
from telegram.ext import Application, ContextTypes, Job, JobQueue
1414

1515
from .const import (
16-
USER_AGENT,
16+
DEFAULT_HEADERS,
17+
DEFAULT_REPO_NAME,
18+
DEFAULT_REPO_OWNER,
1719
DOCS_URL,
20+
EXAMPLES_URL,
21+
GITHUB_PATTERN,
1822
OFFICIAL_URL,
19-
WIKI_URL,
23+
USER_AGENT,
2024
WIKI_CODE_SNIPPETS_URL,
2125
WIKI_FAQ_URL,
22-
EXAMPLES_URL,
23-
GITHUB_PATTERN,
2426
WIKI_FRDP_URL,
27+
WIKI_URL,
2528
)
2629
from .entrytypes import (
27-
WikiPage,
28-
Example,
30+
BaseEntry,
2931
CodeSnippet,
30-
FAQEntry,
3132
DocEntry,
32-
BaseEntry,
33+
FAQEntry,
3334
FRDPEntry,
3435
ParamDocEntry,
36+
WikiPage,
3537
)
36-
from .github import github_issues
38+
from .github import GitHub
3739
from .taghints import TAG_HINTS
3840

3941

40-
def cached_parsing(func: Callable[..., Any]) -> Callable[..., Any]:
41-
@functools.wraps(func)
42-
def checking_cache_time(self: "Search", *args: Any, **kwargs: Any) -> Any:
43-
if date.today() > self.last_cache_date:
44-
self.fetch_entries()
45-
self.last_cache_date = date.today()
46-
return func(self, *args, **kwargs)
47-
48-
return checking_cache_time
49-
50-
5142
class Search:
52-
def __init__(self) -> None:
53-
self.__lock = Lock()
43+
def __init__(self, github_auth: str, github_user_agent: str = USER_AGENT) -> None:
44+
self.__lock = asyncio.Lock()
5445
self._docs: List[DocEntry] = []
5546
self._official: Dict[str, str] = {}
5647
self._wiki: List[WikiPage] = []
57-
self._examples: List[Example] = []
5848
self._snippets: List[CodeSnippet] = []
5949
self._faq: List[FAQEntry] = []
6050
self._design_patterns: List[FRDPEntry] = []
61-
self.last_cache_date = date.today()
62-
self.github_session = github_issues
63-
self.fetch_entries()
64-
65-
def fetch_entries(self) -> None:
66-
with self.__lock:
67-
self.fetch_docs()
68-
self.fetch_wiki()
69-
self.fetch_examples()
70-
self.fetch_wiki_code_snippets()
71-
self.fetch_wiki_faq()
72-
self.fetch_wiki_design_patterns()
73-
74-
# This is important: If the docs have changed the cache is useless
75-
self.search.cache_clear()
76-
self.multi_search_combinations.cache_clear()
77-
78-
def fetch_official_docs(self) -> None:
79-
request = Request(OFFICIAL_URL, headers={"User-Agent": USER_AGENT})
80-
with urlopen(request) as file:
81-
official_soup = BeautifulSoup(file, "html.parser")
51+
self.github = GitHub(auth=github_auth, user_agent=github_user_agent)
52+
self._httpx_client = httpx.AsyncClient()
53+
54+
async def initialize(
55+
self, application: Application[Any, Any, Any, Any, Any, JobQueue]
56+
) -> None:
57+
await self.github.initialize()
58+
application.job_queue.run_once(callback=self.update_job, when=1, data=(None, None, None))
59+
60+
async def shutdown(self) -> None:
61+
await self.github.shutdown()
62+
await self._httpx_client.aclose()
63+
await self.search.close() # pylint:disable=no-member
64+
await self.multi_search_combinations.close() # pylint:disable=no-member
65+
66+
async def update_job(self, context: ContextTypes.DEFAULT_TYPE) -> None:
67+
job = cast(Job, context.job)
68+
cursors = cast(Tuple[Optional[str], Optional[str], Optional[str]], job.data)
69+
restart = not any(cursors)
70+
71+
if restart:
72+
await asyncio.gather(
73+
context.application.create_task(self.github.update_examples()),
74+
context.application.create_task(self.github.update_ptb_contribs()),
75+
)
76+
async with self.__lock:
77+
await asyncio.gather(
78+
context.application.create_task(self.update_docs()),
79+
context.application.create_task(self.update_wiki()),
80+
context.application.create_task(self.update_wiki_code_snippets()),
81+
context.application.create_task(self.update_wiki_faq()),
82+
context.application.create_task(self.update_wiki_design_patterns()),
83+
)
84+
85+
issue_cursor = (
86+
await self.github.update_issues(cursor=cursors[0]) if restart or cursors[0] else None
87+
)
88+
pr_cursor = (
89+
await self.github.update_pull_requests(cursor=cursors[1])
90+
if restart or cursors[1]
91+
else None
92+
)
93+
discussion_cursor = (
94+
await self.github.update_discussions(cursor=cursors[2])
95+
if restart or cursors[2]
96+
else None
97+
)
98+
99+
new_cursors = (issue_cursor, pr_cursor, discussion_cursor)
100+
when = datetime.timedelta(seconds=30) if any(new_cursors) else datetime.timedelta(hours=12)
101+
cast(JobQueue, context.job_queue).run_once(
102+
callback=self.update_job, when=when, data=new_cursors
103+
)
104+
105+
# This is important: If the docs have changed the cache is useless
106+
self.search.cache_clear() # pylint:disable=no-member
107+
self.multi_search_combinations.cache_clear() # pylint:disable=no-member
108+
109+
async def _update_official_docs(self) -> None:
110+
response = await self._httpx_client.get(url=OFFICIAL_URL, headers=DEFAULT_HEADERS)
111+
official_soup = BeautifulSoup(response.content, "html.parser")
82112
for anchor in official_soup.select("a.anchor"):
83113
if "-" not in anchor["href"]:
84114
self._official[anchor["href"][1:]] = anchor.next_sibling
85115

86-
def fetch_docs(self) -> None:
87-
self.fetch_official_docs()
88-
request = Request(urljoin(DOCS_URL, "objects.inv"), headers={"User-Agent": USER_AGENT})
89-
with urlopen(request) as docs_data:
90-
data = InventoryFile.load(docs_data, DOCS_URL, urljoin)
116+
async def update_docs(self) -> None:
117+
await self._update_official_docs()
118+
response = await self._httpx_client.get(
119+
url=urljoin(DOCS_URL, "objects.inv"),
120+
headers=DEFAULT_HEADERS,
121+
follow_redirects=True,
122+
)
123+
data = InventoryFile.load(BytesIO(response.content), DOCS_URL, urljoin)
91124
self._docs = []
92125
for entry_type, items in data.items():
93126
for name, (_, _, url, display_name) in items.items():
@@ -99,11 +132,12 @@ def fetch_docs(self) -> None:
99132
tg_url, tg_test, tg_name = "", "", ""
100133
name_bits = name.split(".")
101134

102-
if entry_type in ["py:method", "py:attribute"]:
103-
if "telegram.Bot" in name or "telegram.ext.ExtBot" in name:
104-
tg_test = name_bits[-1]
105-
else:
106-
tg_test = name_bits[-2]
135+
if entry_type == "py:method" and (
136+
"telegram.Bot" in name or "telegram.ext.ExtBot" in name
137+
):
138+
tg_test = name_bits[-1]
139+
if entry_type == "py:attribute":
140+
tg_test = name_bits[-2]
107141
if entry_type == "py:class":
108142
tg_test = name_bits[-1]
109143
elif entry_type == "py:parameter":
@@ -138,10 +172,9 @@ def fetch_docs(self) -> None:
138172
)
139173
)
140174

141-
def fetch_wiki(self) -> None:
142-
request = Request(WIKI_URL, headers={"User-Agent": USER_AGENT})
143-
with urlopen(request) as file:
144-
wiki_soup = BeautifulSoup(file, "html.parser")
175+
async def update_wiki(self) -> None:
176+
response = await self._httpx_client.get(url=WIKI_URL, headers=DEFAULT_HEADERS)
177+
wiki_soup = BeautifulSoup(response.content, "html.parser")
145178
self._wiki = []
146179

147180
# Parse main pages from custom sidebar
@@ -160,10 +193,11 @@ def fetch_wiki(self) -> None:
160193

161194
self._wiki.append(WikiPage(category="Code Resources", name="Examples", url=EXAMPLES_URL))
162195

163-
def fetch_wiki_code_snippets(self) -> None:
164-
request = Request(WIKI_CODE_SNIPPETS_URL, headers={"User-Agent": USER_AGENT})
165-
with urlopen(request) as file:
166-
code_snippet_soup = BeautifulSoup(file, "html.parser")
196+
async def update_wiki_code_snippets(self) -> None:
197+
response = await self._httpx_client.get(
198+
url=WIKI_CODE_SNIPPETS_URL, headers=DEFAULT_HEADERS
199+
)
200+
code_snippet_soup = BeautifulSoup(response.content, "html.parser")
167201
self._snippets = []
168202
for headline in code_snippet_soup.select(
169203
"div#wiki-body h4,div#wiki-body h3,div#wiki-body h2"
@@ -175,20 +209,18 @@ def fetch_wiki_code_snippets(self) -> None:
175209
)
176210
)
177211

178-
def fetch_wiki_faq(self) -> None:
179-
request = Request(WIKI_FAQ_URL, headers={"User-Agent": USER_AGENT})
180-
with urlopen(request) as file:
181-
faq_soup = BeautifulSoup(file, "html.parser")
212+
async def update_wiki_faq(self) -> None:
213+
response = await self._httpx_client.get(url=WIKI_FAQ_URL, headers=DEFAULT_HEADERS)
214+
faq_soup = BeautifulSoup(response.content, "html.parser")
182215
self._faq = []
183216
for headline in faq_soup.select("div#wiki-body h3"):
184217
self._faq.append(
185218
FAQEntry(name=headline.text.strip(), url=urljoin(WIKI_FAQ_URL, headline.a["href"]))
186219
)
187220

188-
def fetch_wiki_design_patterns(self) -> None:
189-
request = Request(WIKI_FRDP_URL, headers={"User-Agent": USER_AGENT})
190-
with urlopen(request) as file:
191-
frdp_soup = BeautifulSoup(file, "html.parser")
221+
async def update_wiki_design_patterns(self) -> None:
222+
response = await self._httpx_client.get(url=WIKI_FRDP_URL, headers=DEFAULT_HEADERS)
223+
frdp_soup = BeautifulSoup(response.content, "html.parser")
192224
self._design_patterns = []
193225
for headline in frdp_soup.select("div#wiki-body h3,div#wiki-body h2"):
194226
self._design_patterns.append(
@@ -197,18 +229,14 @@ def fetch_wiki_design_patterns(self) -> None:
197229
)
198230
)
199231

200-
def fetch_examples(self) -> None:
201-
self._examples = []
202-
for name, _ in self.github_session.get_examples_directory(r"^.*\.py"):
203-
self._examples.append(Example(name=name))
204-
205232
@staticmethod
206233
def _sort_key(entry: BaseEntry, search_query: str) -> float:
207234
return entry.compare_to_query(search_query)
208235

209-
@functools.lru_cache(maxsize=64)
210-
@cached_parsing
211-
def search(self, search_query: Optional[str], amount: int = None) -> Optional[List[BaseEntry]]:
236+
@alru_cache(maxsize=64) # type: ignore[misc]
237+
async def search(
238+
self, search_query: Optional[str], amount: int = None
239+
) -> Optional[List[BaseEntry]]:
212240
"""Searches all available entries for appropriate results. This includes:
213241
214242
* wiki pages
@@ -251,34 +279,40 @@ def search(self, search_query: Optional[str], amount: int = None) -> Optional[Li
251279
match.groupdict()[x]
252280
for x in ("owner", "repo", "number", "sha", "query", "ptbcontrib")
253281
)
282+
owner = owner or DEFAULT_REPO_OWNER
283+
repo = repo or DEFAULT_REPO_NAME
254284

255285
# If it's an issue
256286
if number:
257-
issue = github_issues.get_issue(int(number), owner, repo)
287+
issue = await self.github.get_thread(int(number), owner, repo)
258288
return [issue] if issue else None
259289
# If it's a commit
260290
if sha:
261-
commit = github_issues.get_commit(sha, owner, repo)
291+
commit = await self.github.get_commit(sha, owner, repo)
262292
return [commit] if commit else None
263293
# If it's a search
264294
if gh_search_query:
265295
search_query = gh_search_query
266-
search_entries = github_issues.all_issues
296+
search_entries = itertools.chain(
297+
self.github.all_issues,
298+
self.github.all_pull_requests,
299+
self.github.all_discussions,
300+
)
267301
elif ptbcontrib:
268-
search_entries = github_issues.all_ptbcontribs
302+
search_entries = self.github.all_ptbcontribs
269303

270304
if search_query and search_query.startswith("/"):
271305
search_entries = TAG_HINTS.values()
272306

273-
with self.__lock:
307+
async with self.__lock:
274308
if not search_entries:
275309
search_entries = itertools.chain(
276310
self._wiki,
277-
self._examples,
311+
self.github.all_examples,
278312
self._faq,
279313
self._design_patterns,
280314
self._snippets,
281-
github_issues.all_ptbcontribs,
315+
self.github.all_ptbcontribs,
282316
self._docs,
283317
TAG_HINTS.values(),
284318
)
@@ -298,9 +332,8 @@ def search(self, search_query: Optional[str], amount: int = None) -> Optional[Li
298332
key=lambda entry: self._sort_key(entry, search_query), # type: ignore[arg-type]
299333
)
300334

301-
@functools.lru_cache(64)
302-
@cached_parsing
303-
def multi_search_combinations(
335+
@alru_cache(maxsize=64) # type: ignore[misc]
336+
async def multi_search_combinations(
304337
self, search_queries: Tuple[str], results_per_query: int = 3
305338
) -> List[Dict[str, BaseEntry]]:
306339
"""For each query, runs :meth:`search` and fetches the ``results_per_query`` most likely
@@ -322,13 +355,10 @@ def multi_search_combinations(
322355
# Remove duplicates while maintaining the order
323356
effective_queries = list(dict.fromkeys(search_queries))
324357
for query in effective_queries:
325-
if res := self.search(search_query=query, amount=results_per_query):
358+
if res := await self.search(search_query=query, amount=results_per_query):
326359
results[query] = res
327360

328361
return [
329362
dict(zip(effective_queries, query_results))
330363
for query_results in itertools.product(*results.values())
331364
]
332-
333-
334-
search = Search()

‎components/taghints.py

+38-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import re
2-
from typing import Dict, Any, Optional, List, Match
2+
from typing import Any, Dict, List, Match, Optional
33

44
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageEntity
5-
from telegram.ext import MessageFilter
5+
from telegram.ext.filters import MessageFilter
66

77
from components import const
88
from components.const import PTBCONTRIB_LINK
@@ -101,18 +101,20 @@
101101
},
102102
"wronglib": {
103103
"message": (
104-
"{query} If you insist on using that other one, please go where you belong: "
105-
'<a href="https://telegram.me/joinchat/Bn4ixj84FIZVkwhk2jag6A">pyTelegramBotApi</a>, '
104+
"{query} : If you are using a different package/language, we are sure you can "
105+
"find some kind of community help on their homepage. Here are a few links for other"
106+
"popular libraries: "
107+
'<a href="https://t.me/joinchat/Bn4ixj84FIZVkwhk2jag6A">pyTelegramBotApi</a>, '
106108
'<a href="https://github.com/nickoala/telepot">Telepot</a>, '
107-
'<a href="https://t.me/Pyrogram">pyrogram</a>, '
109+
'<a href="https://t.me/pyrogramchat">pyrogram</a>, '
108110
'<a href="https://t.me/TelethonChat">Telethon</a>, '
109111
'<a href="https://t.me/aiogram">aiogram</a>, '
110112
'<a href="https://t.me/botogram_users">botogram</a>.'
111113
),
112114
"help": "Other Python wrappers for Telegram",
113115
"default": (
114-
"Hey, I think you're wrong 🧐\nIt looks like you're not using the python-telegram-bot "
115-
"library."
116+
"Hey, I think you're wrong 🧐\nThis is the support group of the "
117+
"<code>python-telegram-bot</code> library."
116118
),
117119
},
118120
"pastebin": {
@@ -174,8 +176,8 @@
174176
"message": (
175177
"{query} This group is for technical questions that come up while you code your own "
176178
"Telegram bot. If you are looking for ready-to-use bots, please have a look at "
177-
"channels like @BotsArchive or @BotList. There are also a number of websites that "
178-
"list existing bots."
179+
"channels like @BotsArchive or @BotList/@BotlistBot. There are also a number of "
180+
"websites that list existing bots."
179181
),
180182
"default": "Hey.",
181183
"help": "Redirect users to lists of existing bots.",
@@ -190,6 +192,31 @@
190192
"default": "Hey.",
191193
"help": "Remind the users of the Code of Conduct.",
192194
},
195+
"docs": {
196+
"message": (
197+
f"{{query}} You can find our documentation at <a href='{const.DOCS_URL}'>Read the "
198+
f"Docs</a>. "
199+
),
200+
"default": "Hey.",
201+
"help": "Point users to the documentation",
202+
"group_command": True,
203+
},
204+
"wiki": {
205+
"message": f"{{query}} You can find our wiki on <a href='{const.WIKI_URL}'>Github</a>.",
206+
"default": "Hey.",
207+
"help": "Point users to the wiki",
208+
"group_command": True,
209+
},
210+
"help": {
211+
"message": (
212+
"{query} You can find an explanation of @roolsbot's functionality on '"
213+
'<a href="https://github.com/python-telegram-bot/rules-bot/blob/master/README.md">'
214+
"GitHub</a>."
215+
),
216+
"default": "Hey.",
217+
"help": "Point users to the bots readme",
218+
"group_command": True,
219+
},
193220
}
194221

195222

@@ -203,6 +230,7 @@
203230
description=value["help"],
204231
default_query=value.get("default"),
205232
inline_keyboard=InlineKeyboardMarkup(value["buttons"]) if "buttons" in value else None,
233+
group_command=value.get("group_command", False),
206234
)
207235
for key, value in _TAG_HINTS.items()
208236
}
@@ -226,7 +254,7 @@ class TagHintFilter(MessageFilter):
226254
"""Custom filter class for filtering for tag hint messages"""
227255

228256
def __init__(self) -> None:
229-
self.data_filter = True
257+
super().__init__(name="TageHintFilter", data_filter=True)
230258

231259
def filter(self, message: Message) -> Optional[Dict[str, List[Match]]]:
232260
"""Does the filtering. Applies the regex and makes sure that only those tag hints are

‎components/util.py

+25-35
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,14 @@
44
import sys
55
import warnings
66
from functools import wraps
7-
from typing import (
8-
Optional,
9-
List,
10-
Callable,
11-
TypeVar,
12-
Dict,
13-
cast,
14-
Tuple,
15-
Union,
16-
)
7+
from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Union, cast
178

189
from bs4 import MarkupResemblesLocatorWarning
19-
from telegram import Update, InlineKeyboardButton, Message
20-
from telegram.error import BadRequest, Unauthorized
21-
from telegram.ext import CallbackContext
10+
from telegram import InlineKeyboardButton, Message, Update
11+
from telegram.error import BadRequest, Forbidden
12+
from telegram.ext import CallbackContext, ContextTypes
2213

23-
from .const import RATE_LIMIT_SPACING, ONTOPIC_CHAT_ID, OFFTOPIC_CHAT_ID
14+
from .const import OFFTOPIC_CHAT_ID, ONTOPIC_CHAT_ID, RATE_LIMIT_SPACING
2415
from .taghints import TAG_HINTS
2516

2617
# Messages may contain links that we don't care about - so let's ignore the warnings
@@ -33,25 +24,25 @@ def get_reply_id(update: Update) -> Optional[int]:
3324
return None
3425

3526

36-
def reply_or_edit(update: Update, context: CallbackContext, text: str) -> None:
27+
async def reply_or_edit(update: Update, context: CallbackContext, text: str) -> None:
3728
chat_data = cast(Dict, context.chat_data)
3829
if update.edited_message and update.edited_message.message_id in chat_data:
3930
try:
40-
chat_data[update.edited_message.message_id].edit_text(text)
31+
await chat_data[update.edited_message.message_id].edit_text(text)
4132
except BadRequest as exc:
4233
if "not modified" not in str(exc):
4334
raise exc
4435
else:
4536
message = cast(Message, update.effective_message)
4637
issued_reply = get_reply_id(update)
4738
if issued_reply:
48-
chat_data[message.message_id] = context.bot.send_message(
39+
chat_data[message.message_id] = await context.bot.send_message(
4940
message.chat_id,
5041
text,
5142
reply_to_message_id=issued_reply,
5243
)
5344
else:
54-
chat_data[message.message_id] = message.reply_text(text)
45+
chat_data[message.message_id] = await message.reply_text(text)
5546

5647

5748
def get_text_not_in_entities(message: Message) -> str:
@@ -94,48 +85,47 @@ def build_menu(
9485
return menu
9586

9687

97-
def try_to_delete(message: Message) -> bool:
88+
async def try_to_delete(message: Message) -> bool:
9889
try:
99-
return message.delete()
100-
except (BadRequest, Unauthorized):
90+
return await message.delete()
91+
except (BadRequest, Forbidden):
10192
return False
10293

10394

104-
def rate_limit_tracker(_: Update, context: CallbackContext) -> None:
95+
async def rate_limit_tracker(_: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
10596
data = cast(Dict, context.chat_data).setdefault("rate_limit", {})
10697

10798
for key in data.keys():
10899
data[key] += 1
109100

110101

111-
Func = TypeVar("Func", bound=Callable[[Update, CallbackContext], None])
112-
113-
114102
def rate_limit(
115-
func: Callable[[Update, CallbackContext], None]
116-
) -> Callable[[Update, CallbackContext], None]:
103+
func: Callable[[Update, ContextTypes.DEFAULT_TYPE], Coroutine[Any, Any, None]]
104+
) -> Callable[[Update, ContextTypes.DEFAULT_TYPE], Coroutine[Any, Any, None]]:
117105
"""
118106
Rate limit command so that RATE_LIMIT_SPACING non-command messages are
119107
required between invocations. Private chats are not rate limited.
120108
"""
121109

122110
@wraps(func)
123-
def wrapper(update: Update, context: CallbackContext) -> None:
111+
async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
124112
if chat := update.effective_chat:
125113
if chat.type == chat.PRIVATE:
126-
return func(update, context)
114+
return await func(update, context)
127115

128116
# Get rate limit data
129117
data = cast(Dict, context.chat_data).setdefault("rate_limit", {})
130118

131119
# If we have not seen two non-command messages since last of type `func`
132120
if data.get(func, RATE_LIMIT_SPACING) < RATE_LIMIT_SPACING:
133121
logging.debug("Ignoring due to rate limit!")
134-
try_to_delete(cast(Message, update.effective_message))
122+
context.application.create_task(
123+
try_to_delete(cast(Message, update.effective_message)), update=update
124+
)
135125
return None
136126

137127
data[func] = 0
138-
return func(update, context)
128+
return await func(update, context)
139129

140130
return wrapper
141131

@@ -149,11 +139,11 @@ def build_command_list(
149139
) -> List[Tuple[str, str]]:
150140

151141
base_commands = [
152-
("docs", "Send the link to the docs."),
153-
("wiki", "Send the link to the wiki."),
154-
("help", "Send the link to this bots README."),
142+
(hint.tag, hint.description) for hint in TAG_HINTS.values() if hint.group_command
143+
]
144+
hint_commands = [
145+
(hint.tag, hint.description) for hint in TAG_HINTS.values() if not hint.group_command
155146
]
156-
hint_commands = [(hint.tag, hint.description) for hint in TAG_HINTS.values()]
157147

158148
if private:
159149
return base_commands + hint_commands

‎pyproject.toml

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
[tool.black]
22
line-length = 99
3-
target-version = ['py38']
3+
target-version = ['py38', 'py39', 'py310']
4+
5+
[tool.isort] # black config
6+
profile = "black"
7+
line_length = 99

‎requirements-dev.txt

+1-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
pre-commit
2-
# Make sure that the versions specified here match the pre-commit settings!
3-
black==22.3.0
4-
flake8==4.0.1
5-
pylint==2.14.3
6-
mypy==0.961
7-
pyupgrade==2.34.0
1+
pre-commit

‎requirements.txt

+8-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Make sure to install those as additional_dependencies in the
22
# pre-commit hooks for pylint & mypy
3-
beautifulsoup4
4-
thefuzz==0.19.0
5-
python-Levenshtein
6-
python-telegram-bot==13.9
7-
Sphinx
8-
requests
9-
github3.py==2.0.0
3+
beautifulsoup4~=4.11.0
4+
thefuzz~=0.19.0
5+
python-Levenshtein~=0.12.0
6+
python-telegram-bot==20.0a2
7+
Sphinx~=5.0.2
8+
httpx~=0.23.0
9+
gql~=3.3.0
10+
async-lru~=1.0.3

‎rules_bot.py

+112-112
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,59 @@
1+
import asyncio
12
import configparser
23
import logging
34
import os
5+
from typing import cast
46

57
from telegram import (
6-
ParseMode,
7-
Bot,
8-
Update,
8+
BotCommandScopeAllGroupChats,
99
BotCommandScopeAllPrivateChats,
1010
BotCommandScopeChat,
11-
BotCommandScopeAllGroupChats,
1211
BotCommandScopeChatAdministrators,
12+
Update,
1313
)
14-
from telegram.error import BadRequest, Unauthorized
14+
from telegram.constants import ParseMode
1515
from telegram.ext import (
16+
Application,
17+
ApplicationBuilder,
18+
CallbackQueryHandler,
19+
ChatJoinRequestHandler,
1620
CommandHandler,
17-
Updater,
18-
MessageHandler,
19-
Filters,
2021
Defaults,
21-
ChatMemberHandler,
2222
InlineQueryHandler,
23-
CallbackQueryHandler,
23+
MessageHandler,
24+
filters,
2425
)
2526

2627
from components import inlinequeries
2728
from components.callbacks import (
28-
start,
29-
rules,
30-
docs,
31-
wiki,
32-
help_callback,
29+
ban_sender_channels,
30+
delete_new_chat_members_message,
31+
join_request_buttons,
32+
join_request_callback,
33+
leave_chat,
3334
off_on_topic,
34-
sandwich,
35+
raise_app_handler_stop,
3536
reply_search,
36-
delete_new_chat_members_message,
37-
greet_new_chat_members,
38-
tag_hint,
39-
say_potato_command,
37+
rules,
38+
sandwich,
4039
say_potato_button,
41-
ban_sender_channels,
40+
say_potato_command,
41+
start,
42+
tag_hint,
4243
)
43-
from components.errorhandler import error_handler
4444
from components.const import (
45-
OFFTOPIC_RULES,
45+
ALLOWED_CHAT_IDS,
46+
ALLOWED_USERNAMES,
47+
ERROR_CHANNEL_CHAT_ID,
48+
OFFTOPIC_CHAT_ID,
4649
OFFTOPIC_USERNAME,
47-
ONTOPIC_RULES,
48-
ONTOPIC_USERNAME,
49-
ONTOPIC_RULES_MESSAGE_ID,
50-
OFFTOPIC_RULES_MESSAGE_ID,
5150
ONTOPIC_CHAT_ID,
52-
OFFTOPIC_CHAT_ID,
51+
ONTOPIC_USERNAME,
5352
)
53+
from components.errorhandler import error_handler
54+
from components.search import Search
5455
from components.taghints import TagHintFilter
55-
from components.util import (
56-
rate_limit_tracker,
57-
build_command_list,
58-
)
59-
from components.github import github_issues
56+
from components.util import build_command_list, rate_limit_tracker
6057

6158
if os.environ.get("ROOLSBOT_DEBUG"):
6259
logging.basicConfig(
@@ -67,136 +64,139 @@
6764
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
6865
)
6966
logging.getLogger("apscheduler").setLevel(logging.WARNING)
70-
logging.getLogger("github3").setLevel(logging.WARNING)
67+
logging.getLogger("gql").setLevel(logging.WARNING)
7168

7269
logger = logging.getLogger(__name__)
7370

7471

75-
def update_rules_messages(bot: Bot) -> None:
76-
try:
77-
bot.edit_message_text(
78-
chat_id=ONTOPIC_CHAT_ID,
79-
message_id=ONTOPIC_RULES_MESSAGE_ID,
80-
text=ONTOPIC_RULES,
72+
async def post_init(application: Application) -> None:
73+
bot = application.bot
74+
await cast(Search, application.bot_data["search"]).initialize(application)
75+
76+
# set commands
77+
await bot.set_my_commands(
78+
build_command_list(private=True),
79+
scope=BotCommandScopeAllPrivateChats(),
80+
)
81+
await bot.set_my_commands(
82+
build_command_list(private=False),
83+
scope=BotCommandScopeAllGroupChats(),
84+
)
85+
86+
for group_name in [ONTOPIC_CHAT_ID, OFFTOPIC_CHAT_ID]:
87+
await bot.set_my_commands(
88+
build_command_list(private=False, group_name=group_name),
89+
scope=BotCommandScopeChat(group_name),
8190
)
82-
except (BadRequest, Unauthorized) as exc:
83-
logger.warning("Updating on-topic rules failed: %s", exc)
84-
try:
85-
bot.edit_message_text(
86-
chat_id=OFFTOPIC_CHAT_ID,
87-
message_id=OFFTOPIC_RULES_MESSAGE_ID,
88-
text=OFFTOPIC_RULES,
91+
await bot.set_my_commands(
92+
build_command_list(private=False, group_name=group_name, admins=True),
93+
scope=BotCommandScopeChatAdministrators(group_name),
8994
)
90-
except (BadRequest, Unauthorized) as exc:
91-
logger.warning("Updating off-topic rules failed: %s", exc)
95+
96+
97+
async def post_shutdown(application: Application) -> None:
98+
await cast(Search, application.bot_data["search"]).shutdown()
9299

93100

94101
def main() -> None:
95102
config = configparser.ConfigParser()
96103
config.read("bot.ini")
97104

98105
defaults = Defaults(parse_mode=ParseMode.HTML, disable_web_page_preview=True)
99-
updater = Updater(token=config["KEYS"]["bot_api"], defaults=defaults)
100-
dispatcher = updater.dispatcher
101-
update_rules_messages(updater.bot)
106+
application = (
107+
ApplicationBuilder()
108+
.token(config["KEYS"]["bot_api"])
109+
.defaults(defaults)
110+
.post_init(post_init)
111+
.build()
112+
)
113+
114+
application.bot_data["search"] = Search(github_auth=config["KEYS"]["github_auth"])
102115

103116
# Note: Order matters!
104117

105-
dispatcher.add_handler(MessageHandler(~Filters.command, rate_limit_tracker), group=-1)
106-
dispatcher.add_handler(
118+
# Don't handle messages that were sent in the error channel
119+
application.add_handler(
120+
MessageHandler(filters.Chat(chat_id=ERROR_CHANNEL_CHAT_ID), raise_app_handler_stop),
121+
group=-2,
122+
)
123+
# Leave groups that are not maintained by PTB
124+
application.add_handler(
125+
MessageHandler(
126+
filters.ChatType.GROUPS
127+
& ~(filters.Chat(username=ALLOWED_USERNAMES) | filters.Chat(chat_id=ALLOWED_CHAT_IDS)),
128+
leave_chat,
129+
),
130+
group=-2,
131+
)
132+
133+
application.add_handler(MessageHandler(~filters.COMMAND, rate_limit_tracker), group=-1)
134+
application.add_handler(
107135
MessageHandler(
108-
Filters.sender_chat.channel & ~Filters.is_automatic_forward, ban_sender_channels
136+
filters.SenderChat.CHANNEL
137+
& ~filters.IS_AUTOMATIC_FORWARD
138+
& ~filters.Chat(chat_id=ERROR_CHANNEL_CHAT_ID),
139+
ban_sender_channels,
140+
block=False,
109141
)
110142
)
111143

112144
# Simple commands
113145
# The first one also handles deep linking /start commands
114-
dispatcher.add_handler(CommandHandler("start", start))
115-
dispatcher.add_handler(CommandHandler("rules", rules))
116-
dispatcher.add_handler(CommandHandler("docs", docs))
117-
dispatcher.add_handler(CommandHandler("wiki", wiki))
118-
dispatcher.add_handler(CommandHandler("help", help_callback))
146+
application.add_handler(CommandHandler("start", start))
147+
application.add_handler(CommandHandler("rules", rules))
119148

120149
# Stuff that runs on every message with regex
121-
dispatcher.add_handler(
150+
application.add_handler(
122151
MessageHandler(
123-
Filters.regex(r"(?i)[\s\S]*?((sudo )?make me a sandwich)[\s\S]*?"), sandwich
152+
filters.Regex(r"(?i)[\s\S]*?((sudo )?make me a sandwich)[\s\S]*?"), sandwich
124153
)
125154
)
126-
dispatcher.add_handler(MessageHandler(Filters.regex("/(on|off)_topic"), off_on_topic))
155+
application.add_handler(MessageHandler(filters.Regex("/(on|off)_topic"), off_on_topic))
127156

128157
# Tag hints - works with regex
129-
dispatcher.add_handler(MessageHandler(TagHintFilter(), tag_hint))
158+
application.add_handler(MessageHandler(TagHintFilter(), tag_hint))
130159

131-
# We need several matches so Filters.regex is basically useless
160+
# We need several matches so filters.REGEX is basically useless
132161
# therefore we catch everything and do regex ourselves
133-
dispatcher.add_handler(
134-
MessageHandler(Filters.text & Filters.update.messages & ~Filters.command, reply_search)
162+
application.add_handler(
163+
MessageHandler(filters.TEXT & filters.UpdateType.MESSAGES & ~filters.COMMAND, reply_search)
135164
)
136165

137166
# Status updates
138-
dispatcher.add_handler(
139-
ChatMemberHandler(greet_new_chat_members, chat_member_types=ChatMemberHandler.CHAT_MEMBER)
140-
)
141-
dispatcher.add_handler(
167+
application.add_handler(
142168
MessageHandler(
143-
Filters.chat(username=[ONTOPIC_USERNAME, OFFTOPIC_USERNAME])
144-
& Filters.status_update.new_chat_members,
169+
filters.Chat(username=[ONTOPIC_USERNAME, OFFTOPIC_USERNAME])
170+
& filters.StatusUpdate.NEW_CHAT_MEMBERS,
145171
delete_new_chat_members_message,
172+
block=False,
146173
),
147174
group=1,
148175
)
149176

150177
# Inline Queries
151-
dispatcher.add_handler(InlineQueryHandler(inlinequeries.inline_query))
178+
application.add_handler(InlineQueryHandler(inlinequeries.inline_query))
152179

153180
# Captcha for userbots
154-
dispatcher.add_handler(
181+
application.add_handler(
155182
CommandHandler(
156183
"say_potato",
157184
say_potato_command,
158-
filters=Filters.chat(username=[ONTOPIC_USERNAME, OFFTOPIC_USERNAME]),
185+
filters=filters.Chat(username=[ONTOPIC_USERNAME, OFFTOPIC_USERNAME]),
159186
)
160187
)
161-
dispatcher.add_handler(CallbackQueryHandler(say_potato_button, pattern="^POTATO"))
162-
163-
# Error Handler
164-
dispatcher.add_error_handler(error_handler)
188+
application.add_handler(CallbackQueryHandler(say_potato_button, pattern="^POTATO"))
165189

166-
updater.start_polling(allowed_updates=Update.ALL_TYPES)
167-
logger.info("Listening...")
190+
# Join requests
191+
application.add_handler(ChatJoinRequestHandler(callback=join_request_callback, block=False))
192+
application.add_handler(CallbackQueryHandler(join_request_buttons, pattern="^JOIN"))
168193

169-
try:
170-
github_issues.set_auth(
171-
config["KEYS"]["github_client_id"], config["KEYS"]["github_client_secret"]
172-
)
173-
except KeyError:
174-
logging.info("No github api token set. Rate-limit is 60 requests/hour without auth.")
175-
176-
github_issues.init_ptb_contribs(dispatcher.job_queue) # type: ignore[arg-type]
177-
github_issues.init_issues(dispatcher.job_queue) # type: ignore[arg-type]
178-
179-
# set commands
180-
updater.bot.set_my_commands(
181-
build_command_list(private=True),
182-
scope=BotCommandScopeAllPrivateChats(),
183-
)
184-
updater.bot.set_my_commands(
185-
build_command_list(private=False),
186-
scope=BotCommandScopeAllGroupChats(),
187-
)
188-
189-
for group_name in [ONTOPIC_CHAT_ID, OFFTOPIC_CHAT_ID]:
190-
updater.bot.set_my_commands(
191-
build_command_list(private=False, group_name=group_name),
192-
scope=BotCommandScopeChat(group_name),
193-
)
194-
updater.bot.set_my_commands(
195-
build_command_list(private=False, group_name=group_name, admins=True),
196-
scope=BotCommandScopeChatAdministrators(group_name),
197-
)
194+
# Error Handler
195+
application.add_error_handler(error_handler)
198196

199-
updater.idle()
197+
application.run_polling(allowed_updates=Update.ALL_TYPES, close_loop=False)
198+
# Can be used in AppBuilder.post_shutdown once #3126 is released
199+
asyncio.get_event_loop().run_until_complete(post_shutdown(application))
200200

201201

202202
if __name__ == "__main__":

0 commit comments

Comments
 (0)
Please sign in to comment.