diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4db2d10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +.vscode +# C extensions +*.so + +# config files +*.json +.env + +# temp dir +Downloads/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +__pycache__/ +.env +venv/ +files/ +*.log +.venv diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5104229 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 drui9 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..453bd9b --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +env := .venv +deps := requirements.txt + +run: + @clear;./$(env)/bin/python start.py + +build: + @./$(env)/bin/pip install build;./$(env)/bin/python -m build + +clean: + @rm -rf dist build *.egg-info **/__pycache__/ + +stable: clean build + git push;git checkout releases;git merge main;git push;twine upload dist/*;git checkout main; + +$(env): $(deps) + python -m venv $@ + +install: $(env) + @./$(env)/bin/pip install -r $(deps) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd29ff1 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +

+ Autogram +

+ +## Installation :: Python3 +`pip install autogram` + +## `An efficient asyncronous Telegram bot API wrapper!` +Autogram is a telegram BOT API wrapper with focus on simplicity and performance. + +## `Why AutoGram?` +I need a bot framework that makes it easy to administer control remotely. + +## `Project TODOs` +- Plans to cover the entire telegram API methods. + +### `footnotes` +- `Polling` can be implemented by the user, while feeding data to the bot through `bot.parseUpdate(...)` +- Don't run multiple bots with the same `TOKEN` as this will cause update problems +- Sending unescaped special characters when using MarkdownV2 will return HTTP400 +- Have `fun` with whatever you're building `;)` + diff --git a/autogram.png b/autogram.png new file mode 100644 index 0000000..6404525 Binary files /dev/null and b/autogram.png differ diff --git a/autogram/__init__.py b/autogram/__init__.py new file mode 100644 index 0000000..146e125 --- /dev/null +++ b/autogram/__init__.py @@ -0,0 +1,25 @@ +from collections import namedtuple +# +ChatActionTypes = namedtuple( + 'ChatActions', [ + 'typing', + 'photo', + 'video', + 'audio', + 'document' + ]) +# +chat_actions = ChatActionTypes( + 'typing', + 'upload_photo', + 'upload_video', + 'upload_voice', + 'upload_document' + ) +# +from autogram.config import Start # noqa: E402 +from autogram.app import Autogram # noqa: E402 + +__all__ = [ + 'Start', 'Autogram', 'chat_actions' +] diff --git a/autogram/app.py b/autogram/app.py new file mode 100644 index 0000000..2235379 --- /dev/null +++ b/autogram/app.py @@ -0,0 +1,74 @@ +import os +import time +import queue +from autogram.base import Bot +from requests.exceptions import ConnectionError + + +class Autogram(Bot): + def __init__(self, config) -> None: + """Initialize parent object""" + self.update_handler = None + super().__init__(config) + return + + def addHandler(self, function): + self.update_handler = function + return function + + def prepare(self): + """Confirm auth through getMe(), then check update methods""" + res = self.getMe() + if not res.ok: + self.do_err(msg=str(res.json())) + self.webhook_addr = self.config.get('AUTOGRAM_ENDPOINT') or os.getenv('AUTOGRAM_ENDPOINT') # noqa: E501 + if self.webhook_addr: + res = self.setWebhook(self.webhook_addr) + if not res.ok: + self.do_err(msg='/setWebhook failed!') + else: + res = self.deleteWebhook() + if not res.ok: + self.do_err('/deleteWebhook failed!') + else: + self.short_poll() + return + + def start(self): + """Launch the bot""" + try: + self.prepare() + while not self.terminate.is_set(): + try: + if not self.update_handler: + time.sleep(5) + continue + self.update_handler(self.updates.get()) + except queue.Empty: + continue + except ConnectionError: + self.terminate.set() + self.logger.critical('Connection Error!') + finally: + self.shutdown() + + def shutdown(self): + """Gracefully terminate the bot""" + if self.terminate.is_set(): + try: + res = self.getWebhookInfo() + if not res.ok: + return + if not res.json()['result']['url']: + return + except Exception: + return + # delete webhook and exit + try: + res = self.deleteWebhook() + if not res.ok: + raise RuntimeError() + except Exception: + self.logger.critical('/deleteWebhook failed!') + finally: + self.terminate.set() diff --git a/autogram/base.py b/autogram/base.py new file mode 100644 index 0000000..146bc0d --- /dev/null +++ b/autogram/base.py @@ -0,0 +1,342 @@ +import os +import re +import time +import json +import loguru +import threading +import requests +from typing import Any +from queue import Queue +from requests.models import Response +from autogram.webserver import WebServer +from bottle import request, response, post, run, get +from autogram.config import save_config +from . import chat_actions + + +class Bot(): + endpoint = 'https://api.telegram.org/' + + def __init__(self, config :dict) -> None: + """Initialize parent database object""" + super().__init__() + self.updates = Queue() + self.logger = loguru.logger + self.terminate = threading.Event() + self.requests = requests.session() + self.config = config or self.do_err(msg='Please pass a config !') + if not self.config.get("telegram-token"): + self.config.update({ + "telegram-token" : os.getenv('AUTOGRAM_TG_TOKEN') or self.do_err(msg='Missing bot token!') # noqa: E501 + }) + + def do_err(self, err_type =RuntimeError, msg ='Error!'): + """Clean terminate the program on errors.""" + self.terminate.set() + raise err_type(msg) + + def settings(self, key :str, val: Any|None=None): + """Get or set value of key in config""" + if val: + self.config.update({key: val}) + save_config(self.config) + return val + elif not (ret := self.config.get(key)): + self.do_err(msg=f'Missing key in config: {key}') + return ret + + def media_quality(self): + """Get preffered media quality.""" + if (quality := self.settings("media-quality").lower() or 'low') == 'low': + return 0 + elif quality == 'high': + return 2 + return 1 + + def setWebhook(self, hook_addr : str): + if not re.search('^(https?):\\/\\/[^\\s/$.?#].[^\\s]*$', hook_addr): + raise RuntimeError('Invalid webhook url. format ') + # ensure hook_addr stays reachable + @get('/') + def ping(): + return json.dumps({'ok': True}) + # keep-alive service + def keep_alive(): + self.logger.info('Keep-alive started.') + while not self.terminate.is_set(): + try: + res = self.requests.get(hook_addr) + if not res.ok: + self.logger.debug('Ngrok tunnel disconnected!') + except Exception: + self.logger.debug('Connection error.') + time.sleep(3) + # start keep-alive + alive_guard = threading.Thread(target=keep_alive) + alive_guard.name = 'Autogram:Keep-alive' + alive_guard.daemon = True + alive_guard.start() + # receive updates + @post('/') + def hookHandler(): + response.content_type = 'application/json' + self.updates.put(request.json) + return json.dumps({'ok': True}) + # + def runServer(server: Any): + return run(server=server, quiet=True) + # + server = WebServer(host="0.0.0.0", port=self.settings('lport')) + serv_thread = threading.Thread(target=runServer, args=(server,)) + serv_thread.name = 'Autogram:Bottle' + serv_thread.daemon = True + serv_thread.start() + # inform telegram + url = f'{self.endpoint}bot{self.settings("telegram-token")}/setWebhook' + params = { + 'url': hook_addr + } + return self.requests.get(url, params=params) + + def short_poll(self): + """Start fetching updates in seperate thread""" + def getter(): + failed = False + offset = 0 + while not self.terminate.is_set(): + try: + data = { + 'timeout': 3, + 'params': { + 'offset': offset, + 'limit': 10, + 'timeout': 1 + } + } + res = self.getUpdates(**data) + except Exception: + time.sleep(2) + continue + # + if not res.ok: + if not failed: + time.sleep(2) + failed = True + else: + self.terminate.set() + else: + updates = res.json()['result'] + for update in updates: + offset = update['update_id'] + 1 + self.updates.put(update) + # rate-limit + poll_interval = 2 + time.sleep(poll_interval) + return + poll = threading.Thread(target=getter) + poll.name = 'Autogram:short_polling' + poll.daemon = True + poll.start() + + def getMe(self) -> Response: + """Fetch `bot` information""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/getMe' + return self.requests.get(url) + + def getUpdates(self, **kwargs) -> Response: + url = f'{self.endpoint}bot{self.settings("telegram-token")}/getUpdates' + return self.requests.get(url, **kwargs) + + def downloadFile(self, file_path: str) -> Response: + """Downloads a file with file_path got from getFile(...)""" + url = f'https://api.telegram.org/file/bot{self.settings("telegram-token")}/{file_path}' + return self.requests.get(url) + + def getFile(self, file_id: str) -> Response: + """Gets details of file with file_id""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/getFile' + return self.requests.get(url, params={'file_id': file_id}) + + def getChat(self, chat_id: int) -> Response: + """Gets information on chat_id""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/getChat' + return self.requests.get(url, params={'chat_id': chat_id}) + + def getWebhookInfo(self) -> Response: + """Gets information on currently set webhook""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/getWebhookInfo' + return self.requests.get(url) + + def sendChatAction(self, chat_id: int, action: str) -> Response: + """Sends `action` to chat_id""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/sendChatAction' + params = { + 'chat_id': chat_id, + 'action': action + } + return self.requests.get(url, params=params) + + def sendMessage(self, chat_id :int|str, text :str, **kwargs) -> Response: + """Sends `text` to `chat_id`""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/sendMessage' + params = { + 'params': { + 'chat_id': chat_id, + 'text': text, + } | kwargs + } + return self.requests.get(url, **params) + + def deleteMessage(self, chat_id: int, msg_id: int) -> Response: + """Deletes message sent <24hrs ago""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/deleteMessage' + params= { + 'chat_id': chat_id, + 'message_id': msg_id + } + return self.requests.get(url, params=params) + + def deleteWebhook(self, drop_pending = False) -> Response: + """Deletes webhook value""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/deleteWebhook' + return self.requests.get(url, params={'drop_pending_updates': drop_pending}) + + def editMessageText(self, chat_id: int, msg_id: int, text: str, **kwargs) -> Response: + """Edit message sent <24hrs ago""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/editMessageText' + params = { + 'params': { + 'text':text, + 'chat_id': chat_id, + 'message_id': msg_id + } | kwargs + } + return self.requests.get(url, **params) + + def editMessageCaption(self, chat_id: int, msg_id: int, capt: str, params={}) -> Response: # noqa: E501 + """Edit message caption""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/editMessageCaption' + params = { + 'params': { + 'chat_id': chat_id, + 'message_id': msg_id, + 'caption': capt + }|params + } + return self.requests.get(url, **params) + + def editMessageReplyMarkup(self, chat_id: int, msg_id: int, markup: str, params={}) -> Response: # noqa: E501 + """Edit reply markup""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/editMessageReplyMarkup' + params = { + 'params': { + 'chat_id':chat_id, + 'message_id':msg_id, + 'reply_markup': markup + }|params + } + return self.requests.get(url, **params) + + def forwardMessage(self, chat_id: int, from_chat_id: int, msg_id: int) -> Response: + """Forward message with message_id from from_chat_id to chat_id""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/forwardMessage' + params = { + 'params': { + 'chat_id': chat_id, + 'from_chat_id': from_chat_id, + 'message_id': msg_id + } + } + return self.requests.get(url, **params) + + def answerCallbackQuery(self, query_id, text :str|None =None, params : dict|None =None) -> Response: # noqa: E501 + """Answers callback queries with text: str of len(text) < 200""" + url = f'{self.endpoint}bot{self.settings("telegram-token")}/answerCallbackQuery' + params = params or {} + text = text or 'Updated!' + params.update({ + 'callback_query_id':query_id, + 'text': text[:200] + }) + return self.requests.get(url, params=params) + + def sendPhoto(self,chat_id: int, photo_bytes: bytes, caption: str|None = None, params: dict|None = None) -> Response: # noqa: E501 + """Sends a photo to a telegram user""" + params = params or {} + url = f'{self.endpoint}bot{self.settings("telegram-token")}/sendPhoto' + params.update({ + 'chat_id':chat_id, + 'caption': caption, + }) + self.sendChatAction(chat_id, chat_actions.photo) + return self.requests.get(url, params=params, files={'photo': photo_bytes}) + + def sendAudio(self,chat_id: int,audio :bytes|str, caption: str|None = None, params: dict|None = None) -> Response: # noqa: E501 + """Sends an audio to a telegram user""" + params = params or {} + url = f'{self.endpoint}bot{self.settings("telegram-token")}/sendAudio' + params |= { + 'chat_id':chat_id, + 'caption': caption + } + self.sendChatAction(chat_id, chat_actions.audio) + if isinstance(audio, bytes): + return self.requests.get(url, params=params, files={'audio': audio}) + params.update({'audio': audio}) + return self.requests.get(url, params=params) + + def sendDocument(self,chat_id: int ,document_bytes: bytes, caption: str|None = None, params: dict|None = None) -> Response: # noqa: E501 + """Sends a document to a telegram user""" + params = params or {} + url = f'{self.endpoint}bot{self.settings("telegram-token")}/sendDocument' + params.update({ + 'chat_id':chat_id, + 'caption':caption + }) + self.sendChatAction(chat_id, chat_actions.document) + self.requests.get(url, params=params, files={'document': document_bytes}) + + def sendVideo(self,chat_id: int ,video_bytes: bytes, caption: str|None = None, params: dict|None = None ) -> Response: # noqa: E501 + """Sends a video to a telegram user""" + params = params or {} + url = f'{self.endpoint}bot{self.settings("telegram-token")}/sendVideo' + params.update({ + 'chat_id':chat_id, + 'caption':caption + }) + self.sendChatAction(chat_id, chat_actions.video) + return self.requests.get(url, params=params,files={'video':video_bytes}) + + def forceReply(self, params: dict|None = None) -> str: + """Returns forceReply value as string""" + params = params or {} + markup = { + 'force_reply': True, + }|params + return json.dumps(markup) + + def getKeyboardMarkup(self, keys: list, params: dict|None =None) -> str: + """Returns keyboard markup as string""" + params = params or {} + markup = { + "keyboard":[row for row in keys] + } | params + return json.dumps(markup) + + def getInlineKeyboardMarkup(self, keys: list, params: dict|None =None) -> str: + params = params or {} + markup = { + 'inline_keyboard':keys + }|params + return json.dumps(markup) + + def parseFilters(self, filters: dict|None =None) -> str: + filters = filters or {} + return json.dumps(filters.keys()) + + def removeKeyboard(self, params: dict|None =None) -> str: + params = params or {} + markup = { + 'remove_keyboard': True, + }|params + return json.dumps(markup) diff --git a/autogram/config.py b/autogram/config.py new file mode 100644 index 0000000..741e4d3 --- /dev/null +++ b/autogram/config.py @@ -0,0 +1,53 @@ +import os +import sys +import json +from loguru import logger +from typing import Callable, Dict + +default_config = { + 'lport': 4004, + 'media-quality': 'low', + 'telegram-token': None +} + +def load_config(config_file : str, config_path : str): + """Load configuration file from config_path dir""" + if not os.path.exists(config_path): + os.mkdir(config_path) + # + configuration = os.path.join(config_path, config_file) + if not os.path.exists(configuration): + with open(configuration, 'w') as conf: + json.dump(default_config, conf, indent=3) + logger.critical(f"Please edit [{configuration}]") + sys.exit(0) + config = {'config-file': configuration} + with open(configuration, 'r') as conf: + config |= json.load(conf) + return config + +def save_config(config :Dict): + """config-file must be in the dictionary""" + try: + conffile = config.pop('config-file') + with open(conffile, 'w') as conf: + json.dump(config, conf, indent=2) + conf.flush() + except Exception: + conffile = conffile or None + if conffile: + return config | {'config-file': conffile} + else: + logger.critical('Failed saving config file!') + +def Start(config_file :str|None =None, config_path :str|None =None): + """Call custom function with config as parameter""" + config_path = config_path or os.getcwd() + config_file = config_file or 'autogram.json' + # + def wrapper(func: Callable): + return func(load_config(config_file, config_path)) + return wrapper +# + +__all__ = [ "Start", "save_config", "load_config"] diff --git a/autogram/updates/__init__.py b/autogram/updates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autogram/updates/base.py b/autogram/updates/base.py new file mode 100644 index 0000000..e69de29 diff --git a/autogram/webserver.py b/autogram/webserver.py new file mode 100644 index 0000000..4ea0115 --- /dev/null +++ b/autogram/webserver.py @@ -0,0 +1,29 @@ +import socket +from bottle import WSGIRefServer +from wsgiref.simple_server import make_server +from wsgiref.simple_server import WSGIRequestHandler, WSGIServer + +class WebServer(WSGIRefServer): + def run(self, app): + # + class FixedHandler(WSGIRequestHandler): + def address_string(self): + return self.client_address[0] + def log_request(*args, **kw): + if not self.quiet: + return WSGIRequestHandler.log_request(*args, **kw) + # + handler_cls = self.options.get('handler_class', FixedHandler) + server_cls = self.options.get('server_class', WSGIServer) + # + if ':' in self.host: + if getattr(server_cls, 'address_family') == socket.AF_INET: + class server_cls(server_cls): + address_family = socket.AF_INET6 + # + srv = make_server(self.host, self.port, app, server_cls, handler_cls) + self.srv = srv + srv.serve_forever() + + def shutdown(self): + self.srv.shutdown() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0a58ea --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +# pyproject.toml + +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "autogram" +version = "3.4.5" +description = "An easily extensible telegram API wrapper" +readme = "README.md" +authors = [{ name = "droi9", email = "ngaira14nelson@gmail.com" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = ["telegram", "API", "wrapper"] +dependencies = [ + "SQLAlchemy==2.0.19", + "loguru==0.7.0", + "bottle==0.12.25", + "requests==2.31.0", +] +requires-python = ">=3.6" + +[project.optional-dependencies] +dev = ["build", "twine"] + +[project.urls] +Homepage = "https://github.com/droi9/autogram" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..906d8a3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +arrow>=1.3.0 +loguru>=0.7.2 +requests>=2.32.2 +python-dotenv>=1.0.1 diff --git a/start.py b/start.py new file mode 100644 index 0000000..fd82b22 --- /dev/null +++ b/start.py @@ -0,0 +1,49 @@ +""" +@author: drui9 +@config values: + - update-endpoint: optional + - ngrok-token: optional + - bot-token: required + - cava-auth: optional +""" +import os +import requests +from unittest import mock +from autogram import Autogram +from dotenv import load_dotenv +from autogram.config import load_config + +def get_update_endpoint(validator_fn, inject_key=None): + """Get updates endpoint, silently default to telegram servers""" + if key := os.getenv('ngrok-api-key', inject_key): + header = {'Authorization': f'Bearer {key}', 'Ngrok-Version': '2'} + try: + def getter(): + if os.getenv('TESTING') == '1': + return 'http://localhost:8000' + rep = requests.get('https://api.ngrok.com/tunnels', headers=header, timeout=6) + if rep.ok and (out := validator_fn(rep.json())): + return out + return getter + except Exception: + raise + return Autogram.api_endpoint + +# modify to select one ngrok tunnel from list of tunnels +def select_tunnel(tunnels): + for tunnel in tunnels['tunnels']: + if tunnel['forwards_to'] == 'http://api:8000': + return tunnel['public_url'] + +# launcher +if __name__ == '__main__': + load_dotenv() + config = Autogram.cfg_template() + with load_config(config): + if ngrok_token := config.get('ngrok-token'): + if getter := get_update_endpoint(select_tunnel, ngrok_token): + config['update-endpoint'] = getter() + bot = Autogram(config) + bot.getter = getter # fn to get updated endpoint + bot.loop() +