diff --git a/README.md b/README.md index 536670c..eced459 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ ### Telegram bot that uses [habr article api](https://git.frik.su/n0one/habr-article-API) for sending articles to user +You can get your chatID + userID using the `/start` command. + Commands: - /start - /help -- /articles +- /last - url To do: diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..2de2bb1 --- /dev/null +++ b/compose.yml @@ -0,0 +1,13 @@ +services: + habr-bot: + image: habr-bot:latest + container_name: habr-bot + environment: + botToken: "your:telegram-token" + notifyStartStop: "False" + enableWhitelist: "True" + whitelist: "[chatid]" + habrApiUrl: "https://habr.frik.su/api/" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..f78c661 --- /dev/null +++ b/dockerfile @@ -0,0 +1,11 @@ +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "src/main.py"] diff --git a/src/app/consts.py b/src/app/consts.py new file mode 100644 index 0000000..4fa124e --- /dev/null +++ b/src/app/consts.py @@ -0,0 +1,3 @@ +FIND_HEADING_REGEXP = r'^(.*)\n(=+)$' +MAX_MESSAGE_LENGTH = 4000 +HABR_ARTICLE_PREFIX = "https://habr.com/articles/" diff --git a/src/app/httpxHandler/__init__.py b/src/app/httpxHandler/__init__.py index ebc1d01..584bd4f 100644 --- a/src/app/httpxHandler/__init__.py +++ b/src/app/httpxHandler/__init__.py @@ -1,3 +1,3 @@ -from .utils import getArticles +from .utils import getArticleByUrl, getArticles, rateArticle -__all__ = ["getArticles"] +__all__ = ["getArticles", "getArticleByUrl", "rateArticle"] diff --git a/src/app/httpxHandler/utils.py b/src/app/httpxHandler/utils.py index 7c0e570..1cb36b5 100644 --- a/src/app/httpxHandler/utils.py +++ b/src/app/httpxHandler/utils.py @@ -3,7 +3,7 @@ import json import httpx -habrApiUrl="https://habr.frik.su/api/".removesuffix("/") +from app.settings import settings async def getArticles(amount: int) -> list[tuple[str, str]]: @@ -12,7 +12,7 @@ async def getArticles(amount: int) -> list[tuple[str, str]]: payload = { "amount": amount } - response = await client.post(habrApiUrl + "/articles/get/md", json=payload) + response = await client.post(settings.habrApiUrl + "/articles/get/md", json=payload) responseJson = json.loads(response.content) @@ -20,3 +20,31 @@ async def getArticles(amount: int) -> list[tuple[str, str]]: decoded = base64.b64decode(responseJson[key].encode('ascii')).decode('utf-8') result.append((key, decoded)) return result + + +async def getArticleByUrl(url: str) -> tuple[str, str]: + result = ("", "") + async with httpx.AsyncClient() as client: + payload = { + "url": url + } + response = await client.post(settings.habrApiUrl + "/article/get/md", json=payload) + + responseJson = json.loads(response.content) + + for key in responseJson: + decoded = base64.b64decode(responseJson[key].encode('ascii')).decode('utf-8') + result = (key, decoded) + return result + + +async def rateArticle(username: str, url: str, rating: int) -> None: + async with httpx.AsyncClient() as client: + payload = { + "username": username, + "url": url, + "rating": rating + } + response = await client.post(settings.habrApiUrl + "/article/rate", json=payload) + if response.status_code != 200: + raise Exception diff --git a/src/app/settings.py b/src/app/settings.py index 49c66b2..f7e6e70 100644 --- a/src/app/settings.py +++ b/src/app/settings.py @@ -1,14 +1,22 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +def setup_settings(): + settings.habrApiUrl = settings.habrApiUrl.removesuffix("/") + + class Settings(BaseSettings): botToken: str + + notifyStartStop: bool + enableWhitelist: bool + whitelist: list - dataPath: str - jsonFilename: str + habrApiUrl: str model_config = SettingsConfigDict(env_file = ".env", env_file_encoding="utf-8") settings = Settings() # type: ignore +setup_settings() diff --git a/src/app/telegram/strings.py b/src/app/telegram/strings.py index 9a2b65b..8642daf 100644 --- a/src/app/telegram/strings.py +++ b/src/app/telegram/strings.py @@ -6,17 +6,25 @@ stopBot = "Bot stopped" unexpectedError = "Unexpected error" +invalidUrl = "Please enter a valid url to the article" + # Commands -startCommand = "Hi. I'm open source bot for reading habr articles from telegram. Here is your chatid btw: " +startCommand = "Hi. I'm open source bot for reading habr articles from telegram.\nHere is your chatID: " -helpCommand = "Commands are obvious no help needed, hehe" +helpCommand = "Commands are obvious no help needed, but I'll give you one tip - try sending the link to the Habr article" # Route status -askParam = "Enter url:" +showArticle = "Show the article" + +askUrl = "Enter url:" + +askRating = "Enter rating:" + +ratedSuccessfully = "Set new rating for article" # Data status diff --git a/src/app/telegram/telegram.py b/src/app/telegram/telegram.py index fd19182..5a1cb86 100644 --- a/src/app/telegram/telegram.py +++ b/src/app/telegram/telegram.py @@ -1,25 +1,33 @@ from aiogram import Bot, Dispatcher, F, Router from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage -from aiogram.types import BotCommand, BotCommandScopeDefault, Message +from aiogram.types import ( + BotCommand, + BotCommandScopeDefault, + CallbackQuery, + LinkPreviewOptions, + Message, +) +from aiogram.utils.keyboard import InlineKeyboardBuilder from loguru import logger from app import httpxHandler +from app.consts import HABR_ARTICLE_PREFIX from app.settings import settings -from app.telegram import strings +from app.telegram import strings, utils async def setCommands(): commands = [ - BotCommand(command='start', description='Start'), - BotCommand(command='help', description='Help'), - BotCommand(command='info', description='Info'), - BotCommand(command='last', description='last'), - BotCommand(command='remove', description='Remove') + BotCommand(command="start", description="Start"), + BotCommand(command="help", description="Help"), + BotCommand(command="last", description="Get latest article"), + BotCommand(command="rates", description="Article rates"), + BotCommand(command="rate", description="Rate article"), + BotCommand(command="remove_rate", description="Remove article rate"), ] await bot.set_my_commands(commands, BotCommandScopeDefault()) @@ -28,82 +36,174 @@ class ArticleForm(StatesGroup): url = State() class removeForm(StatesGroup): - param = State() + url = State() + +class RatingForm(StatesGroup): + url = State() + rate = State() dp = Dispatcher(storage=MemoryStorage()) bot = Bot( token=settings.botToken, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), + default=DefaultBotProperties(parse_mode=None), ) -strategyRouter = Router() +ratingRouter = Router() removeRouter = Router() +class get_whitelist_filter(): + def __call__(self, message: Message) -> bool: + if settings.enableWhitelist: + return message.chat.id in settings.whitelist + else: + return True + +whitelist_filter = get_whitelist_filter() + + +# Start command @dp.message(Command("start")) async def commandStart(message: Message) -> None: - await message.answer(strings.startCommand + str(message.chat.id)) + await message.answer(strings.startCommand + str(message.chat.id) + "\nAnd your userID: " + str(message.from_user.id)) # type: ignore -@dp.message(Command("help"), F.chat.id.in_(settings.whitelist)) +# Help command +@dp.message(whitelist_filter, Command("help")) async def commandHelp(message: Message) -> None: await message.answer(strings.helpCommand) -@dp.message(Command("info"), F.chat.id.in_(settings.whitelist)) -async def commandInfo(message: Message) -> None: - msgText = "" +# Show all user rates +@dp.message(whitelist_filter, Command("rates")) +async def commandRates(message: Message) -> None: + msgText = "Not yet implemented.." await message.answer(msgText) -@dp.message(Command("last"), F.chat.id.in_(settings.whitelist)) +# Send latest article +@dp.message(whitelist_filter, Command("last")) async def commandLast(message: Message): - articles = await httpxHandler.getArticles(1) - if articles == []: + try: + articles = await httpxHandler.getArticles(1) + if articles == []: + await message.answer(strings.unexpectedError) + else: + for (url, content) in articles: + heading = utils.getHeading(content) + articleID = utils.getArticleID(url) + + builder = InlineKeyboardBuilder() + builder.button(text=strings.showArticle, callback_data=f"showArticle_id={articleID}") + + await message.answer(url + "\n" + heading, reply_markup=builder.as_markup()) + except Exception: await message.answer(strings.unexpectedError) - else: - for (url, content) in articles: - print(url, content) - await message.answer("Url: " + url + "\n" + content) -@strategyRouter.message(Command("get"), F.chat.id.in_(settings.whitelist)) -async def commandGet(message: Message, state: FSMContext): - await message.answer(strings.askParam) +# Rate article by user +@ratingRouter.message(Command("rate"), F.chat.id.in_(settings.whitelist)) +async def commandRate(message: Message, state: FSMContext): + await message.answer(strings.askUrl) await state.set_state(ArticleForm.url) -@strategyRouter.message(F.text, ArticleForm.url) -async def captureStartPair(message: Message, state: FSMContext): - await state.update_data(pair=message.text) - data = await state.get_data() - url = data.get("url") - msgText = "" +@ratingRouter.message(F.text, ArticleForm.url) +async def captureUrl(message: Message, state: FSMContext): + await state.update_data(url=message.text) + msgText = strings.askRating await httpxHandler.getArticles(1) await message.answer(msgText) await state.clear() +@ratingRouter.message(F.text, ArticleForm.url) +async def captureRating(message: Message, state: FSMContext): + await state.update_data(rating=message.text) + data = await state.get_data() + username = str(message.from_user.id) # type: ignore + url = str(data.get("url", "")) + rating = int(data.get("rating", 0)) + try: + await httpxHandler.rateArticle(username, url, rating) + await message.answer(strings.ratedSuccessfully) + except Exception: + await message.answer(strings.unexpectedError) + + await state.clear() + + +# Send article from user url +@dp.message(whitelist_filter) +async def messageUrl(message: Message): + if message.text is None: + await message.answer(strings.unexpectedError) + return + if "https://" not in message.text and "http://" not in message.text: + await message.answer(strings.invalidUrl) + return + + try: + url, content = await httpxHandler.getArticleByUrl(message.text) + heading = utils.getHeading(content) + articleID = utils.getArticleID(url) + + builder = InlineKeyboardBuilder() + builder.button(text=strings.showArticle, callback_data=f"showArticle_id={articleID}") + + await message.answer(url + "\n" + heading, reply_markup=builder.as_markup()) + except Exception: + await message.answer(strings.unexpectedError) + + +# Handle button showArticle +@dp.callback_query(F.data.startswith("showArticle")) +async def showArticle(callback_query: CallbackQuery): + if callback_query.data is None or callback_query.message is None: + logger.error("callback_query content is None") + await callback_query.answer() + return + + # TODO: fix this + linkPreviewOptions = LinkPreviewOptions(is_disabled=True) + + try: + url = HABR_ARTICLE_PREFIX + callback_query.data.split("=")[1] + url, content = await httpxHandler.getArticleByUrl(url) + parts = utils.splitArticle(content) + for part in parts: + await callback_query.message.answer(part) + except Exception as e: + logger.error(e) + await callback_query.message.answer( + strings.unexpectedError, + link_preview_options=linkPreviewOptions + ) + + await callback_query.answer() + async def startBot(): await setCommands() - try: - for i in settings.whitelist: - await bot.send_message(chat_id=i, text=strings.startBot) - except Exception as e: - logger.error(e) + if settings.enableWhitelist and settings.notifyStartStop: + try: + for i in settings.whitelist: + await bot.send_message(chat_id=i, text=strings.startBot) + except Exception as e: + logger.error(e) logger.success("Started bot") async def stopBot(): - try: - for i in settings.whitelist: - await bot.send_message(chat_id=i, text=strings.stopBot) - except Exception as e: - logger.error(e) + if settings.enableWhitelist and settings.notifyStartStop: + try: + for i in settings.whitelist: + await bot.send_message(chat_id=i, text=strings.stopBot) + except Exception as e: + logger.error(e) logger.success("Stopped bot") async def start_bot() -> None: dp.startup.register(startBot) - dp.include_router(strategyRouter) + dp.include_router(ratingRouter) dp.include_router(removeRouter) dp.shutdown.register(stopBot) await dp.start_polling(bot) diff --git a/src/app/telegram/utils.py b/src/app/telegram/utils.py new file mode 100644 index 0000000..80588f7 --- /dev/null +++ b/src/app/telegram/utils.py @@ -0,0 +1,24 @@ +import re + +from app.consts import FIND_HEADING_REGEXP, MAX_MESSAGE_LENGTH + + +def getArticleID(url: str) -> str: + url = url.removesuffix("/") + return url[url.rfind("/")+1:] + + +def getHeading(text: str) -> str: + match = re.search(FIND_HEADING_REGEXP, text, re.MULTILINE) + + if match is None: + return "Heading not found" + else: + return match.group(1) + + +def splitArticle(text: str) -> list[str]: + parts = [] + for i in range(0, len(text), MAX_MESSAGE_LENGTH): + parts.append(text[i:i + MAX_MESSAGE_LENGTH]) + return parts