[\w_]+)
145 | )
146 | """,
147 | re.VERBOSE,
148 | )
149 | VEGETABLES = [
150 | "amaranth",
151 | "anise",
152 | "artichoke",
153 | "arugula",
154 | "asparagus",
155 | "aubergine",
156 | "basil",
157 | "beet",
158 | "broccoflower",
159 | "broccoli",
160 | "cabbage",
161 | "calabrese",
162 | "caraway",
163 | "carrot",
164 | "cauliflower",
165 | "celeriac",
166 | "celery",
167 | "chamomile",
168 | "chard",
169 | "chayote",
170 | "chickpea",
171 | "chives",
172 | "cilantro",
173 | "corn",
174 | "corn salad",
175 | "courgette",
176 | "cucumber",
177 | "daikon",
178 | "delicata",
179 | "dill",
180 | "eggplant",
181 | "endive",
182 | "fennel",
183 | "fiddlehead",
184 | "frisee",
185 | "garlic",
186 | "ginger",
187 | "habanero",
188 | "horseradish",
189 | "jalapeno",
190 | "jicama",
191 | "kale",
192 | "kohlrabi",
193 | "lavender",
194 | "leek ",
195 | "legume",
196 | "lentils",
197 | "lettuce",
198 | "mamey",
199 | "mangetout",
200 | "marjoram",
201 | "mushroom",
202 | "nopale",
203 | "okra",
204 | "onion",
205 | "oregano",
206 | "paprika",
207 | "parsley",
208 | "parsnip",
209 | "pea",
210 | "potato",
211 | "pumpkin",
212 | "radicchio",
213 | "radish",
214 | "rhubarb",
215 | "rosemary",
216 | "rutabaga",
217 | "sage",
218 | "scallion",
219 | "shallot",
220 | "skirret",
221 | "spinach",
222 | "squash",
223 | "taro",
224 | "thyme",
225 | "topinambur",
226 | "tubers",
227 | "turnip",
228 | "wasabi",
229 | "watercress",
230 | "yam",
231 | "zucchini",
232 | ]
233 | BUY_TEXT = (
234 | "Hello there, {0}. You are writing your job offer in our technical focused groups, which is "
235 | "against our rules. To find a bot developer, please look at agencies dedicated towards "
236 | "freelancers. An example of those would be https://fiverr.com, which we are not "
237 | "associated with."
238 | )
239 | TOKEN_TEXT = "⚠️ You posted a token, go revoke it with @BotFather.\n\n"
240 |
241 | COMPAT_ERRORS = re.compile(
242 | r"""
243 | (
244 | (Updater\._{0,2}init_{0,2}\(\))?
245 | (
246 | \ got\ an\ unexpected\ keyword\ argument
247 | \ ['"]*(use_context|token|use_controls|dispatcher)['"]*
248 | |
249 | \ missing\ 1\ required\ positional\ argument:\ ['"]*update_queue['"]*
250 | )
251 | )|(
252 | updater\.(idle\(\)|dispatcher)
253 | )|(
254 | dispatcher.add_handler\(
255 | )|(
256 | cannot\ import\ name\ ['"]*Filters['"]*
257 | )
258 | """,
259 | flags=re.VERBOSE,
260 | )
261 |
262 | PRIVACY_POLICY = "https://github.com/python-telegram-bot/rules-bot/wiki/Privacy-Policy"
263 |
264 | SHORT_DESCRIPTION = (
265 | "Helper bot of the python-telegram-bot groups | Help and source at "
266 | "https://github.com/python-telegram-bot/rules-bot"
267 | )
268 | DESCRIPTION = f"""
269 | Helper bot of the https://python-telegram-bot.org community groups:
270 | {ONTOPIC_CHAT_ID} and {OFFTOPIC_CHAT_ID}.
271 |
272 | The privacy policy of this bot can be found at {PRIVACY_POLICY}.
273 |
274 | Usage instructions and source code can be found at
275 | https://github.com/python-telegram-bot/rules-bot.
276 | """
277 |
--------------------------------------------------------------------------------
/components/entrytypes.py:
--------------------------------------------------------------------------------
1 | import re
2 | from abc import ABC, abstractmethod
3 | from dataclasses import dataclass
4 | from typing import ClassVar, List, Optional
5 | from urllib.parse import urljoin
6 |
7 | from telegram import InlineKeyboardMarkup
8 | from thefuzz import fuzz
9 |
10 | from components.const import (
11 | ARROW_CHARACTER,
12 | DEFAULT_REPO_NAME,
13 | DEFAULT_REPO_OWNER,
14 | DOCS_URL,
15 | TELEGRAM_SUPERSCRIPT,
16 | )
17 |
18 |
19 | class BaseEntry(ABC):
20 | """Base class for all searchable entries."""
21 |
22 | @property
23 | @abstractmethod
24 | def display_name(self) -> str:
25 | """Name to display in the search results"""
26 |
27 | @property
28 | def short_name(self) -> str:
29 | """Potentially shorter name to display. Defaults to :attr:`display_name`"""
30 | return self.display_name
31 |
32 | @property
33 | @abstractmethod
34 | def description(self) -> str:
35 | """Description of the entry to display in the search results"""
36 |
37 | @property
38 | def short_description(self) -> str:
39 | """Short description of the entry to display in the search results. Useful when displaying
40 | multiple search results in one entry. Defaults to :attr:`short_name` if not overridden."""
41 | return self.short_name
42 |
43 | @abstractmethod
44 | def html_markup(self, search_query: str = None) -> str:
45 | """HTML markup to be used if this entry is selected in the search. May depend on the search
46 | query."""
47 |
48 | @abstractmethod
49 | def html_insertion_markup(self, search_query: str = None) -> str:
50 | """HTML markup to be used for insertion search. May depend on the search query."""
51 |
52 | def html_reply_markup(self, search_query: str = None) -> str:
53 | """HTML markup to be used for reply search. May depend on the search query.
54 | Defaults to :meth:`html_insertion_markup`, but may be overridden.
55 | """
56 | return self.html_insertion_markup(search_query=search_query)
57 |
58 | @abstractmethod
59 | def compare_to_query(self, search_query: str) -> float:
60 | """Gives a number ∈[0,100] describing how similar the search query is to this entry."""
61 |
62 | @property
63 | def inline_keyboard(self) -> Optional[InlineKeyboardMarkup]:
64 | """Inline Keyboard markup that can be attached to this entry. Returns :obj:`None`, if
65 | not overridden."""
66 | return None
67 |
68 |
69 | class ReadmeSection(BaseEntry):
70 | """A section of the readme.
71 |
72 | Args:
73 | name: The name of the section
74 | anchor: the URL anchor of the section
75 | """
76 |
77 | def __init__(self, name: str, anchor: str):
78 | self.name = name
79 | self.anchor = anchor
80 |
81 | @property
82 | def url(self) -> str:
83 | return urljoin(DOCS_URL, self.anchor)
84 |
85 | @property
86 | def display_name(self) -> str:
87 | return f"Readme {ARROW_CHARACTER} {self.name}"
88 |
89 | @property
90 | def short_name(self) -> str:
91 | return self.name
92 |
93 | @property
94 | def description(self) -> str:
95 | return "Readme of python-telegram-bot"
96 |
97 | def html_markup(self, search_query: str = None) -> str:
98 | return (
99 | f"Readme of python-telegram-bot\n" f"{self.html_insertion_markup(search_query)}"
100 | )
101 |
102 | def html_insertion_markup(self, search_query: str = None) -> str:
103 | return f'{self.short_name}'
104 |
105 | def html_reply_markup(self, search_query: str = None) -> str:
106 | return f'Readme Section: {self.short_name}'
107 |
108 | def compare_to_query(self, search_query: str) -> float:
109 | return fuzz.token_set_ratio(f"readme {self.name}", search_query)
110 |
111 |
112 | class Example(BaseEntry):
113 | """An example in the examples directory.
114 |
115 | Args:
116 | name: The name of the example
117 | """
118 |
119 | def __init__(self, name: str):
120 | self._name = name
121 | self._search_name = f"example {self._name}"
122 |
123 | if name.endswith(".py"):
124 | href = name[:-3]
125 | else:
126 | href = name
127 | self.url = f"{DOCS_URL}examples.html#examples-{href}"
128 |
129 | @property
130 | def display_name(self) -> str:
131 | return f"Examples {ARROW_CHARACTER} {self._name}"
132 |
133 | @property
134 | def short_name(self) -> str:
135 | return self._name
136 |
137 | @property
138 | def description(self) -> str:
139 | return "Examples directory of python-telegram-bot"
140 |
141 | def html_markup(self, search_query: str = None) -> str:
142 | return (
143 | "Examples directory of python-telegram-bot:"
144 | f"\n{self.html_insertion_markup(search_query)}"
145 | )
146 |
147 | def html_insertion_markup(self, search_query: str = None) -> str:
148 | return f'{self.short_name}'
149 |
150 | def compare_to_query(self, search_query: str) -> float:
151 | if search_query.endswith(".py"):
152 | search_query = search_query[:-3]
153 |
154 | return fuzz.partial_token_set_ratio(self._search_name, search_query)
155 |
156 |
157 | class WikiPage(BaseEntry):
158 | """A wiki page.
159 |
160 | Args:
161 | category: The .py of the page, as listed in the sidebar
162 | name: The name of the page
163 | url: URL of the page
164 | """
165 |
166 | def __init__(self, category: str, name: str, url: str):
167 | self.category = category
168 | self.name = name
169 | self.url = url
170 | self._compare_name = f"{self.category} {self.name}"
171 |
172 | @property
173 | def display_name(self) -> str:
174 | return f"{self.category} {ARROW_CHARACTER} {self.name}"
175 |
176 | @property
177 | def short_name(self) -> str:
178 | return self.name
179 |
180 | @property
181 | def description(self) -> str:
182 | return "Wiki of python-telegram-bot"
183 |
184 | def html_markup(self, search_query: str = None) -> str:
185 | return (
186 | f"Wiki of python-telegram-bot - Category {self.category}\n"
187 | f"{self.html_insertion_markup(search_query)}"
188 | )
189 |
190 | def html_insertion_markup(self, search_query: str = None) -> str:
191 | return f'{self.short_name}'
192 |
193 | def html_reply_markup(self, search_query: str = None) -> str:
194 | return f'Wiki Category {self.category}: {self.short_name}'
195 |
196 | def compare_to_query(self, search_query: str) -> float:
197 | return fuzz.token_set_ratio(self._compare_name, search_query)
198 |
199 |
200 | class CodeSnippet(WikiPage):
201 | """A code snippet
202 |
203 | Args:
204 | name: The name of the snippet
205 | url: URL of the snippet
206 | """
207 |
208 | def __init__(self, name: str, url: str):
209 | super().__init__(category="Code Snippets", name=name, url=url)
210 |
211 |
212 | class FAQEntry(WikiPage):
213 | """An FAQ entry
214 |
215 | Args:
216 | name: The name of the entry
217 | url: URL of the entry
218 | """
219 |
220 | def __init__(self, name: str, url: str):
221 | super().__init__(category="FAQ", name=name, url=url)
222 |
223 |
224 | class FRDPEntry(WikiPage):
225 | """A frequently requested design pattern entry
226 |
227 | Args:
228 | name: The name of the entry
229 | url: URL of the entry
230 | """
231 |
232 | def __init__(self, name: str, url: str):
233 | super().__init__(category="Design Pattern", name=name, url=url)
234 |
235 |
236 | class DocEntry(BaseEntry):
237 | """An entry to the PTB docs.
238 |
239 | Args:
240 | url: URL to the online documentation of the entry.
241 | entry_type: Which type of entry this is.
242 | name: Name of the entry.
243 | display_name: Optional. Display name for the entry.
244 | telegram_name: Optional: Name of the corresponding Telegram documentation entry.
245 | telegram_url: Optional. Link to the corresponding Telegram documentation.
246 | """
247 |
248 | def __init__(
249 | self,
250 | url: str,
251 | entry_type: str,
252 | name: str,
253 | display_name: str = None,
254 | telegram_name: str = None,
255 | telegram_url: str = None,
256 | ):
257 | self.url = url
258 | self.entry_type = entry_type
259 | self.effective_type = self.entry_type.split(":")[-1]
260 | self.name = name
261 | self._display_name = display_name
262 | self.telegram_url = telegram_url
263 | self.telegram_name = telegram_name
264 | self._parsed_name: List[str] = self.parse_search_query(self.name)
265 |
266 | @staticmethod
267 | def parse_search_query(search_query: str) -> List[str]:
268 | """
269 | Does some preprocessing of the query needed for comparison with the entries in the docs.
270 |
271 | Args:
272 | search_query: The search query.
273 |
274 | Returns:
275 | The query, split on ``.``, ``-`` and ``/``, in reversed order.
276 | """
277 | # reversed, so that 'class' matches the 'class' part of 'module.class' exactly instead of
278 | # not matching the 'module' part
279 | return list(reversed(re.split(r"\.|/|-", search_query.strip())))
280 |
281 | @property
282 | def display_name(self) -> str:
283 | return self._display_name or self.name
284 |
285 | @property
286 | def short_name(self) -> str:
287 | name = self._display_name or self.name
288 |
289 | if name.startswith("telegram."):
290 | return name[len("telegram.") :]
291 | return name
292 |
293 | @property
294 | def description(self) -> str:
295 | return "Documentation of python-telegram-bot"
296 |
297 | def html_markup(self, search_query: str = None) -> str:
298 | base = (
299 | f"{self.short_name}
\n"
300 | f"python-telegram-bot documentation for this {self.effective_type}:\n"
301 | f"{self.html_markup_no_telegram}"
302 | )
303 | if not self.telegram_url and not self.telegram_name:
304 | tg_text = ""
305 | else:
306 | tg_text = (
307 | "\n\nTelegram's official Bot API documentation has more info about "
308 | f'{self.telegram_name}.'
309 | )
310 | return base + tg_text
311 |
312 | @property
313 | def html_markup_no_telegram(self) -> str:
314 | return f'{self.name}'
315 |
316 | def html_insertion_markup(self, search_query: str = None) -> str:
317 | if not self.telegram_name and not self.telegram_url:
318 | return self.html_markup_no_telegram
319 | return (
320 | f'{self.html_markup_no_telegram} '
321 | f"{TELEGRAM_SUPERSCRIPT}"
322 | )
323 |
324 | def compare_to_query(self, search_query: str) -> float:
325 | score = 0.0
326 | processed_query = self.parse_search_query(search_query)
327 |
328 | # We compare all the single parts of the query …
329 | for target, value in zip(processed_query, self._parsed_name):
330 | score += fuzz.ratio(target, value)
331 | # ... and the full name because we're generous
332 | score += fuzz.ratio(search_query, self.name)
333 | # To stay <= 100 as not to overrule other results
334 | score = score / 2
335 |
336 | # IISC std: is the domain for general stuff like headlines and chapters.
337 | # we'll wanna give those a little less weight
338 | if self.entry_type.startswith("std:"):
339 | score *= 0.8
340 | return score
341 |
342 |
343 | class ParamDocEntry(DocEntry):
344 | """An entry to the PTB docs. Special case of a parameter of a function or method.
345 |
346 | Args:
347 | url: URL to the online documentation of the entry.
348 | entry_type: Which type of entry this is.
349 | name: Name of the entry.
350 | display_name: Optional. Display name for the entry.
351 | telegram_name: Optional: Name of the corresponding Telegram documentation entry.
352 | telegram_url: Optional. Link to the corresponding Telegram documentation.
353 | """
354 |
355 | def __init__(
356 | self,
357 | url: str,
358 | entry_type: str,
359 | name: str,
360 | display_name: str = None,
361 | telegram_name: str = None,
362 | telegram_url: str = None,
363 | ):
364 | if ".params." not in name:
365 | raise ValueError("The passed name doesn't match a parameter name.")
366 |
367 | base_name, parameter_name = name.split(".params.")
368 | self._base_name = base_name
369 | self._parameter_name = parameter_name
370 | super().__init__(
371 | url=url,
372 | entry_type=entry_type,
373 | name=name,
374 | display_name=f"Parameter {self._parameter_name} of {self._base_name}",
375 | telegram_name=telegram_name,
376 | telegram_url=telegram_url,
377 | )
378 | self._base_url = self.url.split(".params.")[0]
379 | self._parsed_name_wo_params = self.parse_search_query(self.name.replace(".params.", ""))
380 |
381 | def html_markup(self, search_query: str = None) -> str:
382 | base = (
383 | f"{self._base_name}(..., {self._parameter_name}=...)
\n"
384 | f"python-telegram-bot documentation for this {self.effective_type} "
385 | f'of {self._base_name}:\n'
386 | f"{self.html_markup_no_telegram}"
387 | )
388 | if not self.telegram_url and not self.telegram_name:
389 | tg_text = ""
390 | else:
391 | tg_text = (
392 | "\n\nTelegram's official Bot API documentation has more info about "
393 | f'{self.telegram_name}.'
394 | )
395 | return base + tg_text
396 |
397 | @property
398 | def html_markup_no_telegram(self) -> str:
399 | return f'{self._parameter_name}'
400 |
401 | def html_insertion_markup(self, search_query: str = None) -> str:
402 | base_markup = (
403 | f'Parameter {self._parameter_name} of '
404 | f'{self._base_name}'
405 | )
406 | if not self.telegram_name and not self.telegram_url:
407 | return base_markup
408 | return f'{base_markup} ' f"{TELEGRAM_SUPERSCRIPT}"
409 |
410 | def compare_to_query(self, search_query: str) -> float:
411 | score = 0.0
412 | processed_query = self.parse_search_query(search_query)
413 |
414 | # We compare all the single parts of the query, with & without the ".params."
415 | for target, value in zip(processed_query, self._parsed_name):
416 | score += fuzz.ratio(target, value)
417 | for target, value in zip(processed_query, self._parsed_name_wo_params):
418 | score += fuzz.ratio(target, value)
419 | # ... and the full name because we're generous with & without leading "parameter"
420 | score += fuzz.ratio(search_query, self.name)
421 | score += fuzz.ratio(search_query, f"parameter {self.name}")
422 |
423 | # To stay <= 100 as not to overrule other results
424 | return score / 4
425 |
426 |
427 | @dataclass
428 | class Commit(BaseEntry):
429 | """A commit on Github
430 |
431 | Args:
432 | owner: str
433 | repo: str
434 | sha: str
435 | url: str
436 | title: str
437 | author: str
438 | """
439 |
440 | owner: str
441 | repo: str
442 | sha: str
443 | url: str
444 | title: str
445 | author: str
446 |
447 | @property
448 | def short_sha(self) -> str:
449 | return self.sha[:7]
450 |
451 | @property
452 | def short_name(self) -> str:
453 | return (
454 | f'{"" if self.owner == DEFAULT_REPO_OWNER else self.owner + "/"}'
455 | f'{"" if self.repo == DEFAULT_REPO_NAME else self.repo}'
456 | f"@{self.short_sha}"
457 | )
458 |
459 | @property
460 | def display_name(self) -> str:
461 | return f"Commit {self.short_name}: {self.title} by {self.author}"
462 |
463 | @property
464 | def description(self) -> str:
465 | return "Search on GitHub"
466 |
467 | def html_markup(self, search_query: str = None) -> str:
468 | return f'{self.display_name}'
469 |
470 | def html_insertion_markup(self, search_query: str = None) -> str:
471 | return f'{self.short_name}'
472 |
473 | def html_reply_markup(self, search_query: str = None) -> str:
474 | return self.html_markup(search_query=search_query)
475 |
476 | def compare_to_query(self, search_query: str) -> float:
477 | search_query = search_query.lstrip("@ ")
478 | if self.sha.startswith(search_query):
479 | return 100
480 | return 0
481 |
482 |
483 | @dataclass
484 | class _IssueOrPullRequestOrDiscussion(BaseEntry):
485 | _TYPE: ClassVar[str] = ""
486 | owner: str
487 | repo: str
488 | number: int
489 | title: str
490 | url: str
491 | author: Optional[str]
492 |
493 | @property
494 | def short_name(self) -> str:
495 | return (
496 | f'{"" if self.owner == DEFAULT_REPO_OWNER else self.owner + "/"}'
497 | f'{"" if self.repo == DEFAULT_REPO_NAME else self.repo}'
498 | f"#{self.number}"
499 | )
500 |
501 | @property
502 | def display_name(self) -> str:
503 | if self.author:
504 | return f"{self._TYPE} {self.short_name}: {self.title} by {self.author}"
505 | return f"{self._TYPE} {self.short_name}: {self.title}"
506 |
507 | @property
508 | def description(self) -> str:
509 | return "Search on GitHub"
510 |
511 | @property
512 | def short_description(self) -> str:
513 | # Needs to be here because of cyclical imports
514 | from .util import truncate_str # pylint:disable=import-outside-toplevel
515 |
516 | string = f"{self._TYPE} {self.short_name}: {self.title}"
517 | return truncate_str(string, 50)
518 |
519 | def html_markup(self, search_query: str = None) -> str: # pylint:disable=unused-argument
520 | return f'{self.display_name}'
521 |
522 | # pylint:disable=unused-argument
523 | def html_insertion_markup(self, search_query: str = None) -> str:
524 | return f'{self.short_name}'
525 |
526 | def html_reply_markup(self, search_query: str = None) -> str:
527 | return self.html_markup(search_query=search_query)
528 |
529 | def compare_to_query(self, search_query: str) -> float:
530 | search_query = search_query.lstrip("# ")
531 | if str(self.number) == search_query:
532 | return 100
533 | return fuzz.token_set_ratio(self.title, search_query)
534 |
535 |
536 | @dataclass
537 | class Issue(_IssueOrPullRequestOrDiscussion):
538 | """An issue on GitHub
539 |
540 | Args:
541 | number: the number
542 | repo: the repo name
543 | owner: the owner name
544 | url: the url of the issue
545 | title: title of the issue
546 | """
547 |
548 | _TYPE: ClassVar[str] = "Issue"
549 |
550 |
551 | @dataclass
552 | class PullRequest(_IssueOrPullRequestOrDiscussion):
553 | """An pullRequest on GitHub
554 |
555 | Args:
556 | number: the number
557 | repo: the repo name
558 | owner: the owner name
559 | url: the url of the pull request
560 | title: title of the pull request
561 | """
562 |
563 | _TYPE: ClassVar[str] = "PullRequest"
564 |
565 |
566 | @dataclass
567 | class Discussion(_IssueOrPullRequestOrDiscussion):
568 | """A Discussion on GitHub
569 |
570 | Args:
571 | number: the number
572 | repo: the repo name
573 | owner: the owner name
574 | url: the url of the pull request
575 | title: title of the pull request
576 | """
577 |
578 | _TYPE: ClassVar[str] = "Discussion"
579 |
580 |
581 | class PTBContrib(BaseEntry):
582 | """A contribution of ptbcontrib
583 |
584 | Args:
585 | name: The name of the contribution
586 | url: The url to the contribution
587 | """
588 |
589 | def __init__(self, name: str, url: str):
590 | self.name = name
591 | self.url = url
592 |
593 | @property
594 | def display_name(self) -> str:
595 | return f"ptbcontrib/{self.name}"
596 |
597 | @property
598 | def description(self) -> str:
599 | return "Community base extensions for python-telegram-bot"
600 |
601 | def html_markup(self, search_query: str = None) -> str:
602 | return f'{self.display_name}'
603 |
604 | def html_insertion_markup(self, search_query: str = None) -> str:
605 | return self.html_markup(search_query)
606 |
607 | def compare_to_query(self, search_query: str) -> float:
608 | # Here we just assume that everything before thi first / is ptbcontrib
609 | # (modulo typos). That could be wrong, but then it's the users fault :)
610 | search_query = search_query.split("/", maxsplit=1)[-1]
611 | return fuzz.ratio(self.name, search_query)
612 |
613 |
614 | class TagHint(BaseEntry):
615 | """A tag hint for frequently used texts in the groups.
616 |
617 | Attributes:
618 | tag: The tag of this hint.
619 | message: The message to display in HTML layout. It may contain a ``{query}`` part, which
620 | will be filled appropriately.
621 | description: Description of the tag hint.
622 | default_query: Optional. Inserted into the ``message`` if no other query is provided.
623 | inline_keyboard: Optional. In InlineKeyboardMarkup to attach to the hint.
624 | group_command: Optional. Whether this tag hint should be listed as command in the groups.
625 | """
626 |
627 | def __init__(
628 | self,
629 | tag: str,
630 | message: str,
631 | description: str,
632 | default_query: str = None,
633 | inline_keyboard: InlineKeyboardMarkup = None,
634 | group_command: bool = False,
635 | ):
636 | self.tag = tag
637 | self._message = message
638 | self._default_query = default_query
639 | self._description = description
640 | self._inline_keyboard = inline_keyboard
641 | self.group_command = group_command
642 |
643 | @property
644 | def display_name(self) -> str:
645 | return f"Tag hint: {self.short_name}"
646 |
647 | @property
648 | def short_name(self) -> str:
649 | return f"/{self.tag}"
650 |
651 | @property
652 | def description(self) -> str:
653 | return self._description
654 |
655 | def html_markup(self, search_query: str = None) -> str:
656 | parts = search_query.split(maxsplit=1) if search_query else []
657 | insert = parts[1] if len(parts) > 1 else None
658 | return self._message.format(query=insert or self._default_query)
659 |
660 | def html_insertion_markup(self, search_query: str = None) -> str:
661 | return self.html_markup(search_query=search_query)
662 |
663 | def compare_to_query(self, search_query: str) -> float:
664 | parts = search_query.lstrip("/").split(maxsplit=1)
665 | if parts:
666 | return fuzz.ratio(self.tag, parts[0])
667 | return 0
668 |
669 | @property
670 | def inline_keyboard(self) -> Optional[InlineKeyboardMarkup]:
671 | return self._inline_keyboard
672 |
--------------------------------------------------------------------------------
/components/errorhandler.py:
--------------------------------------------------------------------------------
1 | import html
2 | import json
3 | import logging
4 | import traceback
5 | from typing import cast
6 |
7 | from telegram import Update
8 | from telegram.error import BadRequest
9 | from telegram.ext import CallbackContext
10 |
11 | from components.const import ERROR_CHANNEL_CHAT_ID
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | async def error_handler(update: object, context: CallbackContext) -> None:
17 | """Log the error and send a telegram message to notify the developer."""
18 | # Log the error before we do anything else, so we can see it even if something breaks.
19 | logger.error(msg="Exception while handling an update:", exc_info=context.error)
20 |
21 | # traceback.format_exception returns the usual python message about an exception, but as a
22 | # list of strings rather than a single string, so we have to join them together.
23 | tb_list = traceback.format_exception(
24 | None, context.error, cast(Exception, context.error).__traceback__
25 | )
26 | tb_string = "".join(tb_list)
27 |
28 | # Build the message with some markup and additional information about what happened.
29 | # You might need to add some logic to deal with messages longer than the 4096 character limit.
30 | update_str = update.to_dict() if isinstance(update, Update) else str(update)
31 | message_1 = (
32 | f"An exception was raised while handling an update\n\n"
33 | f"update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}
"
34 | )
35 | message_2 = f"{html.escape(tb_string)}
"
36 |
37 | # Finally, send the messages
38 | # We send update and traceback in two parts to reduce the chance of hitting max length
39 | try:
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)
44 | except BadRequest as exc:
45 | if "too long" in str(exc):
46 | message = (
47 | f"Hey.\nThe error {html.escape(str(context.error))}
happened."
48 | f" The traceback is too long to send, but it was written to the log."
49 | )
50 | await context.bot.send_message(chat_id=ERROR_CHANNEL_CHAT_ID, text=message)
51 | else:
52 | raise exc
53 |
--------------------------------------------------------------------------------
/components/github.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
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()
19 | self.issues: Dict[int, Issue] = {}
20 | self.pull_requests: Dict[int, PullRequest] = {}
21 | self.discussions: Dict[int, Discussion] = {}
22 | self.issue_iterator: Optional[Iterable[Issue]] = None
23 | self.ptb_contribs: Dict[str, PTBContrib] = {}
24 | self.examples: Dict[str, Example] = {}
25 |
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()
31 |
32 | @property
33 | def all_ptbcontribs(self) -> List[PTBContrib]:
34 | return list(self.ptb_contribs.values())
35 |
36 | @property
37 | def all_issues(self) -> List[Issue]:
38 | return list(self.issues.values())
39 |
40 | @property
41 | def all_pull_requests(self) -> List[PullRequest]:
42 | return list(self.pull_requests.values())
43 |
44 | @property
45 | def all_discussions(self) -> List[Discussion]:
46 | return list(self.discussions.values())
47 |
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)
97 | try:
98 | thread = await self._gql_client.get_thread(
99 | number=number, organization=owner, repository=repo
100 | )
101 |
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)
123 | try:
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
128 | )
129 | return None
130 |
--------------------------------------------------------------------------------
/components/graphql_queries/getCommit.gql:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/components/graphql_queries/getDiscussions.gql:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/components/graphql_queries/getExamples.gql:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/components/graphql_queries/getIssues.gql:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/components/graphql_queries/getPTBContribs.gql:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/components/graphql_queries/getPullRequests.gql:
--------------------------------------------------------------------------------
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 | }
--------------------------------------------------------------------------------
/components/graphql_queries/getThread.gql:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
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 | # OAuth token must be prepended with "Bearer". User might forget to do this.
16 | authorization = auth if auth.casefold().startswith("bearer ") else f"Bearer {auth}"
17 |
18 | self._transport = AIOHTTPTransport(
19 | url="https://api.github.com/graphql",
20 | headers={
21 | "Authorization": authorization,
22 | "user-agent": user_agent,
23 | },
24 | )
25 | self._session = AsyncClientSession(Client(transport=self._transport))
26 |
27 | async def initialize(self) -> None:
28 | await self._transport.connect()
29 |
30 | async def shutdown(self) -> None:
31 | await self._transport.close()
32 |
33 | async def _do_request(
34 | self, query_name: str, variable_values: Dict[str, Any] = None
35 | ) -> Dict[str, Any]:
36 | return await self._session.execute(
37 | gql(Path(f"components/graphql_queries/{query_name}.gql").read_text(encoding="utf-8")),
38 | variable_values=variable_values,
39 | )
40 |
41 | async def get_examples(self) -> List[Example]:
42 | """The all examples on the master branch"""
43 | result = await self._do_request("getExamples")
44 | return [
45 | Example(name=file["name"])
46 | for file in result["repository"]["object"]["entries"]
47 | if file["name"].endswith(".py")
48 | ]
49 |
50 | async def get_ptb_contribs(self) -> List[PTBContrib]:
51 | """The all ptb_contribs on the main branch"""
52 | result = await self._do_request("getPTBContribs")
53 | return [
54 | PTBContrib(
55 | name=contrib["name"],
56 | url=f"{PTBCONTRIB_LINK}tree/main/ptbcontrib/{contrib['name']}",
57 | )
58 | for contrib in result["repository"]["object"]["entries"]
59 | if contrib["type"] == "tree"
60 | ]
61 |
62 | async def get_thread(
63 | self,
64 | number: int,
65 | organization: str = DEFAULT_REPO_OWNER,
66 | repository: str = DEFAULT_REPO_NAME,
67 | ) -> Union[Issue, PullRequest, Discussion]:
68 | """Get a specific thread (issue/pr/discussion) on any repository. By default, ptb/ptb
69 | will be searched"""
70 | # The try-except is needed because we query for both issueOrPR & discussion at the same
71 | # time, but it will only ever be one of them. Unfortunately we don't know which one …
72 | try:
73 | result = await self._do_request(
74 | "getThread",
75 | variable_values={
76 | "number": number,
77 | "organization": organization,
78 | "repository": repository,
79 | },
80 | )
81 | except TransportQueryError as exc:
82 | # … but the exc.data will contain the thread that is available
83 | if not exc.data:
84 | raise exc
85 | result = exc.data
86 |
87 | data = result["repository"]
88 | thread_data = data["issueOrPullRequest"] or data["discussion"]
89 |
90 | entry_type_data = {
91 | "owner": organization,
92 | "repo": repository,
93 | "number": number,
94 | "title": thread_data["title"],
95 | "url": thread_data["url"],
96 | "author": thread_data["author"]["login"],
97 | }
98 |
99 | if thread_data.get("__typename") == "Issue":
100 | return Issue(**entry_type_data)
101 | if thread_data.get("__typename") == "PullRequest":
102 | return PullRequest(**entry_type_data)
103 | return Discussion(**entry_type_data)
104 |
105 | async def get_commit(
106 | self,
107 | sha: str,
108 | organization: str = DEFAULT_REPO_OWNER,
109 | repository: str = DEFAULT_REPO_NAME,
110 | ) -> Commit:
111 | """Get a specific commit on any repository. By default, ptb/ptb
112 | will be searched"""
113 | result = await self._do_request(
114 | "getCommit",
115 | variable_values={
116 | "sha": sha,
117 | "organization": organization,
118 | "repository": repository,
119 | },
120 | )
121 | data = result["repository"]["object"]
122 | return Commit(
123 | owner=organization,
124 | repo=repository,
125 | sha=data["oid"],
126 | url=data["url"],
127 | title=data["message"],
128 | author=data["author"]["user"]["login"],
129 | )
130 |
131 | async def get_issues(self, cursor: str = None) -> Tuple[List[Issue], Optional[str]]:
132 | """Last 100 issues before cursor"""
133 | result = await self._do_request("getIssues", variable_values={"cursor": cursor})
134 | return [
135 | Issue(
136 | owner=DEFAULT_REPO_OWNER,
137 | repo=DEFAULT_REPO_NAME,
138 | number=issue["number"],
139 | title=issue["title"],
140 | url=issue["url"],
141 | author=issue["author"]["login"] if issue["author"] else None,
142 | )
143 | for issue in result["repository"]["issues"]["nodes"]
144 | ], result["repository"]["issues"]["pageInfo"]["startCursor"]
145 |
146 | async def get_pull_requests(
147 | self, cursor: str = None
148 | ) -> Tuple[List[PullRequest], Optional[str]]:
149 | """Last 100 pull requests before cursor"""
150 | result = await self._do_request("getPullRequests", variable_values={"cursor": cursor})
151 | return [
152 | PullRequest(
153 | owner=DEFAULT_REPO_OWNER,
154 | repo=DEFAULT_REPO_NAME,
155 | number=pull_request["number"],
156 | title=pull_request["title"],
157 | url=pull_request["url"],
158 | author=pull_request["author"]["login"] if pull_request["author"] else None,
159 | )
160 | for pull_request in result["repository"]["pullRequests"]["nodes"]
161 | ], result["repository"]["pullRequests"]["pageInfo"]["startCursor"]
162 |
163 | async def get_discussions(self, cursor: str = None) -> Tuple[List[Discussion], Optional[str]]:
164 | """Last 100 discussions before cursor"""
165 | result = await self._do_request("getDiscussions", variable_values={"cursor": cursor})
166 | return [
167 | Discussion(
168 | owner=DEFAULT_REPO_OWNER,
169 | repo=DEFAULT_REPO_NAME,
170 | number=discussion["number"],
171 | title=discussion["title"],
172 | url=discussion["url"],
173 | author=discussion["author"]["login"] if discussion["author"] else None,
174 | )
175 | for discussion in result["repository"]["discussions"]["nodes"]
176 | ], result["repository"]["discussions"]["pageInfo"]["startCursor"]
177 |
--------------------------------------------------------------------------------
/components/inlinequeries.py:
--------------------------------------------------------------------------------
1 | from copy import deepcopy
2 | from typing import cast
3 | from uuid import uuid4
4 |
5 | from telegram import (
6 | InlineKeyboardMarkup,
7 | InlineQuery,
8 | InlineQueryResultArticle,
9 | InputTextMessageContent,
10 | Update,
11 | )
12 | from telegram.error import BadRequest
13 | from telegram.ext import ContextTypes
14 |
15 | from components.const import ENCLOSED_REGEX, ENCLOSING_REPLACEMENT_CHARACTER
16 | from components.entrytypes import Issue
17 | from components.search import Search
18 |
19 |
20 | def article(
21 | title: str = "",
22 | description: str = "",
23 | message_text: str = "",
24 | key: str = None,
25 | reply_markup: InlineKeyboardMarkup = None,
26 | ) -> InlineQueryResultArticle:
27 | return InlineQueryResultArticle(
28 | id=key or str(uuid4()),
29 | title=title,
30 | description=description,
31 | input_message_content=InputTextMessageContent(message_text=message_text),
32 | reply_markup=reply_markup,
33 | )
34 |
35 |
36 | async def inline_query(
37 | update: Update, context: ContextTypes.DEFAULT_TYPE
38 | ) -> None: # pylint: disable=R0915
39 | ilq = cast(InlineQuery, update.inline_query)
40 | query = ilq.query
41 | switch_pm_text = "❓ Help"
42 | search = cast(Search, context.bot_data["search"])
43 |
44 | if ENCLOSED_REGEX.search(query):
45 | results_list = []
46 | symbols = tuple(ENCLOSED_REGEX.findall(query))
47 | search_results = await search.multi_search_combinations(symbols)
48 |
49 | for combination in search_results:
50 | if len(symbols) == 1:
51 | # If we have only one search term, we can show a more verbose description
52 | description = list(combination.values())[0].display_name
53 | else:
54 | description = ", ".join(entry.short_description for entry in combination.values())
55 |
56 | message_text = query
57 | index = []
58 | buttons = None
59 |
60 | for symbol, entry in combination.items():
61 | char = ENCLOSING_REPLACEMENT_CHARACTER
62 | message_text = message_text.replace(
63 | f"{char}{symbol}{char}", entry.html_insertion_markup(symbol)
64 | )
65 | if isinstance(entry, Issue):
66 | index.append(entry.html_markup(symbol))
67 | # Merge keyboards into one
68 | if entry_kb := entry.inline_keyboard:
69 | if buttons is None:
70 | buttons = [
71 | [deepcopy(button) for button in row]
72 | for row in entry_kb.inline_keyboard
73 | ]
74 | else:
75 | buttons.extend(entry_kb.inline_keyboard)
76 |
77 | keyboard = InlineKeyboardMarkup(buttons) if buttons else None
78 |
79 | if index:
80 | message_text += "\n\n" + "\n".join(index)
81 |
82 | results_list.append(
83 | article(
84 | title="Insert links into message",
85 | description=description,
86 | message_text=message_text,
87 | reply_markup=keyboard,
88 | )
89 | )
90 | else:
91 | simple_search_results = await search.search(query)
92 | if not simple_search_results:
93 | results_list = []
94 | switch_pm_text = "❌ No Search Results Found"
95 | else:
96 | results_list = [
97 | article(
98 | title=entry.display_name,
99 | description=entry.description,
100 | message_text=entry.html_markup(query),
101 | reply_markup=entry.inline_keyboard,
102 | )
103 | for entry in simple_search_results
104 | ]
105 |
106 | try:
107 | await ilq.answer(
108 | results=results_list,
109 | switch_pm_text=switch_pm_text,
110 | switch_pm_parameter="inline-help",
111 | cache_time=0,
112 | auto_pagination=True,
113 | )
114 | except BadRequest as exc:
115 | if "can't parse entities" not in exc.message:
116 | raise exc
117 | await ilq.answer(
118 | results=[],
119 | switch_pm_text="❌ Invalid entities. Click me.",
120 | switch_pm_parameter="inline-entity-parsing",
121 | )
122 |
--------------------------------------------------------------------------------
/components/joinrequests.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import Tuple, Union, cast
3 |
4 | from telegram import (
5 | CallbackQuery,
6 | ChatJoinRequest,
7 | InlineKeyboardButton,
8 | InlineKeyboardMarkup,
9 | Message,
10 | Update,
11 | User,
12 | )
13 | from telegram.error import BadRequest, Forbidden
14 | from telegram.ext import ContextTypes, Job, JobQueue
15 |
16 | from components.const import (
17 | ERROR_CHANNEL_CHAT_ID,
18 | OFFTOPIC_CHAT_ID,
19 | OFFTOPIC_RULES,
20 | ONTOPIC_CHAT_ID,
21 | ONTOPIC_RULES,
22 | ONTOPIC_USERNAME,
23 | )
24 |
25 |
26 | def get_dtm_str() -> str:
27 | return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S:%f")
28 |
29 |
30 | async def approve_user(
31 | user: Union[int, User], chat_id: int, group_name: str, context: ContextTypes.DEFAULT_TYPE
32 | ) -> None:
33 | try:
34 | if isinstance(user, User):
35 | await user.approve_join_request(chat_id=chat_id)
36 | else:
37 | await context.bot.approve_chat_join_request(user_id=user, chat_id=chat_id)
38 | except BadRequest as exc:
39 | user_mention = f"{user.username} - {user.id}" if isinstance(user, User) else str(user)
40 | error_message = f"{exc} - {user_mention} - {group_name}"
41 | raise BadRequest(error_message) from exc
42 | except Forbidden as exc:
43 | if "user is deactivated" not in exc.message:
44 | raise exc
45 |
46 |
47 | async def decline_user(
48 | user: Union[int, User], chat_id: int, group_name: str, context: ContextTypes.DEFAULT_TYPE
49 | ) -> None:
50 | try:
51 | if isinstance(user, User):
52 | await user.decline_join_request(chat_id=chat_id)
53 | else:
54 | await context.bot.decline_chat_join_request(user_id=user, chat_id=chat_id)
55 | except BadRequest as exc:
56 | user_mention = f"{user.username} - {user.id}" if isinstance(user, User) else str(user)
57 | error_message = f"{exc} - {user_mention} - {group_name}"
58 | raise BadRequest(error_message) from exc
59 | except Forbidden as exc:
60 | if "user is deactivated" not in exc.message:
61 | raise exc
62 |
63 |
64 | async def join_request_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
65 | join_request = cast(ChatJoinRequest, update.chat_join_request)
66 | user = join_request.from_user
67 | user_chat_id = join_request.user_chat_id
68 | chat_id = join_request.chat.id
69 | jobs = cast(JobQueue, context.job_queue).get_jobs_by_name(f"JOIN_TIMEOUT {chat_id} {user.id}")
70 | if jobs:
71 | # No need to ping the user again if we already did
72 | return
73 |
74 | on_topic = join_request.chat.username == ONTOPIC_USERNAME
75 | group_mention = ONTOPIC_CHAT_ID if on_topic else OFFTOPIC_CHAT_ID
76 |
77 | text = (
78 | f"Hi, {user.mention_html()}! I'm {context.bot.bot.mention_html()}, the "
79 | f"guardian of the group {group_mention}, that you requested to join.\n\nBefore you can "
80 | "join the group, please carefully read the following rules of the group. Confirm that you "
81 | "have read them by double-tapping the button at the bottom of the message - that's it 🙃"
82 | f"\n\n{ONTOPIC_RULES if on_topic else OFFTOPIC_RULES}\n\n"
83 | "ℹ️ If I fail to react to your confirmation within 2 hours, please contact one of the"
84 | "administrators of the group. Admins are marked as such in the list of group members."
85 | )
86 | reply_markup = InlineKeyboardMarkup.from_button(
87 | InlineKeyboardButton(
88 | text="I have read the rules 📖",
89 | callback_data=f"JOIN 1 {chat_id}",
90 | )
91 | )
92 | try:
93 | message = await context.bot.send_message(
94 | chat_id=user_chat_id, text=text, reply_markup=reply_markup
95 | )
96 | except Forbidden:
97 | # If the user blocked the bot, let's give the admins a chance to handle that
98 | # TG also notifies the user and forwards the message once the user unblocks the bot, but
99 | # forwarding it still doesn't hurt ...
100 | text = (
101 | f"User {user.mention_html()} with id {user.id} requested to join the group "
102 | f"{join_request.chat.username} but has blocked me. Please manually handle this."
103 | )
104 | await context.bot.send_message(chat_id=ERROR_CHANNEL_CHAT_ID, text=text)
105 | return
106 |
107 | cast(JobQueue, context.job_queue).run_once(
108 | callback=join_request_timeout_job,
109 | when=datetime.timedelta(hours=2),
110 | data=(user, message, group_mention),
111 | name=f"JOIN_TIMEOUT {chat_id} {user.id}",
112 | user_id=user.id,
113 | chat_id=chat_id,
114 | )
115 |
116 |
117 | async def join_request_buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
118 | callback_query = cast(CallbackQuery, update.callback_query)
119 | user = cast(User, update.effective_user)
120 | _, press, chat_id = cast(str, callback_query.data).split()
121 | if press == "2":
122 | jobs = cast(JobQueue, context.job_queue).get_jobs_by_name(
123 | f"JOIN_TIMEOUT {chat_id} {user.id}"
124 | )
125 | if jobs:
126 | for job in jobs:
127 | job.schedule_removal()
128 |
129 | try:
130 | await approve_user(
131 | user=user, chat_id=int(chat_id), group_name="Unknown", context=context
132 | )
133 | except BadRequest as exc:
134 | # If the user was already approved for some reason, we can just ignore the error
135 | if "User_already_participant" not in exc.message:
136 | raise exc
137 |
138 | reply_markup = None
139 | else:
140 | reply_markup = InlineKeyboardMarkup.from_button(
141 | InlineKeyboardButton(
142 | text="⚠️ Tap again to confirm",
143 | callback_data=f"JOIN 2 {chat_id}",
144 | )
145 | )
146 |
147 | try:
148 | await callback_query.edit_message_reply_markup(reply_markup=reply_markup)
149 | except BadRequest as exc:
150 | # Ignore people clicking the button too quickly
151 | if "Message is not modified" not in exc.message:
152 | raise exc
153 |
154 |
155 | async def join_request_timeout_job(context: ContextTypes.DEFAULT_TYPE) -> None:
156 | job = cast(Job, context.job)
157 | chat_id = cast(int, job.chat_id)
158 | user, message, group = cast(Tuple[User, Message, str], job.data)
159 | text = (
160 | f"Your request to join the group {group} has timed out. Please send a new request to join."
161 | )
162 | await decline_user(user=user, chat_id=chat_id, group_name=group, context=context)
163 | try:
164 | await message.edit_text(text=text)
165 | except Forbidden as exc:
166 | if "user is deactivated" not in exc.message:
167 | raise exc
168 | except BadRequest as exc:
169 | # These apparently happen frequently, e.g. when user clear the chat
170 | if exc.message not in [
171 | "Message to edit not found",
172 | "Can't access the chat",
173 | "Chat not found",
174 | ]:
175 | raise exc
176 |
--------------------------------------------------------------------------------
/components/rulesjobqueue.py:
--------------------------------------------------------------------------------
1 | from telegram.ext import JobQueue
2 |
3 |
4 | class RulesJobQueue(JobQueue):
5 | """Subclass of JobQueue to add custom stop behavior."""
6 |
7 | async def stop(self, wait: bool = True) -> None:
8 | """Declines all join requests and stops the job queue. That way, users will know that
9 | they have to apply to join again."""
10 | # We loop instead of `asyncio.gather`-ing to minimize the risk of timeouts & flood limits
11 | for job in self.jobs():
12 | if job.name and job.name.startswith("JOIN_TIMEOUT"):
13 | await job.run(self.application)
14 | await super().stop(wait)
15 |
--------------------------------------------------------------------------------
/components/search.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import datetime
3 | import heapq
4 | import itertools
5 | from io import BytesIO
6 | from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
7 | from urllib.parse import urljoin
8 |
9 | import httpx
10 | from async_lru import alru_cache
11 | from bs4 import BeautifulSoup
12 | from sphinx.util.inventory import InventoryFile
13 | from telegram.ext import Application, ContextTypes, Job, JobQueue
14 |
15 | from .const import (
16 | DEFAULT_HEADERS,
17 | DEFAULT_REPO_NAME,
18 | DEFAULT_REPO_OWNER,
19 | DOCS_URL,
20 | EXAMPLES_URL,
21 | GITHUB_PATTERN,
22 | OFFICIAL_URL,
23 | USER_AGENT,
24 | WIKI_CODE_SNIPPETS_URL,
25 | WIKI_FAQ_URL,
26 | WIKI_FRDP_URL,
27 | WIKI_URL,
28 | )
29 | from .entrytypes import (
30 | BaseEntry,
31 | CodeSnippet,
32 | DocEntry,
33 | FAQEntry,
34 | FRDPEntry,
35 | ParamDocEntry,
36 | ReadmeSection,
37 | WikiPage,
38 | )
39 | from .github import GitHub
40 | from .taghints import TAG_HINTS
41 |
42 |
43 | class Search:
44 | def __init__(self, github_auth: str, github_user_agent: str = USER_AGENT) -> None:
45 | self.__lock = asyncio.Lock()
46 | self._docs: List[DocEntry] = []
47 | self._readme: List[ReadmeSection] = []
48 | self._official: Dict[str, str] = {}
49 | self._wiki: List[WikiPage] = []
50 | self._snippets: List[CodeSnippet] = []
51 | self._faq: List[FAQEntry] = []
52 | self._design_patterns: List[FRDPEntry] = []
53 | self.github = GitHub(auth=github_auth, user_agent=github_user_agent)
54 | self._httpx_client = httpx.AsyncClient(headers=DEFAULT_HEADERS)
55 |
56 | async def initialize(
57 | self, application: Application[Any, Any, Any, Any, Any, JobQueue]
58 | ) -> None:
59 | await self.github.initialize()
60 | job_queue = cast(JobQueue, application.job_queue)
61 | job_queue.run_once(callback=self.update_job, when=1, data=(None, None, None))
62 |
63 | async def shutdown(self) -> None:
64 | await self.github.shutdown()
65 | await self._httpx_client.aclose()
66 | await self.search.close() # pylint:disable=no-member
67 | await self.multi_search_combinations.close() # pylint:disable=no-member
68 |
69 | async def update_job(self, context: ContextTypes.DEFAULT_TYPE) -> None:
70 | job = cast(Job, context.job)
71 | cursors = cast(Tuple[Optional[str], Optional[str], Optional[str]], job.data)
72 | restart = not any(cursors)
73 |
74 | if restart:
75 | await asyncio.gather(
76 | context.application.create_task(self.github.update_examples()),
77 | context.application.create_task(self.github.update_ptb_contribs()),
78 | )
79 | async with self.__lock:
80 | await asyncio.gather(
81 | context.application.create_task(self.update_readme()),
82 | context.application.create_task(self.update_docs()),
83 | context.application.create_task(self.update_wiki()),
84 | context.application.create_task(self.update_wiki_code_snippets()),
85 | context.application.create_task(self.update_wiki_faq()),
86 | context.application.create_task(self.update_wiki_design_patterns()),
87 | )
88 |
89 | issue_cursor = (
90 | await self.github.update_issues(cursor=cursors[0]) if restart or cursors[0] else None
91 | )
92 | pr_cursor = (
93 | await self.github.update_pull_requests(cursor=cursors[1])
94 | if restart or cursors[1]
95 | else None
96 | )
97 | discussion_cursor = (
98 | await self.github.update_discussions(cursor=cursors[2])
99 | if restart or cursors[2]
100 | else None
101 | )
102 |
103 | new_cursors = (issue_cursor, pr_cursor, discussion_cursor)
104 | when = datetime.timedelta(seconds=30) if any(new_cursors) else datetime.timedelta(hours=12)
105 | cast(JobQueue, context.job_queue).run_once(
106 | callback=self.update_job, when=when, data=new_cursors
107 | )
108 |
109 | # This is important: If the docs have changed the cache is useless
110 | self.search.cache_clear() # pylint:disable=no-member
111 | self.multi_search_combinations.cache_clear() # pylint:disable=no-member
112 |
113 | async def _update_official_docs(self) -> None:
114 | response = await self._httpx_client.get(url=OFFICIAL_URL)
115 | official_soup = BeautifulSoup(response.content, "html.parser")
116 | for anchor in official_soup.select("a.anchor"):
117 | if "-" not in anchor["href"]:
118 | self._official[anchor["href"][1:]] = anchor.next_sibling
119 |
120 | async def update_docs(self) -> None:
121 | await self._update_official_docs()
122 | response = await self._httpx_client.get(
123 | url=urljoin(DOCS_URL, "objects.inv"),
124 | headers=DEFAULT_HEADERS,
125 | follow_redirects=True,
126 | )
127 | data = InventoryFile.load(BytesIO(response.content), DOCS_URL, urljoin)
128 | self._docs = []
129 | for entry_type, items in data.items():
130 | for name, (_, _, url, display_name) in items.items():
131 | if "._" in name:
132 | # For some reason both `ext._application.Application` and `ext.Application`
133 | # are present ...
134 | continue
135 |
136 | tg_url, tg_test, tg_name = "", "", ""
137 | name_bits = name.split(".")
138 |
139 | if entry_type == "py:method" and (
140 | "telegram.Bot" in name or "telegram.ext.ExtBot" in name
141 | ):
142 | tg_test = name_bits[-1]
143 | if entry_type == "py:attribute":
144 | tg_test = name_bits[-2]
145 | if entry_type == "py:class":
146 | tg_test = name_bits[-1]
147 | elif entry_type == "py:parameter":
148 | tg_test = name_bits[-4]
149 |
150 | tg_test = tg_test.replace("_", "").lower()
151 |
152 | if tg_test in self._official:
153 | tg_name = self._official[tg_test]
154 | tg_url = urljoin(OFFICIAL_URL, "#" + tg_name.lower())
155 |
156 | if entry_type == "py:parameter":
157 | self._docs.append(
158 | ParamDocEntry(
159 | name=name,
160 | url=url,
161 | display_name=display_name if display_name.strip() != "-" else None,
162 | entry_type=entry_type,
163 | telegram_url=tg_url,
164 | telegram_name=tg_name,
165 | )
166 | )
167 | else:
168 | self._docs.append(
169 | DocEntry(
170 | name=name,
171 | url=url,
172 | display_name=display_name if display_name.strip() != "-" else None,
173 | entry_type=entry_type,
174 | telegram_url=tg_url,
175 | telegram_name=tg_name,
176 | )
177 | )
178 |
179 | async def update_readme(self) -> None:
180 | response = await self._httpx_client.get(url=DOCS_URL, follow_redirects=True)
181 | readme_soup = BeautifulSoup(response.content, "html.parser")
182 | self._readme = []
183 |
184 | # parse section headers from readme
185 | for tag in ["h1", "h2", "h3", "h4", "h5"]:
186 | for headline in readme_soup.select(tag):
187 | # check if element is inside a hidden div - special casing for the
188 | # "Hidden Headline" we include for furo
189 | if headline.find_parent("div", attrs={"style": "display: none"}):
190 | continue
191 | self._readme.append(
192 | ReadmeSection(
193 | name=str(headline.contents[0]).strip(), anchor=headline.find("a")["href"]
194 | )
195 | )
196 |
197 | async def update_wiki(self) -> None:
198 | response = await self._httpx_client.get(url=WIKI_URL)
199 | wiki_soup = BeautifulSoup(response.content, "html.parser")
200 | self._wiki = []
201 |
202 | # Parse main pages from custom sidebar
203 | for tag in ["ol", "ul"]:
204 | for element in wiki_soup.select(f"div.wiki-custom-sidebar > {tag}"):
205 | category = element.find_previous_sibling("div").text.strip()
206 | for list_item in element.select("li"):
207 | if list_item.a["href"] != "#":
208 | self._wiki.append(
209 | WikiPage(
210 | category=category,
211 | name=list_item.a.text.strip(),
212 | url=urljoin(WIKI_URL, list_item.a["href"]),
213 | )
214 | )
215 |
216 | self._wiki.append(WikiPage(category="Code Resources", name="Examples", url=EXAMPLES_URL))
217 |
218 | async def update_wiki_code_snippets(self) -> None:
219 | response = await self._httpx_client.get(url=WIKI_CODE_SNIPPETS_URL)
220 | code_snippet_soup = BeautifulSoup(response.content, "html.parser")
221 | self._snippets = []
222 | for headline in code_snippet_soup.select(
223 | "div#wiki-body h4,div#wiki-body h3,div#wiki-body h2"
224 | ):
225 | self._snippets.append(
226 | CodeSnippet(
227 | name=headline.text.strip(),
228 | url=urljoin(WIKI_CODE_SNIPPETS_URL, headline.find_next_sibling("a")["href"]),
229 | )
230 | )
231 |
232 | async def update_wiki_faq(self) -> None:
233 | response = await self._httpx_client.get(url=WIKI_FAQ_URL)
234 | faq_soup = BeautifulSoup(response.content, "html.parser")
235 | self._faq = []
236 | for headline in faq_soup.select("div#wiki-body h3"):
237 | self._faq.append(
238 | FAQEntry(
239 | name=headline.text.strip(),
240 | url=urljoin(WIKI_FAQ_URL, headline.find_next_sibling("a")["href"]),
241 | )
242 | )
243 |
244 | async def update_wiki_design_patterns(self) -> None:
245 | response = await self._httpx_client.get(url=WIKI_FRDP_URL)
246 | frdp_soup = BeautifulSoup(response.content, "html.parser")
247 | self._design_patterns = []
248 | for headline in frdp_soup.select("div#wiki-body h3,div#wiki-body h2"):
249 | self._design_patterns.append(
250 | FRDPEntry(
251 | name=headline.text.strip(),
252 | url=urljoin(WIKI_FRDP_URL, headline.find_next_sibling("a")["href"]),
253 | )
254 | )
255 |
256 | @staticmethod
257 | def _sort_key(entry: BaseEntry, search_query: str) -> float:
258 | return entry.compare_to_query(search_query)
259 |
260 | @alru_cache(maxsize=64) # type: ignore[misc]
261 | async def search(
262 | self, search_query: Optional[str], amount: int = None
263 | ) -> Optional[List[BaseEntry]]:
264 | """Searches all available entries for appropriate results. This includes:
265 |
266 | * readme sections
267 | * wiki pages
268 | * FAQ entries
269 | * Design Pattern entries
270 | * Code snippets
271 | * examples
272 | * documentation
273 | * ptbcontrib
274 | * issues & PRs on GH
275 |
276 | If the query is in one of the following formats, the search will *only* attempt to fand
277 | one corresponding GitHub result:
278 |
279 | * ((owner)/repo)#
280 | * @
281 |
282 | If the query is in the format `#some search query`, only the issues on
283 | python-telegram-bot/python-telegram-bot will be searched.
284 |
285 | If the query is in the format `ptbcontrib/`, only the contributions
286 | of ptbcontrib will be searched.
287 |
288 | If the query is in the format `/search query`, only the tags hints will be searched.
289 |
290 | Args:
291 | search_query: The search query. May be None, in which case all available entries
292 | will be given.
293 | amount: Optional. If passed, returns the ``amount`` elements with the highest
294 | comparison score.
295 |
296 | Returns:
297 | The results sorted by comparison score.
298 | """
299 | search_entries: Iterable[BaseEntry] = []
300 |
301 | match = GITHUB_PATTERN.fullmatch(search_query) if search_query else None
302 | if match:
303 | owner, repo, number, sha, gh_search_query, ptbcontrib = (
304 | match.groupdict()[x]
305 | for x in ("owner", "repo", "number", "sha", "query", "ptbcontrib")
306 | )
307 | owner = owner or DEFAULT_REPO_OWNER
308 | repo = repo or DEFAULT_REPO_NAME
309 |
310 | # If it's an issue
311 | if number:
312 | issue = await self.github.get_thread(int(number), owner, repo)
313 | return [issue] if issue else None
314 | # If it's a commit
315 | if sha:
316 | commit = await self.github.get_commit(sha, owner, repo)
317 | return [commit] if commit else None
318 | # If it's a search
319 | if gh_search_query:
320 | search_query = gh_search_query
321 | search_entries = itertools.chain(
322 | self.github.all_issues,
323 | self.github.all_pull_requests,
324 | self.github.all_discussions,
325 | )
326 | elif ptbcontrib:
327 | search_entries = self.github.all_ptbcontribs
328 |
329 | if search_query and search_query.startswith("/"):
330 | search_entries = TAG_HINTS.values()
331 |
332 | async with self.__lock:
333 | if not search_entries:
334 | search_entries = itertools.chain(
335 | self._readme,
336 | self._wiki,
337 | self.github.all_examples,
338 | self._faq,
339 | self._design_patterns,
340 | self._snippets,
341 | self.github.all_ptbcontribs,
342 | self._docs,
343 | TAG_HINTS.values(),
344 | )
345 |
346 | if not search_query:
347 | return search_entries if isinstance(search_entries, list) else list(search_entries)
348 |
349 | if not amount:
350 | return sorted(
351 | search_entries,
352 | key=lambda entry: self._sort_key(entry, search_query),
353 | reverse=True,
354 | )
355 | return heapq.nlargest(
356 | amount,
357 | search_entries,
358 | key=lambda entry: self._sort_key(entry, search_query),
359 | )
360 |
361 | @alru_cache(maxsize=64) # type: ignore[misc]
362 | async def multi_search_combinations(
363 | self, search_queries: Tuple[str], results_per_query: int = 3
364 | ) -> List[Dict[str, BaseEntry]]:
365 | """For each query, runs :meth:`search` and fetches the ``results_per_query`` most likely
366 | results. Then builds all possible combinations.
367 |
368 | Args:
369 | search_queries: The search queries.
370 | results_per_query: Optional. Number of results to fetch per query. Defaults to ``3``.
371 |
372 | Returns:
373 | All possible result combinations. Each list entry is a dictionary mapping each query
374 | to the corresponding :class:`BaseEntry`.
375 |
376 | """
377 | # Don't use a page-argument here, as the number of results will usually be relatively small
378 | # so we can just build the list once and get slices from the cached result if necessary
379 |
380 | results = {}
381 | # Remove duplicates while maintaining the order
382 | effective_queries = list(dict.fromkeys(search_queries))
383 | for query in effective_queries:
384 | if res := await self.search(search_query=query, amount=results_per_query):
385 | results[query] = res
386 |
387 | return [
388 | dict(zip(effective_queries, query_results))
389 | for query_results in itertools.product(*results.values())
390 | ]
391 |
--------------------------------------------------------------------------------
/components/taghints.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Any, Dict, List, Match, Optional
3 |
4 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageEntity
5 | from telegram.ext.filters import MessageFilter
6 |
7 | from components import const
8 | from components.const import DOCS_URL, PTBCONTRIB_LINK
9 | from components.entrytypes import TagHint
10 |
11 | # Tag hints should be used for "meta" hints, i.e. pointing out how to use the PTB groups
12 | # Explaining functionality should be done in the wiki instead.
13 | #
14 | # Note that wiki pages are available through the search directly, but the Ask-Right and MWE pages
15 | # are needed so frequently that we provide tag hints for them ...
16 | _TAG_HINTS: Dict[str, Dict[str, Any]] = {
17 | "askright": {
18 | "message": (
19 | '{query} Please read this short article and try again ;)'
21 | ),
22 | "help": "The wiki page about asking technical questions",
23 | "default": (
24 | "Hey. In order for someone to be able to help you, you must ask a good "
25 | "technical question."
26 | ),
27 | },
28 | "mwe": {
29 | "message": (
30 | "{query} Please follow these instructions on how to write a "
31 | 'Minimal Working Example (MWE).'
33 | ),
34 | "help": "How to build an MWE for PTB.",
35 | "default": "Hey. Please provide a minimal working example (MWE).",
36 | },
37 | "inline": {
38 | "message": (
39 | f"Consider using me in inline-mode 😎 @{const.SELF_BOT_NAME} " + "{query}
"
40 | ),
41 | "default": "Your search terms",
42 | "buttons": [[InlineKeyboardButton(text="🔎 Try it out", switch_inline_query="")]],
43 | "help": "Give a query that will be used for a switch_to_inline-button",
44 | },
45 | "private": {
46 | "message": "Please don't spam the group with {query}, and go to a private "
47 | "chat with me instead. Thanks a lot, the other members will appreciate it 😊",
48 | "default": "searches or commands",
49 | "buttons": [
50 | [
51 | InlineKeyboardButton(
52 | text="🤖 Go to private chat", url=f"https://t.me/{const.SELF_BOT_NAME}"
53 | )
54 | ]
55 | ],
56 | "help": "Tell a member to stop spamming and switch to a private chat",
57 | },
58 | "userbot": {
59 | "message": (
60 | '{query} Refer to this article to learn more about Userbots.'
62 | ),
63 | "help": "What are Userbots?",
64 | "default": "",
65 | },
66 | "meta": {
67 | "message": (
68 | 'No need for meta questions. Just ask! 🤗'
69 | '"Has anyone done .. before?" '
70 | "Probably. Just ask your question and somebody will help!"
71 | ),
72 | "help": "Show our stance on meta-questions",
73 | },
74 | "tutorial": {
75 | "message": (
76 | "{query}"
77 | "We have compiled a list of learning resources just for you:\n\n"
78 | '• As Beginner'
79 | "\n"
80 | '• As Programmer'
81 | "\n"
82 | '• Official Tutorial\n'
83 | '• Dive into Python\n'
84 | '• Learn Python\n'
85 | '• Computer Science Circles\n'
86 | '• MIT '
88 | "OpenCourse\n"
89 | '• Hitchhiker’s Guide to Python\n'
90 | "• The @PythonRes Telegram Channel.\n"
91 | '• Corey Schafer videos for beginners and in general'
94 | "\n"
95 | '• Project Python\n'
96 | ),
97 | "help": "How to find a Python tutorial",
98 | "default": (
99 | "Oh, hey! There's someone new joining our awesome community of Python developers ❤️ "
100 | ),
101 | },
102 | "wronglib": {
103 | "message": (
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 | 'pyTelegramBotApi, '
108 | 'Telepot, '
109 | 'pyrogram, '
110 | 'Telethon, '
111 | 'aiogram, '
112 | 'botogram.'
113 | ),
114 | "help": "Other Python wrappers for Telegram",
115 | "default": (
116 | "Hey, I think you're wrong 🧐\nThis is the support group of the "
117 | "python-telegram-bot
library."
118 | ),
119 | },
120 | "pastebin": {
121 | "message": (
122 | "{query} Please post code or tracebacks using a pastebin rather than via plain text "
123 | "or a picture. https://pastebin.com/ is quite popular, but there are "
124 | "many alternatives "
125 | "out there. Of course, for very short snippets, text is fine. Please at "
126 | "least format it as monospace in that case."
127 | ),
128 | "help": "Ask users not to post code as text or images.",
129 | "default": "Hey.",
130 | },
131 | "doublepost": {
132 | "message": (
133 | "{query} Please don't double post. Questions usually are on-topic only in one of the "
134 | "two groups anyway."
135 | ),
136 | "help": "Ask users not to post the same question in both on- and off-topic.",
137 | "default": "Hey.",
138 | },
139 | "xy": {
140 | "message": (
141 | '{query} This seems like an xy-problem to me.'
142 | ),
143 | "default": "Hey. What exactly do you want this for?",
144 | "help": "Ask users for the actual use case.",
145 | },
146 | "dontping": {
147 | "message": (
148 | "{query} Please only mention or reply to users directly if you're following up on a "
149 | "conversation with them. Otherwise just ask your question and wait if someone has a "
150 | "solution for you - that's how this group works 😉 Also note that the "
151 | "@admin
tag is only to be used to report spam or abuse!"
152 | ),
153 | "default": "Hey.",
154 | "help": "Tell users not to ping randomly ping you.",
155 | },
156 | "read": {
157 | "message": (
158 | "I just pointed you to {query} and I have the strong feeling that you did not "
159 | "actually read it. Please do so. If you don't understand everything and have "
160 | "follow up questions, that's fine, but you can't expect me to repeat everything "
161 | "just for you because you didn't feel like reading on your own. 😉"
162 | ),
163 | "default": "a resource in the wiki, the docs or the examples",
164 | "help": "Tell users to actually read the resources they were linked to",
165 | },
166 | "ptbcontrib": {
167 | "message": (
168 | "{query} ptbcontrib
is a library that provides extensions for the "
169 | "python-telegram-bot
library that written and maintained by the "
170 | "community of PTB users."
171 | ),
172 | "default": "Hey.",
173 | "buttons": [[InlineKeyboardButton(text="🔗 Take me there!", url=PTBCONTRIB_LINK)]],
174 | "help": "Display a short info text about ptbcontrib",
175 | },
176 | "botlists": {
177 | "message": (
178 | "{query} This group is for technical questions that come up while you code your own "
179 | "Telegram bot. If you are looking for ready-to-use bots, please have a look at "
180 | "channels like @BotsArchive or @BotList/@BotlistBot. There are also a number of "
181 | "websites that list existing bots."
182 | ),
183 | "default": "Hey.",
184 | "help": "Redirect users to lists of existing bots.",
185 | },
186 | "coc": {
187 | "message": (
188 | f'{{query}} Please read our Code of Conduct and '
189 | "stick to it. Note that violation of the CoC can lead to temporary or permanent "
190 | "banishment from this group."
191 | ),
192 | "default": "Hey.",
193 | "help": "Remind the users of the Code of Conduct.",
194 | },
195 | "docs": {
196 | "message": (
197 | f"{{query}} You can find our documentation at Read the "
198 | f"Docs. "
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 Github.",
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 | ''
214 | "GitHub."
215 | ),
216 | "default": "Hey.",
217 | "help": "Point users to the bots readme",
218 | "group_command": True,
219 | },
220 | "upgrade": {
221 | "message": (
222 | "{query} You seem to be using a version <=13.15 of "
223 | "python-telegram-bot
. "
224 | "Please note that we only provide support for the latest stable version and that the "
225 | "library has undergone significant changes in v20. Please consider upgrading to v20 "
226 | "by reading the release notes and the transition guide linked below."
227 | ),
228 | "buttons": [
229 | [
230 | InlineKeyboardButton(
231 | text="🔗 Release Notes",
232 | url="https://telegra.ph/Release-notes-for-python-telegram-bot-v200a0-05-06",
233 | ),
234 | InlineKeyboardButton(
235 | text="🔗 Transition Guide",
236 | url="https://github.com/python-telegram-bot/python-telegram-bot/wiki"
237 | "/Transition-guide-to-Version-20.0",
238 | ),
239 | ]
240 | ],
241 | "default": "Hey.",
242 | "help": "Ask users to upgrade to the latest version of PTB",
243 | "group_command": True,
244 | },
245 | "compat": {
246 | "message": (
247 | "{query} You seem to be using the new version (>=20.0) of "
248 | "python-telegram-bot
but your code is written for an older and "
249 | "deprecated version (<=13.15).\nPlease update your code to the new v20 by reading"
250 | " the release notes and the transition guide linked below.\nYou can also install a "
251 | "version of PTB that is compatible with your code base, but please note that the "
252 | "library has undergone significant changes in v20 and the older version is not "
253 | "supported anymore. It may contain bugs that will not be fixed by the PTB team "
254 | "and it also doesn't support new functions added by newer Bot API releases."
255 | ),
256 | "buttons": [
257 | [
258 | InlineKeyboardButton(
259 | text="🔗 Release Notes",
260 | url="https://telegra.ph/Release-notes-for-python-telegram-bot-v200a0-05-06",
261 | ),
262 | InlineKeyboardButton(
263 | text="🔗 Transition Guide",
264 | url="https://github.com/python-telegram-bot/python-telegram-bot/wiki"
265 | "/Transition-guide-to-Version-20.0",
266 | ),
267 | ]
268 | ],
269 | "default": "Hey.",
270 | "help": "Point out compatibility issues of code and PTB version to users",
271 | "group_command": True,
272 | },
273 | "llm": {
274 | "message": (
275 | "{query} This text reads like an AI/LLM was used to generate this. We found their "
276 | "answers to be unfitting for this group. We are all about providing fine tuned help "
277 | "for technical questions. These generated texts are often long winded, very "
278 | "explanatory answers for steps which didn't need explaining, and then happen to miss "
279 | "the actual underlying question completely or are outright false in the worst case."
280 | "\n\n"
281 | "Please refrain from this in the future. If you can answer a question yourself, we "
282 | "are glad to see a precise, technical answer. If you can not answer a question, it's "
283 | "better to just not reply instead of copy-pasting an autogenerated answer 😉."
284 | ),
285 | "default": "Hey.",
286 | "help": "Tell users not to use AI/LLM generated answers",
287 | "group_command": True,
288 | },
289 | "traceback": {
290 | "message": (
291 | "{query} Please show the full traceback via a pastebin. Make sure to include "
292 | "everything from the first Traceback (most recent call last):
until the "
293 | "last error message. https://pastebin.com/ is a popular pastebin service, but there "
294 | "are many alternatives out "
295 | "there."
296 | ),
297 | "default": "Hey.",
298 | "help": "Ask for the full traceback",
299 | "group_command": True,
300 | },
301 | }
302 |
303 |
304 | # Sort the hints by key
305 | _TAG_HINTS = dict(sorted(_TAG_HINTS.items()))
306 | # convert into proper objects
307 | TAG_HINTS: Dict[str, TagHint] = {
308 | key: TagHint(
309 | tag=key,
310 | message=value["message"],
311 | description=value["help"],
312 | default_query=value.get("default"),
313 | inline_keyboard=InlineKeyboardMarkup(value["buttons"]) if "buttons" in value else None,
314 | group_command=value.get("group_command", False),
315 | )
316 | for key, value in _TAG_HINTS.items()
317 | }
318 | TAG_HINTS_PATTERN = re.compile(
319 | # case insensitive
320 | r"(?i)"
321 | # join the /tags
322 | r"((?P(?P"
323 | rf'{"|".join(hint.short_name for hint in TAG_HINTS.values())})'
324 | # don't allow the tag to be followed by '/' - That could be the start of the next tag
325 | r"(?!/)"
326 | # Optionally the bots username
327 | rf"(@{re.escape(const.SELF_BOT_NAME)})?)"
328 | # match everything that comes next as long as it's separated by a whitespace - important for
329 | # inserting a custom query in inline mode
330 | r"($| (?P[^\/.]*)))"
331 | )
332 |
333 |
334 | class TagHintFilter(MessageFilter):
335 | """Custom filter class for filtering for tag hint messages"""
336 |
337 | def __init__(self) -> None:
338 | super().__init__(name="TageHintFilter", data_filter=True)
339 |
340 | def filter(self, message: Message) -> Optional[Dict[str, List[Match]]]:
341 | """Does the filtering. Applies the regex and makes sure that only those tag hints are
342 | handled, that are also marked as bot command.
343 | """
344 | if not message.text:
345 | return None
346 |
347 | matches = []
348 | command_texts = message.parse_entities([MessageEntity.BOT_COMMAND]).values()
349 | for match in TAG_HINTS_PATTERN.finditer(message.text):
350 | if match.groupdict()["tag_hint_with_username"] in command_texts:
351 | matches.append(match)
352 |
353 | if not matches:
354 | return None
355 |
356 | return {"matches": matches}
357 |
--------------------------------------------------------------------------------
/components/util.py:
--------------------------------------------------------------------------------
1 | # pylint:disable=cyclic-import
2 | # because we import truncate_str in entrytypes.Issue.short_description
3 | import logging
4 | import re
5 | import sys
6 | import warnings
7 | from functools import wraps
8 | from typing import Any, Callable, Coroutine, Dict, List, Optional, Pattern, Tuple, Union, cast
9 |
10 | from bs4 import MarkupResemblesLocatorWarning
11 | from telegram import Bot, Chat, InlineKeyboardButton, Message, Update, User
12 | from telegram.error import BadRequest, Forbidden, InvalidToken
13 | from telegram.ext import CallbackContext, ContextTypes, filters
14 |
15 | from .const import OFFTOPIC_CHAT_ID, ONTOPIC_CHAT_ID, RATE_LIMIT_SPACING
16 | from .taghints import TAG_HINTS
17 |
18 | # Messages may contain links that we don't care about - so let's ignore the warnings
19 | warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning, module="bs4")
20 |
21 |
22 | def get_reply_id(update: Update) -> Optional[int]:
23 | if update.effective_message and update.effective_message.reply_to_message:
24 | return update.effective_message.reply_to_message.message_id
25 | return None
26 |
27 |
28 | async def reply_or_edit(update: Update, context: CallbackContext, text: str) -> None:
29 | chat_data = cast(Dict, context.chat_data)
30 | if update.edited_message and update.edited_message.message_id in chat_data:
31 | try:
32 | await chat_data[update.edited_message.message_id].edit_text(text)
33 | except BadRequest as exc:
34 | if "not modified" not in str(exc):
35 | raise exc
36 | else:
37 | message = cast(Message, update.effective_message)
38 | issued_reply = get_reply_id(update)
39 | if issued_reply:
40 | chat_data[message.message_id] = await context.bot.send_message(
41 | message.chat_id,
42 | text,
43 | reply_to_message_id=issued_reply,
44 | )
45 | else:
46 | chat_data[message.message_id] = await message.reply_text(text)
47 |
48 |
49 | def get_text_not_in_entities(message: Message) -> str:
50 | if message.text is None:
51 | raise ValueError("Message has no text!")
52 |
53 | if sys.maxunicode != 0xFFFF:
54 | text: Union[str, bytes] = message.text.encode("utf-16-le")
55 | else:
56 | text = message.text
57 |
58 | removed_chars = 0
59 | for entity in message.entities:
60 | start = entity.offset - removed_chars
61 | end = entity.offset + entity.length - removed_chars
62 | removed_chars += entity.length
63 |
64 | if sys.maxunicode != 0xFFFF:
65 | start = 2 * start
66 | end = 2 * end
67 |
68 | text = text[:start] + text[end:] # type: ignore
69 |
70 | if isinstance(text, str):
71 | return text
72 | return text.decode("utf-16-le")
73 |
74 |
75 | def build_menu(
76 | buttons: List[InlineKeyboardButton],
77 | n_cols: int,
78 | header_buttons: List[InlineKeyboardButton] = None,
79 | footer_buttons: List[InlineKeyboardButton] = None,
80 | ) -> List[List[InlineKeyboardButton]]:
81 | menu = [buttons[i : i + n_cols] for i in range(0, len(buttons), n_cols)]
82 | if header_buttons:
83 | menu.insert(0, header_buttons)
84 | if footer_buttons:
85 | menu.append(footer_buttons)
86 | return menu
87 |
88 |
89 | async def try_to_delete(message: Message) -> bool:
90 | try:
91 | return await message.delete()
92 | except (BadRequest, Forbidden):
93 | return False
94 |
95 |
96 | async def rate_limit_tracker(_: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
97 | data = cast(Dict, context.chat_data).setdefault("rate_limit", {})
98 |
99 | for key in data.keys():
100 | data[key] += 1
101 |
102 |
103 | def rate_limit(
104 | func: Callable[[Update, ContextTypes.DEFAULT_TYPE], Coroutine[Any, Any, None]],
105 | ) -> Callable[[Update, ContextTypes.DEFAULT_TYPE], Coroutine[Any, Any, None]]:
106 | """
107 | Rate limit command so that RATE_LIMIT_SPACING non-command messages are
108 | required between invocations. Private chats are not rate limited.
109 | """
110 |
111 | @wraps(func)
112 | async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
113 | if chat := update.effective_chat:
114 | if chat.type == chat.PRIVATE:
115 | return await func(update, context)
116 |
117 | # Get rate limit data
118 | data = cast(Dict, context.chat_data).setdefault("rate_limit", {})
119 |
120 | # If we have not seen two non-command messages since last of type `func`
121 | if data.get(func, RATE_LIMIT_SPACING) < RATE_LIMIT_SPACING:
122 | logging.debug("Ignoring due to rate limit!")
123 | context.application.create_task(
124 | try_to_delete(cast(Message, update.effective_message)), update=update
125 | )
126 | return None
127 |
128 | data[func] = 0
129 | return await func(update, context)
130 |
131 | return wrapper
132 |
133 |
134 | def truncate_str(string: str, max_length: int) -> str:
135 | return (string[:max_length] + "…") if len(string) > max_length else string
136 |
137 |
138 | def build_command_list(
139 | private: bool = False, group_name: str = None, admins: bool = False
140 | ) -> List[Tuple[str, str]]:
141 | base_commands = [
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
146 | ]
147 |
148 | if private:
149 | return base_commands + hint_commands
150 |
151 | base_commands += [
152 | ("privacy", "Show the privacy policy of this bot"),
153 | ("rules", "Show the rules for this group."),
154 | ("buy", "Tell people to not do job offers."),
155 | ("token", "Warn people if they share a token."),
156 | ]
157 |
158 | if group_name is None:
159 | return base_commands + hint_commands
160 |
161 | on_off_topic = [
162 | {
163 | ONTOPIC_CHAT_ID: ("off_topic", "Redirect to the off-topic group"),
164 | OFFTOPIC_CHAT_ID: ("on_topic", "Redirect to the on-topic group"),
165 | }[group_name],
166 | ]
167 |
168 | if not admins:
169 | return base_commands + on_off_topic + hint_commands
170 |
171 | say_potato = [("say_potato", "Send captcha to a potential userbot")]
172 |
173 | return base_commands + on_off_topic + say_potato + hint_commands
174 |
175 |
176 | async def admin_check(chat_data: Dict, chat: Chat, who_banned: User) -> bool:
177 | # This check will fail if we add or remove admins at runtime but that is so rare that
178 | # we can just restart the bot in that case ...
179 | admins = chat_data.setdefault("admins", await chat.get_administrators())
180 | if who_banned not in [admin.user for admin in admins]:
181 | return False
182 | return True
183 |
184 |
185 | async def get_bot_from_token(token: str) -> Optional[User]:
186 | bot = Bot(token)
187 |
188 | try:
189 | user = await bot.get_me()
190 | return user
191 |
192 | # raised when the token isn't valid
193 | except InvalidToken:
194 | return None
195 |
196 |
197 | def update_shared_token_timestamp(message: Message, context: ContextTypes.DEFAULT_TYPE) -> str:
198 | chat_data = cast(Dict, context.chat_data)
199 | key = "shared_token_timestamp"
200 |
201 | last_time = chat_data.get(key)
202 | current_time = message.date
203 | chat_data[key] = current_time
204 |
205 | if last_time is None:
206 | return (
207 | "... Error... No time found....\n"
208 | "Oh my god. Where is the time. Has someone seen the time?"
209 | )
210 |
211 | time_diff = current_time - last_time
212 | # We do a day counter for now
213 | return f"{time_diff.days}"
214 |
215 |
216 | class FindAllFilter(filters.MessageFilter):
217 | __slots__ = ("pattern",)
218 |
219 | def __init__(self, pattern: Union[str, Pattern]):
220 | if isinstance(pattern, str):
221 | pattern = re.compile(pattern)
222 | self.pattern: Pattern = pattern
223 | super().__init__(data_filter=True)
224 |
225 | def filter(self, message: Message) -> Optional[Dict[str, List[str]]]:
226 | if message.text:
227 | matches = re.findall(self.pattern, message.text)
228 | if matches:
229 | return {"matches": matches}
230 | return {}
231 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 99
3 | target-version = ['py38', 'py39', 'py310']
4 |
5 | [tool.isort] # black config
6 | profile = "black"
7 | line_length = 99
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | pre-commit
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # Make sure to install those as additional_dependencies in the
2 | # pre-commit hooks for pylint & mypy
3 | beautifulsoup4~=4.11.0
4 | thefuzz~=0.19.0
5 | python-Levenshtein~=0.25.0
6 | python-telegram-bot[job-queue]==20.2
7 | Sphinx~=5.0.2
8 | httpx~=0.23.0
9 | gql[aiohttp]~=3.5.0
10 | async-lru~=1.0.3
11 |
--------------------------------------------------------------------------------
/rules_bot.py:
--------------------------------------------------------------------------------
1 | import configparser
2 | import logging
3 | import os
4 | from typing import cast
5 |
6 | import httpx
7 | from telegram import (
8 | BotCommandScopeAllGroupChats,
9 | BotCommandScopeAllPrivateChats,
10 | BotCommandScopeChat,
11 | BotCommandScopeChatAdministrators,
12 | Update,
13 | )
14 | from telegram.constants import ParseMode
15 | from telegram.ext import (
16 | Application,
17 | ApplicationBuilder,
18 | CallbackQueryHandler,
19 | ChatJoinRequestHandler,
20 | CommandHandler,
21 | Defaults,
22 | InlineQueryHandler,
23 | MessageHandler,
24 | TypeHandler,
25 | filters,
26 | )
27 |
28 | from components import inlinequeries
29 | from components.callbacks import (
30 | ban_sender_channels,
31 | buy,
32 | command_token_warning,
33 | compat_warning,
34 | delete_message,
35 | leave_chat,
36 | long_code_handling,
37 | off_on_topic,
38 | privacy,
39 | raise_app_handler_stop,
40 | regex_token_warning,
41 | reply_search,
42 | rules,
43 | sandwich,
44 | say_potato_button,
45 | say_potato_command,
46 | start,
47 | tag_hint,
48 | )
49 | from components.const import (
50 | COMPAT_ERRORS,
51 | DESCRIPTION,
52 | ERROR_CHANNEL_CHAT_ID,
53 | OFFTOPIC_CHAT_ID,
54 | OFFTOPIC_USERNAME,
55 | ONTOPIC_CHAT_ID,
56 | ONTOPIC_USERNAME,
57 | SHORT_DESCRIPTION,
58 | )
59 | from components.errorhandler import error_handler
60 | from components.joinrequests import join_request_buttons, join_request_callback
61 | from components.rulesjobqueue import RulesJobQueue
62 | from components.search import Search
63 | from components.taghints import TagHintFilter
64 | from components.util import FindAllFilter, build_command_list, rate_limit_tracker
65 |
66 | if os.environ.get("ROOLSBOT_DEBUG"):
67 | logging.basicConfig(
68 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG
69 | )
70 | else:
71 | logging.basicConfig(
72 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
73 | )
74 | logging.getLogger("apscheduler").setLevel(logging.WARNING)
75 | logging.getLogger("gql").setLevel(logging.WARNING)
76 |
77 | logger = logging.getLogger(__name__)
78 |
79 |
80 | async def post_init(application: Application) -> None:
81 | bot = application.bot
82 | await cast(Search, application.bot_data["search"]).initialize(application)
83 |
84 | await bot.set_my_short_description(SHORT_DESCRIPTION)
85 | await bot.set_my_description(DESCRIPTION)
86 |
87 | # set commands
88 | await bot.set_my_commands(
89 | build_command_list(private=True),
90 | scope=BotCommandScopeAllPrivateChats(),
91 | )
92 | await bot.set_my_commands(
93 | build_command_list(private=False),
94 | scope=BotCommandScopeAllGroupChats(),
95 | )
96 |
97 | for group_name in [ONTOPIC_CHAT_ID, OFFTOPIC_CHAT_ID]:
98 | await bot.set_my_commands(
99 | build_command_list(private=False, group_name=group_name),
100 | scope=BotCommandScopeChat(group_name),
101 | )
102 | await bot.set_my_commands(
103 | build_command_list(private=False, group_name=group_name, admins=True),
104 | scope=BotCommandScopeChatAdministrators(group_name),
105 | )
106 |
107 |
108 | async def post_shutdown(application: Application) -> None:
109 | await cast(Search, application.bot_data["search"]).shutdown()
110 |
111 |
112 | def main() -> None:
113 | config = configparser.ConfigParser()
114 | config.read("bot.ini")
115 |
116 | defaults = Defaults(parse_mode=ParseMode.HTML, disable_web_page_preview=True)
117 | application = (
118 | ApplicationBuilder()
119 | .token(config["KEYS"]["bot_api"])
120 | .defaults(defaults)
121 | .post_init(post_init)
122 | .post_shutdown(post_shutdown)
123 | .job_queue(RulesJobQueue())
124 | .build()
125 | )
126 |
127 | application.bot_data["search"] = Search(github_auth=config["KEYS"]["github_auth"])
128 |
129 | if "pastebin_auth" in config["KEYS"]:
130 | application.bot_data["pastebin_client"] = httpx.AsyncClient(
131 | auth=httpx.BasicAuth(username="Rools", password=config["KEYS"]["pastebin_auth"])
132 | )
133 |
134 | # Note: Order matters!
135 |
136 | # Don't handle messages that were sent in the error channel
137 | application.add_handler(
138 | MessageHandler(filters.Chat(chat_id=ERROR_CHANNEL_CHAT_ID), raise_app_handler_stop),
139 | group=-2,
140 | )
141 | # Leave groups that are not maintained by PTB
142 | application.add_handler(
143 | TypeHandler(
144 | type=Update,
145 | callback=leave_chat,
146 | ),
147 | group=-2,
148 | )
149 |
150 | application.add_handler(MessageHandler(~filters.COMMAND, rate_limit_tracker), group=-2)
151 |
152 | # We need several different patterns, so filters.REGEX doesn't do the trick
153 | # therefore we catch everything and do regex ourselves. In case the message contains a
154 | # long code block, we'll raise AppHandlerStop to prevent further processing.
155 | application.add_handler(MessageHandler(filters.TEXT, long_code_handling), group=-1)
156 |
157 | application.add_handler(
158 | MessageHandler(
159 | filters.SenderChat.CHANNEL & ~filters.ChatType.CHANNEL & ~filters.IS_AUTOMATIC_FORWARD,
160 | ban_sender_channels,
161 | block=False,
162 | )
163 | )
164 |
165 | # Simple commands
166 | # The first one also handles deep linking /start commands
167 | application.add_handler(CommandHandler("start", start))
168 | application.add_handler(CommandHandler("rules", rules))
169 | application.add_handler(CommandHandler("buy", buy))
170 | application.add_handler(CommandHandler("privacy", privacy))
171 |
172 | # Stuff that runs on every message with regex
173 | application.add_handler(
174 | MessageHandler(
175 | filters.Regex(r"(?i)[\s\S]*?((sudo )?make me a sandwich)[\s\S]*?"), sandwich
176 | )
177 | )
178 | application.add_handler(MessageHandler(filters.Regex("/(on|off)_topic"), off_on_topic))
179 |
180 | # Warn user who shared a bot's token
181 | application.add_handler(CommandHandler("token", command_token_warning))
182 | application.add_handler(
183 | MessageHandler(FindAllFilter(r"([0-9]+:[a-zA-Z0-9_-]{35})"), regex_token_warning)
184 | )
185 |
186 | # Tag hints - works with regex
187 | application.add_handler(MessageHandler(TagHintFilter(), tag_hint))
188 |
189 | # Compat tag hint via regex
190 | application.add_handler(MessageHandler(filters.Regex(COMPAT_ERRORS), compat_warning))
191 |
192 | # We need several matches so filters.REGEX is basically useless
193 | # therefore we catch everything and do regex ourselves
194 | application.add_handler(
195 | MessageHandler(filters.TEXT & filters.UpdateType.MESSAGES & ~filters.COMMAND, reply_search)
196 | )
197 |
198 | # Inline Queries
199 | application.add_handler(InlineQueryHandler(inlinequeries.inline_query))
200 |
201 | # Captcha for userbots
202 | application.add_handler(
203 | CommandHandler(
204 | "say_potato",
205 | say_potato_command,
206 | filters=filters.Chat(username=[ONTOPIC_USERNAME, OFFTOPIC_USERNAME]),
207 | )
208 | )
209 | application.add_handler(CallbackQueryHandler(say_potato_button, pattern="^POTATO"))
210 |
211 | # Join requests
212 | application.add_handler(ChatJoinRequestHandler(callback=join_request_callback, block=False))
213 | application.add_handler(CallbackQueryHandler(join_request_buttons, pattern="^JOIN"))
214 |
215 | # Delete unhandled commands - e.g. for users that like to click on blue text in other messages
216 | application.add_handler(MessageHandler(filters.COMMAND, delete_message))
217 |
218 | # Status updates
219 | application.add_handler(
220 | MessageHandler(
221 | filters.Chat(username=[ONTOPIC_USERNAME, OFFTOPIC_USERNAME])
222 | & filters.StatusUpdate.NEW_CHAT_MEMBERS,
223 | delete_message,
224 | block=False,
225 | ),
226 | group=1,
227 | )
228 |
229 | # Error Handler
230 | application.add_error_handler(error_handler)
231 |
232 | application.run_polling(allowed_updates=Update.ALL_TYPES, close_loop=False)
233 |
234 |
235 | if __name__ == "__main__":
236 | main()
237 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 99
3 | ignore = W503, W605
4 | extend-ignore = E203
5 | exclude = setup.py, setup-raw.py docs/source/conf.py, telegram/vendor
6 |
7 | [pylint.message-control]
8 | # We're ignoring a bunch of warnings, b/c rools is no high-end product …
9 | disable = C0116,C0115,R0902,C0114,R0912,R0914,R0913,R0917
10 |
11 |
12 | [mypy]
13 | warn_unused_ignores = True
14 | warn_unused_configs = True
15 | disallow_untyped_defs = True
16 | disallow_incomplete_defs = True
17 | disallow_untyped_decorators = True
18 | show_error_codes = True
19 | implicit_optional = True
20 |
--------------------------------------------------------------------------------